# Plan 001: Characterization tests for the resume service > **Executor instructions**: Follow this plan step by step. Run every > verification command and confirm the expected result before moving to the > next step. If anything in the "STOP conditions" section occurs, stop and > report — do not improvise. When done, update the status row for this plan > in `plans/README.md`. > > **Drift check (run first)**: `git diff --stat 73daf22b2..HEAD -- packages/api/src/features/resume/service.ts` > If `service.ts` changed since this plan was written, compare the "Current > state" excerpts against the live code before proceeding; on a mismatch, > treat it as a STOP condition. ## Status - **Priority**: P1 - **Effort**: M - **Risk**: LOW - **Depends on**: none - **Category**: tests - **Planned at**: commit `73daf22b2`, 2026-07-08 ## Why this matters `packages/api/src/features/resume/service.ts` is 772 lines and owns every resume mutation — `create`, `update`, `patch` (JSON Patch application), `delete`, `duplicate`, `setLocked`, `setPassword`, `removePassword`, `verifyPassword`, `getBySlug`, plus version-history snapshotting. It has **no `service.test.ts`**. The only coverage is Playwright e2e, which exercises the happy path through the UI and cannot assert error codes, lock enforcement, or snapshot throttling in isolation. This service also has high churn (v5.2.0 added undo/redo + version history). Characterization tests here (a) catch regressions in CRUD/patch/lock/password behavior before users do, and (b) are a prerequisite for Plan 003 (which changes not-found behavior in this file) and Plan 007 (template refactor) — you cannot safely refactor code that has no behavioral net under it. The goal is **characterization tests**: capture what the code does today, locking in current behavior so later changes are deliberate, not accidental. ## Current state - `packages/api/src/features/resume/service.ts` — the service under test. Exports a `resumeService` object literal whose methods each take an `input` object and call the mocked `db`. Key behaviors to pin down: - `update` (lines ~555–635): reads `isLocked`; throws `ORPCError("RESUME_LOCKED")` if locked; on a successful `UPDATE ... RETURNING`, if `!resume` throws `ORPCError("NOT_FOUND")`; maps a unique-constraint violation on `resume_slug_user_id_unique` to `ORPCError("RESUME_SLUG_ALREADY_EXISTS")`. - `setLocked` (lines ~663–679), `setPassword` (~681–699), `removePassword` (~726–742): each runs an `UPDATE ... RETURNING`, then `if (!resume) return;` (silent no-op when no row matches — **this is today's behavior; capture it as-is. Plan 003 will change it.**), else calls `notifyResumeUpdated`. - `delete` (lines ~744–769): transaction that throws `NOT_FOUND` when the row is missing and `RESUME_LOCKED` when locked, then deletes and cleans storage. - `statistics.increment` (lines ~199–237): two `INSERT ... ON CONFLICT DO UPDATE` writes inside a transaction. - **Test convention to follow** — model the new test after the existing sibling `packages/api/src/features/applications/service.test.ts`. It mocks the DB layer with `vi.hoisted` + `vi.mock`, then dynamically imports the service. The exact shape to copy (from that file, lines 1–68): ```ts import { beforeEach, describe, expect, it, vi } from "vitest"; const dbMock = vi.hoisted(() => ({ select: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), transaction: vi.fn(), })); vi.mock("@reactive-resume/db/client", () => ({ db: dbMock })); vi.mock("@reactive-resume/db/schema", () => ({ /* stub the tables used */ })); vi.mock("drizzle-orm", () => ({ and: (...a: unknown[]) => a, eq: (...a: unknown[]) => a, isNotNull: (...a: unknown[]) => a, sql: Object.assign( (s: TemplateStringsArray, ...v: unknown[]) => ({ s, v }), { join: (v: unknown[]) => v }, ), })); const { resumeService } = await import("./service"); ``` Note the `applications/service.test.ts` helpers `createSelectChain(rows)` and `setSelectResults(...)` — reuse that pattern to script what each `db.select` / `db.update().returning()` call resolves to. - **Mocks this service needs beyond the applications example** (grep the imports at the top of `service.ts` and mock each): - `bcrypt` (`hash`, `compare`) — used by `setPassword`/`verifyPassword`. (The real import specifier in `service.ts` is `bcrypt`, not `bcryptjs` — mock that exact module path.) Mock `hash` to return a fixed string and `compare` to return a boolean you control. - The snapshot/patch helpers imported into `service.ts` (e.g. `applyResumePatchTx`, `maybeSnapshotOnSave`, `writeResumeVersion`, `notifyResumeUpdated`, `getStorageService`) — mock them so the service's own branching is what's under test, not their internals. Read the top of `service.ts` to get the exact import specifiers and mock each module path the same way the applications test mocks `../storage/service`. - **ORPCError assertions** — errors are `ORPCError` instances from `@orpc/server` with a `.code` (e.g. `"NOT_FOUND"`, `"RESUME_LOCKED"`). Assert with `await expect(fn()).rejects.toThrow()` and, where you can, check the code: `await fn().catch((e) => expect(e.code).toBe("RESUME_LOCKED"))`, or assert on `.message`. Confirm the real shape by reading how `access-policy.test.ts` or `access.test.ts` in the same folder assert on `ORPCError` and copy that style. ## Commands you will need | Purpose | Command | Expected on success | |-----------|---------------------------------------------------------------------|---------------------| | Typecheck | `pnpm --filter @reactive-resume/api typecheck` | exit 0, no errors | | Run test | `pnpm --filter @reactive-resume/api test -- resume/service.test.ts` | all pass | | All api tests | `pnpm --filter @reactive-resume/api test` | all pass | (These are the repo's real commands — package-scoped Vitest via Turborepo. Do NOT run `pnpm check`; it rewrites files.) ## Scope **In scope** (the only files you should create/modify): - `packages/api/src/features/resume/service.test.ts` (create) - `plans/README.md` (status row only) **Out of scope** (do NOT touch): - `packages/api/src/features/resume/service.ts` — this plan adds tests that characterize its *current* behavior. Do not "fix" anything you find here, even the silent `if (!resume) return;` no-ops in `setLocked`/`setPassword`/ `removePassword` — those are Plan 003's job, and this test must assert the current silent-return behavior so Plan 003's change is visible as a test diff. - Any router file (`crud.ts`, `sharing.ts`) — router tests are a separate future effort. - The real database or migrations — this is a pure unit test with a mocked db. ## Git workflow - Branch: `advisor/001-resume-service-tests` - Commit style: conventional commits (repo uses them — e.g. `test(api): add characterization tests for resume service`). - Do NOT push or open a PR unless the operator instructed it. ## Steps ### Step 1: Scaffold the test file and mocks Create `packages/api/src/features/resume/service.test.ts`. Copy the mock scaffolding pattern from `applications/service.test.ts` (lines 1–68). Read the top-of-file imports in `service.ts` and add a `vi.mock(...)` for every module it imports that touches I/O (db, schema, drizzle-orm, bcryptjs, storage service, and the patch/snapshot/notify helpers). Stub `@reactive-resume/db/schema` with the table/column objects the service references (`resume`, `resumeStatistics`, `resumeStatisticsDaily`, `user`). **Verify**: `pnpm --filter @reactive-resume/api test -- resume/service.test.ts` → the file is picked up (even with zero real tests it should not error on import; add one `it("imports", () => expect(resumeService).toBeDefined())` to confirm wiring). Expected: 1 passing test. ### Step 2: Characterize `update` Add a `describe("update")` block with these cases, asserting **current** behavior: - Throws `RESUME_LOCKED` when the pre-read returns `{ isLocked: true }`. - Returns the updated row on success (script `db.update().set().where().returning()` to resolve `[{ id, name, slug, ... }]`). - Throws `NOT_FOUND` when the `RETURNING` resolves to `[]` (no row matched). - Maps a thrown error whose `cause.constraint === "resume_slug_user_id_unique"` to `RESUME_SLUG_ALREADY_EXISTS`. **Verify**: `pnpm --filter @reactive-resume/api test -- resume/service.test.ts` → all cases pass. ### Step 3: Characterize `setLocked`, `setPassword`, `removePassword` Add a `describe` for each. For every method assert **both** paths: - Success path: `RETURNING` resolves to `[{ id, updatedAt }]` → the method resolves (returns `undefined`) and `notifyResumeUpdated` was called once with the expected `mutation` value (`"lock"` / `"password"`). - **Not-found path: `RETURNING` resolves to `[]` → the method resolves `undefined` and `notifyResumeUpdated` is NOT called** (this pins the current silent no-op; Plan 003 will flip this to throwing `NOT_FOUND`). - For `setPassword`, assert `hash` (mocked bcrypt) was called with the input password before the update. **Verify**: `pnpm --filter @reactive-resume/api test -- resume/service.test.ts` → all pass. ### Step 4: Characterize `verifyPassword` and `delete` - `verifyPassword`: throws `INVALID_PASSWORD` when no matching row; throws `INVALID_PASSWORD` when `compare` (mocked) returns `false`; returns `true` and calls `grantResumeAccess` when `compare` returns `true`. - `delete`: script the transaction mock (see how `applications/service.test.ts` handles `db.transaction` — if it doesn't, make `dbMock.transaction` invoke its callback with a `tx` object exposing the same `select`/`delete` chain). Assert `NOT_FOUND` when the row is missing, `RESUME_LOCKED` when locked, and storage `delete` called for both screenshot and pdf keys on success. **Verify**: `pnpm --filter @reactive-resume/api test -- resume/service.test.ts` → all pass. ### Step 5: Characterize `statistics.increment` Assert that a `views: true` call runs `db.transaction`, and inside it inserts into both `resumeStatistics` and `resumeStatisticsDaily` with an `onConflictDoUpdate`. You do not need to assert SQL text — assert that both `tx.insert(...)` calls happen (spy on the tx insert). This case is the safety net for Plan 005, which changes when `increment` is *called* (not its body). **Verify**: `pnpm --filter @reactive-resume/api test` → the whole api package test suite passes (no regressions in sibling tests from your new mocks). ## Test plan - New file: `packages/api/src/features/resume/service.test.ts`, structured as one `describe` per method, following `applications/service.test.ts` as the structural pattern. - Cases per method are listed in Steps 2–5 (happy path + each error/edge branch the code contains today). - Verification: `pnpm --filter @reactive-resume/api test` → all pass, including the new tests. Count the new tests in the output; expect ≥ 14 new cases. ## Done criteria Machine-checkable. ALL must hold: - [ ] `packages/api/src/features/resume/service.test.ts` exists - [ ] `pnpm --filter @reactive-resume/api test -- resume/service.test.ts` exits 0 with ≥ 14 passing cases - [ ] `pnpm --filter @reactive-resume/api test` exits 0 (no sibling regressions) - [ ] `pnpm --filter @reactive-resume/api typecheck` exits 0 - [ ] `git status --porcelain` shows only `service.test.ts` (new) and `plans/README.md` modified - [ ] `plans/README.md` status row for 001 updated to DONE ## STOP conditions Stop and report back (do not improvise) if: - `service.ts` has drifted from the excerpts above (method line ranges or error codes differ materially) — the codebase changed since this plan was written. - The mocking approach fights the service: e.g. the service imports something that runs real I/O at module load and can't be cleanly mocked. Report what and where; do not weaken the test into a no-op. - You find a genuine bug while characterizing (behavior that looks wrong). Do NOT fix it here — write the test to capture current behavior, add a `// NOTE: characterizes current behavior; see finding` comment, and report it. ## Maintenance notes - Plan 003 changes `setLocked`/`setPassword`/`removePassword` to throw `NOT_FOUND` instead of silently returning. When that lands, the "not-found path" assertions from Step 3 must be updated in the same PR — that test diff is the intended signal that behavior changed on purpose. - Plan 005 changes the *caller* of `statistics.increment` (dedup), not its body, so Step 5's test should keep passing; if it breaks, 005 changed more than intended. - A reviewer should check the mocks assert real branching, not tautologies (e.g. that `NOT_FOUND` comes from an empty `RETURNING`, not from a mock that always throws).