13 KiB
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.tsIfservice.tschanged 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 aresumeServiceobject literal whose methods each take aninputobject and call the mockeddb. Key behaviors to pin down:update(lines ~555–635): readsisLocked; throwsORPCError("RESUME_LOCKED")if locked; on a successfulUPDATE ... RETURNING, if!resumethrowsORPCError("NOT_FOUND"); maps a unique-constraint violation onresume_slug_user_id_uniquetoORPCError("RESUME_SLUG_ALREADY_EXISTS").setLocked(lines ~663–679),setPassword(~681–699),removePassword(~726–742): each runs anUPDATE ... RETURNING, thenif (!resume) return;(silent no-op when no row matches — this is today's behavior; capture it as-is. Plan 003 will change it.), else callsnotifyResumeUpdated.delete(lines ~744–769): transaction that throwsNOT_FOUNDwhen the row is missing andRESUME_LOCKEDwhen locked, then deletes and cleans storage.statistics.increment(lines ~199–237): twoINSERT ... ON CONFLICT DO UPDATEwrites 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 withvi.hoisted+vi.mock, then dynamically imports the service. The exact shape to copy (from that file, lines 1–68):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.tshelperscreateSelectChain(rows)andsetSelectResults(...)— reuse that pattern to script what eachdb.select/db.update().returning()call resolves to. -
Mocks this service needs beyond the applications example (grep the imports at the top of
service.tsand mock each):bcrypt(hash,compare) — used bysetPassword/verifyPassword. (The real import specifier inservice.tsisbcrypt, notbcryptjs— mock that exact module path.) Mockhashto return a fixed string andcompareto 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 ofservice.tsto get the exact import specifiers and mock each module path the same way the applications test mocks../storage/service.
-
ORPCError assertions — errors are
ORPCErrorinstances from@orpc/serverwith a.code(e.g."NOT_FOUND","RESUME_LOCKED"). Assert withawait 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 howaccess-policy.test.tsoraccess.test.tsin the same folder assert onORPCErrorand 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 silentif (!resume) return;no-ops insetLocked/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_LOCKEDwhen 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_FOUNDwhen theRETURNINGresolves to[](no row matched). - Maps a thrown error whose
cause.constraint === "resume_slug_user_id_unique"toRESUME_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:
RETURNINGresolves to[{ id, updatedAt }]→ the method resolves (returnsundefined) andnotifyResumeUpdatedwas called once with the expectedmutationvalue ("lock"/"password"). - Not-found path:
RETURNINGresolves to[]→ the method resolvesundefinedandnotifyResumeUpdatedis NOT called (this pins the current silent no-op; Plan 003 will flip this to throwingNOT_FOUND). - For
setPassword, asserthash(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: throwsINVALID_PASSWORDwhen no matching row; throwsINVALID_PASSWORDwhencompare(mocked) returnsfalse; returnstrueand callsgrantResumeAccesswhencomparereturnstrue.delete: script the transaction mock (see howapplications/service.test.tshandlesdb.transaction— if it doesn't, makedbMock.transactioninvoke its callback with atxobject exposing the sameselect/deletechain). AssertNOT_FOUNDwhen the row is missing,RESUME_LOCKEDwhen locked, and storagedeletecalled 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 onedescribeper method, followingapplications/service.test.tsas 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.tsexistspnpm --filter @reactive-resume/api test -- resume/service.test.tsexits 0 with ≥ 14 passing casespnpm --filter @reactive-resume/api testexits 0 (no sibling regressions)pnpm --filter @reactive-resume/api typecheckexits 0git status --porcelainshows onlyservice.test.ts(new) andplans/README.mdmodifiedplans/README.mdstatus row for 001 updated to DONE
STOP conditions
Stop and report back (do not improvise) if:
service.tshas 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 findingcomment, and report it.
Maintenance notes
- Plan 003 changes
setLocked/setPassword/removePasswordto throwNOT_FOUNDinstead 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_FOUNDcomes from an emptyRETURNING, not from a mock that always throws).