14 KiB
name, overview, todos, isProject
| name | overview | todos | isProject | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Public API Tests | Add a Vitest suite for `/public-api/v1` with explicit tests that Bearer API keys, scopes, and org context are enforced, plus strict JSON assertions on success and error responses. DB and external I/O stay mocked; one small Hono app uses real middleware with a mocked auth service to prove the full chain. |
|
false |
Public API Test Suite
Architecture
Three layers of tests, each mocking only the layer below it.
flowchart TD
RouteTests["Route Tests\n(mock middleware + services)"]
MiddlewareTests["Middleware Tests\n(mock DB/auth-key service)"]
ServiceTests["Service Tests\n(mock DB queries)"]
RouteTests --> MiddlewareTests
RouteTests --> ServiceTests
MiddlewareTests --> ServiceTests
Coverage vs codebase (audit)
In this repository, public API means the org automation key surface at /public-api/v1 (apps/api/src/app.ts). Nothing else is mounted there; apps/api/src/routes/v1/index.ts only wires /audience and /courses. Other Hono routes (for example /organization, /course) use session or dashboard flows and are out of scope for this plan.
Full endpoint inventory the plan must exercise (paths relative to /public-api/v1):
GET /courses,POST /coursesGET /courses/:courseId/students,GET /courses/:courseId/export,GET /courses/:courseId/structure,PUT /courses/:courseId/structureGET /courses/:courseId,PUT /courses/:courseId,DELETE /courses/:courseIdGET /audience,POST /audience,POST /audience/assign-coursesGET /audience/:memberId,PUT /audience/:memberId,DELETE /audience/:memberId
Validation for these lives under packages/utils/src/validation/public-api/ (course.ts, audience.ts only). If new routers are added under v1/, extend this inventory and add matching test files.
Auth, API keys, and middleware enforcement
Public endpoints must never rely on “trust the client.” Tests must prove the following, aligned with apps/api/src/middlewares/automation-key.ts and apps/api/src/middlewares/automation-key-scopes.ts.
Bearer and API key validation (automationKeyMiddleware)
- Missing
Authorization→ 401, bodysuccess: false, stablecode(e.g.UNAUTHORIZED). - Header present but not
Bearer <token>→ 401 (same shape). - Empty or whitespace-only token after
Bearer→ 401 (authenticate fails). authenticateOrganizationApiKeyServicethrows (invalid / unknown key) → 401, message surfaced from middleware catch path.- Successful auth →
c.set('orgId', …),c.set('actorId', …),c.set('automationKey', …); asserttouchOrganizationApiKeyLastUsedServiceis invoked with the resolved key (proves real keys go through the “touch” path). Expiry is enforced insideauthenticateOrganizationApiKeyService(apps/api/src/services/organization/automation-key.ts) — add a mock case where the service throws for an expired key and expect 401.
Scope enforcement (automationKeyScopesMiddleware(['public_api:*']))
- No
automationKeyin context (simulating a bug or bypass) → 401. - Key present but
scopesomitpublic_api:*(e.g. only MCP scopes) → 403, body includescode: FORBIDDENand a clear error string. - Key includes
public_api:*→next()runs and the handler can execute.
Full chain (no mocked middleware on the router)
Route files that mock middleware cannot prove enforcement by themselves. Add apps/api/src/routes/v1/tests/public-api-auth-chain.test.ts:
- Build a small Hono app that mounts a real v1 router (e.g.
v1CoursesRouterunder/courses) with unmockedautomationKeyMiddlewareandautomationKeyScopesMiddleware. - Mock only
@api/services/organization/automation-key(authenticateOrganizationApiKeyService,touchOrganizationApiKeyLastUsedService) and the v1 service module. - Cases: no
Authorization→ 401;Bearer+ key that authenticates to a record withoutpublic_api:*→ 403;Bearer+ key withpublic_api:*→ 200 and JSON matches the mocked service return (see next section). - Optionally repeat one path on
v1AudienceRouterso audience is covered by the same guarantees.
Service-level actor rules
Several handlers require actorId from the key’s createdByProfileId. With middleware mocked to actorId: null, assert 401 and AppError / handleError shape for: POST /courses, PUT /courses/:courseId/structure, POST /audience, PUT /audience/:memberId. This documents that automation keys without a creator profile cannot perform those mutations.
Response contracts (data must match expectations)
Do not only assert HTTP status. Parse await response.json() and validate shape and values.
Success responses
- Every happy path:
expect(body.success).toBe(true). GET/POSTcourse list/detail:datamust deep-equal (ortoMatchObjectwith explicit fields) the object/array returned by the mocked service — proves the handler forwards the service result, not an accidental empty object or wrong key.GET /audience: assertdata,pagination, andqueryexist and match the mockedlistAudienceServicereturn (same values the service produced).POSTcreate paths: assert 201 where applicable anddatamatches the created entity mock.- Where services return nested structures (course export / structure), assert at least stable top-level keys and one nested field so refactors do not silently change the contract.
Error responses
- Middleware errors:
success: false, stringerror, stringcode— match actual payloads from middleware (401/403). handleError/AppErrorpaths:success: false,errormessage,codematchingErrorCodes.*, optionalfieldwhen the route sets it. Assert status equalsAppError.statusCode(404, 401, etc.).- Validation failures (Zod / hono-openapi): assert 4xx and that the body documents validation failure in the form the stack returns (status + JSON); do not hand-wave as “400” without reading the response.
Service unit tests
- When a function maps or merges arguments (e.g.
createPublicApiCourseServiceaddsorganizationId), assert the exact arguments passed into the downstream mock (toHaveBeenCalledWith).
Files to Change
1. [apps/api/vitest.config.ts](apps/api/vitest.config.ts)
Add resolve.alias so Vitest resolves @api/* without needing tsc-alias at runtime:
import { resolve } from 'path';
export default defineConfig({
resolve: { alias: { '@api': resolve(__dirname, 'src') } },
test: { globals: true, environment: 'node', ... }
});
(@cio/* packages already resolve from node_modules at test time.)
2. Route tests (mocked middleware — fast handler + validation coverage)
These files focus on validators, handler wiring, and error mapping with middleware stubbed so orgId / actorId are deterministic.
**[apps/api/src/routes/v1/__tests__/courses.test.ts](apps/api/src/routes/v1/__tests__/courses.test.ts)**
vi.mockboth middleware modules to injectorgId/actorIdand callnext().vi.mock('@api/services/v1/course')— all service functions asvi.fn().- One
describeblock per endpoint. For each success case: set the mock return to a concrete object/array, perform the request,const body = await res.json(), thenexpect(body).toEqual({ success: true, data: … })(ortoMatchObjectonly where the payload is huge and you document which fields are guaranteed). - Cases (plus response assertions as above):
GET /— 200; service throwsAppError→ assert status +{ success: false, code, error }POST /— 201 + body matches mock; invalid body → validation status + body; middleware mock withactorIdundefined/null → 401 + error contractGET /:courseId— 200 +dataequalsgetCourseServicemock; non-UUID param → 4xx;AppError404 → 404 +COURSE_NOT_FOUND(or whatever the service throws)PUT /:courseId,DELETE /:courseId— same patternGET …/students,export,structure— 200 +dataequals mock; structure route shares export service — assert same mock used if both testedPUT …/structure— 200 + merged result shape; noactorId→ 401; invalid JSON → validation error
- Use
v1CoursesRouter.request('/path', { headers, body }).
**[apps/api/src/routes/v1/__tests__/audience.test.ts](apps/api/src/routes/v1/__tests__/audience.test.ts)**
- Same middleware mocks as courses; mock
@api/services/v1/audience. - Assert full list payload:
success,data,pagination,queryequal mockedlistAudienceServiceoutput fields. - Other endpoints: same success/error JSON rules as courses.
2b. Auth chain tests (real middleware)
**[apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts](apps/api/src/routes/v1/__tests__/public-api-auth-chain.test.ts)**
- Do not mock
automationKeyMiddleware/automationKeyScopesMiddleware. - Mock
authenticateOrganizationApiKeyServiceto return a full key-shaped object (organizationId,createdByProfileId,scopes,id, etc. — whatever the middleware reads). - Mount router under a prefix so paths match production (
/courses+GET /→ list). - Prove 401 without header, 403 when
scopesis[]or MCP-only, 200 whenscopesincludespublic_api:*anddatamatches mockedlistCoursesService(or one audience list call).
3. Middleware tests
**[apps/api/src/middlewares/__tests__/automation-key.test.ts](apps/api/src/middlewares/__tests__/automation-key.test.ts)**
vi.mock('@api/services/organization/automation-key')— mockauthenticateOrganizationApiKeyServiceandtouchOrganizationApiKeyLastUsedService.- Create a minimal Hono app that mounts the real
automationKeyMiddleware+ aGET /handler that returns context values. - Cases (each:
await res.json()and assertsuccess,code,errorwhere applicable):- No
Authorizationheader → 401 +success: false+code: UNAUTHORIZED - Header not
Bearer …→ 401 + same JSON shape authenticateOrganizationApiKeyServicethrows (invalid or expired key; service throws before returning) → 401 + body matches middleware catch behavior- Valid key → 200; response reflects
orgId,actorId,automationKeyfrom context (echo in test handler) touchOrganizationApiKeyLastUsedServicecalled exactly once with the authenticated key
- No
**[apps/api/src/middlewares/__tests__/automation-key-scopes.test.ts](apps/api/src/middlewares/__tests__/automation-key-scopes.test.ts)**
- No DB mocking needed; test
organizationApiKeyHasScopeslogic via the scope middleware itself. - Cases (parse JSON on 401/403):
automationKeynot set in context → 401 +success: false- Key with
scopes: []or withoutpublic_api:*→ 403 +code: FORBIDDEN+ error message string - Key with
public_api:*→ handler runs; response body provesnext()was reached
4. Service unit tests
For every exported function, assert return value equals the expected object (or key fields) after the mocked dependency returns — not only “did not throw.” Use expect(fn).toHaveBeenCalledWith(...) for argument contracts.
**[apps/api/src/services/v1/__tests__/course.test.ts](apps/api/src/services/v1/__tests__/course.test.ts)**
Mock: @cio/db/queries/tag (getCourseOrganizationId), @api/services/organization (getOrganizationCourses), @api/services/course/course (getCourse, updateCourse, deleteCourse, createCourse), @api/services/course/people (listCourseMembers), course-import services.
assertCourseBelongsToOrghelper: course not found → throwsAppError(404, COURSE_NOT_FOUND); org mismatch → samelistCoursesService— returnsitemsfromgetOrganizationCoursesgetCourseService— happy path; unauthorized orglistCourseStudentsService— filters byROLE.STUDENTexportCourseService— delegates togetCourseImportStructureServicecreatePublicApiCourseService— noactorId→ 401; callscreateCoursewithorganizationIdmerged inupdatePublicApiCourseService— callsupdateCourseServicedeletePublicApiCourseService— callsdeleteCourseServiceupdatePublicApiCourseStructureService— noactorId→ 401; creates draft then publishes
**[apps/api/src/services/v1/__tests__/audience.test.ts](apps/api/src/services/v1/__tests__/audience.test.ts)**
Mock: @api/services/organization (getOrgAudience, getOrgAudienceMember, removeAudienceMember), @api/services/organization/audience (assignAudienceToCourses, importAudienceMembers, updatePendingAudienceMemberEmail).
listAudienceService— delegates togetOrgAudiencecreateAudienceService— noactorId→ 401; maps payload fields correctlygetAudienceMemberService— passes throughassignAudienceCoursesService— passes throughremoveAudienceService— passes through;actorIdnull defaults toundefinedupdateAudienceService— noactorId→ 401; calls update then re-fetches member