1.4.7 Integration tests: kind/depth/cycle triggers, concurrent key allocation, revision atomicity, link cycle + ready-set
Estimate: 26m · Depends on: 1.4.4, 1.4.5, 1.4.6
Comprehensive integration tests against a real Postgres covering the structural invariants. Per Yue's standing rule (and feedback_planner_decides_user_approves's no-mocks-on-DB principle), these tests exercise the actual Postgres triggers + RLS + service-layer transactions. They are the safety net Epic 2-7 will lean on every time they touch the work-item path; landing them in 1.4 means later Epics can confidently extend without fear of silently breaking the kind-parent rule.
Test areas:
- Kind-parent matrix: for every (parentKind, childKind) pair in the AC matrix, assert legal pairs succeed and illegal pairs reject with
IllegalParentTypeError. Drive viaworkItemsService.createWorkItemAND via direct repo writes (proves the trigger fires regardless of path). - Depth limit: build a 4-deep chain (epic → story → task → subtask). Asserts the 4-level legal max succeeds. Then attempt to create a child of the depth-4 subtask — rejects with
DepthLimitExceededError. - Cycle prevention: create A → B → C; attempt to re-parent A under C — rejects with
ParentCycleError. - Concurrent key allocation: fire 20 concurrent
createWorkItemcalls against the same project; assert the resulting keys are 1..20 (or contiguous from the starting count) with no duplicates and no lost slots beyond rolled-back transactions (none roll back here, so no gaps expected). - Workspace RLS isolation: user A in workspace W1, user B in workspace W2. With W1's GUC set, B's items are invisible. With NO GUC set, all tables return zero rows.
WITH CHECK: A cannot insert a work item with W2'sworkspace_id(constraint rejection). - Project RLS narrowing: with W1 + P1 set, P2's items are invisible (even though both belong to W1). With W1 set and no project, both projects' items are visible.
- Fractional indexing: reorder via
moveWorkItem; the resulting positions sort lexically as expected. Edge cases: move-to-start, move-to-end, move-between. - Revision atomicity: inject a failure inside the revision-repo write; assert the work-item write rolls back too. Inject a failure in the work-item write; assert no revision row is left orphaned.
- Revision diff correctness: update only
title→ revision has{ title: { from, to } }and nothing else. Updatetitle+assigneeId→ both in the diff. No-op patch → no revision written, no transaction opened. UpdateexplanationMdwhileexplanationSource = ai_draft→ diff includes bothexplanationMdAND the auto-transitionedexplanationSource: { from: ai_draft, to: user_edited }(the source transition is itself an audit-worthy event). - Explanation-source state machine: a fresh-row create with
explanationMd = NULLhas sourceuser_authored. A subsequent update writingexplanationMd+ explicit sourceai_draft(the path AI-drafting Epic 7 takes) sets source =ai_draft. A subsequent update patching onlyexplanationMd(no explicit source in the patch) auto-transitions source touser_edited— verified by an integration test in 1.4.7. A subsequent update with explicit sourceai_draft(a regenerate) resets the badge. Direct PATCH ofexplanationSourcealone (no explanationMd) is allowed (e.g., user manually dismisses the AI-draft badge) — the diff records it. - Link cycle prevention: A is_blocked_by B; attempt B is_blocked_by A → trigger rejects with
WorkItemLinkCycleError. Deeper cycle: A→B→C→A; rejected on the closing edge.relates_toA↔B does NOT trigger cycle check (intended). - Self-link rejection: linkWorkItems(A, A, *) rejects with
SelfLinkError. - Cross-workspace link rejection: A in W1, B in W2; linkWorkItems(A, B, *) rejects with
CrossWorkspaceLinkErrorat the service layer, and the trigger backstops if the service is bypassed. - Symmetric
relates_to: linkWorkItems(A, B, 'relates_to') produces TWO rows (A→B and B→A); unlinkWorkItems on either deletes both. - Duplicate link rejection: linkWorkItems(A, B, 'is_blocked_by') called twice — second call rejects with
DuplicateLinkError(unique constraint). - Link revision audit: linkWorkItems writes a revision row on the from item with the added link in the diff; unlinkWorkItems writes the removal.
- Ready-set predicate: A is_blocked_by B + C;
isReady(A)returns false. Mark B done → still false (C blocks). Mark C done → returns true. Unlink C while B is still open → returns false again. - Cross-project links work: A in project P1, B in project P2 (same workspace W1); linkWorkItems succeeds.
getBlockers(A)returns B even when called under a P1-narrowed project context (link table is workspace-scoped, not project-scoped).
What you'll do: Add tests under tests/integration/work-items/. Use the test-fixture helpers from 1.2.7 + 1.3.5 that spin up users + workspaces + projects against the real Postgres. Add workItemFixtures.ts in tests/fixtures for repeatable work-item setups. Tests run against the same docker-compose'd Postgres as 1.2.7 / 1.3.5.
Acceptance criteria
- New tests under
tests/integration/work-items/cover every area enumerated above; every test names the invariant it protects in its describe-block. - Tests run against the real Postgres (per Yue's no-mocks rule).
- Concurrent-key-allocation test uses
Promise.allover 20 createWorkItem calls; resulting keys form a contiguous set. - RLS tests reset the GUC between cases to avoid cross-test bleed; the test suite passes when run in parallel under Vitest's default concurrency.
- Revision-atomicity tests prove transactional rollback for both the work-item-fails and revision-fails directions.
- Test suite green; CI green; coverage report shows the workItemsService + workItemRepository + workItemLinkRepository + workItemRevisionRepository at >= 90%.
- All quality gates green; existing suite stays green.
Context refs
tests/integration/projects/andtests/integration/workspaces/— the integration-test pattern, fixture helperstests/fixtures/userFixtures.ts/workspaceFixtures.ts/projectFixtures.tslib/services/workItemsService.ts+ repos + errors (the system under test)- This Story page — the invariants the tests must protect