Files
classroomio/.cursor/plans/public_api_tests_89047418.plan.md
T

220 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: Public API Tests
overview: 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.
todos:
- id: vitest-config
content: Update vitest.config.ts with @api path alias
status: pending
- id: courses-routes
content: Create courses route tests with strict response body assertions
status: pending
- id: audience-routes
content: Create audience route tests with strict response body assertions
status: pending
- id: auth-chain
content: Add public-api-auth-chain tests (real middleware + mocked authenticate + mocked services)
status: pending
- id: automation-key-mw
content: Create automation-key middleware tests (Bearer + touch + JSON error shape)
status: pending
- id: automation-scopes-mw
content: Create automation-key-scopes tests (403 without public_api:*, JSON shape)
status: pending
- id: course-service
content: Create v1 course service unit tests (returns + mock call args)
status: pending
- id: audience-service
content: Create v1 audience service unit tests (returns + mock call args)
status: pending
isProject: false
---
# Public API Test Suite
## Architecture
Three layers of tests, each mocking only the layer below it.
```mermaid
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](apps/api/src/app.ts)). Nothing else is mounted there; [apps/api/src/routes/v1/index.ts](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 /courses`
- `GET /courses/:courseId/students`, `GET /courses/:courseId/export`, `GET /courses/:courseId/structure`, `PUT /courses/:courseId/structure`
- `GET /courses/:courseId`, `PUT /courses/:courseId`, `DELETE /courses/:courseId`
- `GET /audience`, `POST /audience`, `POST /audience/assign-courses`
- `GET /audience/:memberId`, `PUT /audience/:memberId`, `DELETE /audience/:memberId`
Validation for these lives under [packages/utils/src/validation/public-api/](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](apps/api/src/middlewares/automation-key.ts) and [apps/api/src/middlewares/automation-key-scopes.ts](apps/api/src/middlewares/automation-key-scopes.ts).
**Bearer and API key validation (`automationKeyMiddleware`)**
- Missing `Authorization` → **401**, body `success: false`, stable `code` (e.g. `UNAUTHORIZED`).
- Header present but not `Bearer <token>` → **401** (same shape).
- Empty or whitespace-only token after `Bearer` → **401** (authenticate fails).
- `authenticateOrganizationApiKeyService` throws (invalid / unknown key) → **401**, message surfaced from middleware catch path.
- Successful auth → `c.set('orgId', …)`, `c.set('actorId', …)`, `c.set('automationKey', …)`; assert `touchOrganizationApiKeyLastUsedService` is invoked with the resolved key (proves real keys go through the “touch” path). Expiry is enforced inside `authenticateOrganizationApiKeyService` ([apps/api/src/services/organization/automation-key.ts](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 `automationKey` in context (simulating a bug or bypass) → **401**.
- Key present but `scopes` omit `public_api:*` (e.g. only MCP scopes) → **403**, body includes `code: FORBIDDEN` and 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](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. `v1CoursesRouter` under `/courses`) with **unmocked** `automationKeyMiddleware` and `automationKeyScopesMiddleware`.
- 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 **without** `public_api:*` → 403; `Bearer` + key with `public_api:*` → 200 and JSON matches the mocked service return (see next section).
- Optionally repeat one path on `v1AudienceRouter` so 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/POST` course list/detail: `data` must **deep-equal** (or `toMatchObject` with 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`: assert `data`, `pagination`, and `query` exist and match the mocked `listAudienceService` return (same values the service produced).
- `POST` create paths: assert **201** where applicable and `data` matches 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`, string `error`, string `code` — match actual payloads from middleware (401/403).
- `handleError` / `AppError` paths: `success: false`, `error` message, `code` matching `ErrorCodes.*`, optional `field` when the route sets it. Assert status equals `AppError.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. `createPublicApiCourseService` adds `organizationId`), 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:
```ts
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.mock` both middleware modules to inject `orgId`/`actorId` and call `next()`.
- `vi.mock('@api/services/v1/course')` — all service functions as `vi.fn()`.
- One `describe` block per endpoint. For **each** success case: set the mock return to a **concrete** object/array, perform the request, `const body = await res.json()`, then `expect(body).toEqual({ success: true, data: … })` (or `toMatchObject` only where the payload is huge and you document which fields are guaranteed).
- Cases (plus response assertions as above):
- `GET /` — 200; service throws `AppError` → assert status + `{ success: false, code, error }`
- `POST /` — 201 + body matches mock; invalid body → validation status + body; middleware mock with `actorId` undefined/null → 401 + error contract
- `GET /:courseId` — 200 + `data` equals `getCourseService` mock; non-UUID param → 4xx; `AppError` 404 → 404 + `COURSE_NOT_FOUND` (or whatever the service throws)
- `PUT /:courseId`, `DELETE /:courseId` — same pattern
- `GET …/students`, `export`, `structure` — 200 + `data` equals mock; structure route shares export service — assert same mock used if both tested
- `PUT …/structure` — 200 + merged result shape; no `actorId` → 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`, `query` **equal** mocked `listAudienceService` output 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 `authenticateOrganizationApiKeyService` to 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 `scopes` is `[]` or MCP-only, **200** when `scopes` includes `public_api:*` and `data` matches mocked `listCoursesService` (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')` — mock `authenticateOrganizationApiKeyService` and `touchOrganizationApiKeyLastUsedService`.
- Create a minimal Hono app that mounts the real `automationKeyMiddleware` + a `GET /` handler that returns context values.
- Cases (each: `await res.json()` and assert `success`, `code`, `error` where applicable):
- No `Authorization` header → 401 + `success: false` + `code: UNAUTHORIZED`
- Header not `Bearer …` → 401 + same JSON shape
- `authenticateOrganizationApiKeyService` throws (invalid or expired key; service throws before returning) → 401 + body matches middleware catch behavior
- Valid key → 200; response reflects `orgId`, `actorId`, `automationKey` from context (echo in test handler)
- `touchOrganizationApiKeyLastUsedService` called exactly once with the authenticated key
`**[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 `organizationApiKeyHasScopes` logic via the scope middleware itself.
- Cases (parse JSON on 401/403):
- `automationKey` not set in context → 401 + `success: false`
- Key with `scopes: []` or without `public_api:*` → 403 + `code: FORBIDDEN` + error message string
- Key with `public_api:*` → handler runs; response body proves `next()` 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.
- `assertCourseBelongsToOrg` helper: course not found → throws `AppError(404, COURSE_NOT_FOUND)`; org mismatch → same
- `listCoursesService` — returns `items` from `getOrganizationCourses`
- `getCourseService` — happy path; unauthorized org
- `listCourseStudentsService` — filters by `ROLE.STUDENT`
- `exportCourseService` — delegates to `getCourseImportStructureService`
- `createPublicApiCourseService` — no `actorId` → 401; calls `createCourse` with `organizationId` merged in
- `updatePublicApiCourseService` — calls `updateCourseService`
- `deletePublicApiCourseService` — calls `deleteCourseService`
- `updatePublicApiCourseStructureService` — no `actorId` → 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 to `getOrgAudience`
- `createAudienceService` — no `actorId` → 401; maps payload fields correctly
- `getAudienceMemberService` — passes through
- `assignAudienceCoursesService` — passes through
- `removeAudienceService` — passes through; `actorId` null defaults to `undefined`
- `updateAudienceService` — no `actorId` → 401; calls update then re-fetches member
## Total: 8 new files, 1 updated file