Skip to content

Commit edbda46

Browse files
committed
test: lock mark-sent atomicity so CI fails on status drift
Contract tests assert transitionCase runs before escalation.sent write, transition failure never mutates escalation, and migration 022 covers intake_scoping. E2E smoke rejects silent 200 without a real session.
1 parent b4a4666 commit edbda46

3 files changed

Lines changed: 284 additions & 0 deletions

File tree

docs/DEPLOY_VERCEL_HOBBY.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,13 @@ Then external tick/jobs schedulers become optional duplicates — remove them to
8585
- `vercel.json` — source of truth for Vercel crons
8686
- `.github/workflows/cron.yml` — frequent authenticated schedules
8787
- `config/VERCEL_ENV_KEYS.md` — deployment key reference
88+
89+
## Required after mark-sent fix (2026-07)
90+
91+
Apply SQL migration on the production Supabase project (SQL editor or CLI):
92+
93+
\\ ext
94+
supabase/migrations/022_mark_sent_from_prep_statuses.sql
95+
\
96+
Without 022, mark-sent still fails when \case.status\ is \intake_scoping\ even though the app code is fixed.
97+
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { NextRequest } from "next/server";
3+
import { readFileSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { ApiError } from "@/lib/api/errors";
6+
import { resetRateLimitMemory } from "@/lib/ratelimit";
7+
8+
const callOrder: string[] = [];
9+
10+
const mocks = vi.hoisted(() => ({
11+
requireRequestAuth: vi.fn(),
12+
assertCaseAccess: vi.fn(),
13+
checkProofGates: vi.fn(),
14+
assertProofGate: vi.fn(),
15+
transitionCase: vi.fn(),
16+
runCaseTick: vi.fn(),
17+
createAdminClient: vi.fn(),
18+
escalationUpdate: vi.fn(),
19+
escalationSelect: vi.fn(),
20+
evidenceSelect: vi.fn(),
21+
actionInsert: vi.fn(),
22+
}));
23+
24+
vi.mock("@/lib/api/case-access", () => ({
25+
requireRequestAuth: mocks.requireRequestAuth,
26+
assertCaseAccess: mocks.assertCaseAccess,
27+
}));
28+
vi.mock("@/lib/escalations/proof-gates", () => ({
29+
checkProofGates: mocks.checkProofGates,
30+
assertProofGate: mocks.assertProofGate,
31+
}));
32+
vi.mock("@/lib/state-machine/transition", () => ({
33+
transitionCase: (...args: unknown[]) => mocks.transitionCase(...args),
34+
}));
35+
vi.mock("@/lib/loops/case-tick", () => ({
36+
runCaseTick: (...args: unknown[]) => mocks.runCaseTick(...args),
37+
}));
38+
vi.mock("@/lib/supabase/admin", () => ({
39+
createAdminClient: mocks.createAdminClient,
40+
}));
41+
42+
import { POST as markSent } from "@/app/api/v1/cases/[id]/escalations/[eid]/mark-sent/route";
43+
44+
const caseId = "22222222-2222-4222-8222-222222222222";
45+
const escalationId = "44444444-4444-4444-8444-444444444444";
46+
const proofId = "550e8400-e29b-41d4-a716-446655440001";
47+
const auth = {
48+
userId: "user-1",
49+
guestSessionId: null,
50+
actorType: "user" as const,
51+
actorId: "user-1",
52+
};
53+
54+
function markSentRequest(body: Record<string, unknown> = {}) {
55+
return new NextRequest(
56+
`http://localhost/api/v1/cases/${caseId}/escalations/${escalationId}/mark-sent`,
57+
{
58+
method: "POST",
59+
headers: {
60+
"Content-Type": "application/json",
61+
"Idempotency-Key": "550e8400-e29b-41d4-a716-446655440099",
62+
},
63+
body: JSON.stringify({
64+
proof_evidence_id: proofId,
65+
...body,
66+
}),
67+
},
68+
);
69+
}
70+
71+
function wireAdmin(escalation: Record<string, unknown>) {
72+
mocks.escalationSelect.mockResolvedValue({ data: escalation, error: null });
73+
mocks.evidenceSelect.mockResolvedValue({
74+
data: {
75+
id: proofId,
76+
evidence_type: "letter_sent_proof",
77+
case_id: caseId,
78+
deleted_at: null,
79+
},
80+
error: null,
81+
});
82+
mocks.actionInsert.mockResolvedValue({ error: null });
83+
mocks.escalationUpdate.mockImplementation(() => {
84+
callOrder.push("escalation_update");
85+
return {
86+
eq: () => ({
87+
eq: () => ({
88+
eq: () => ({
89+
select: () => ({
90+
maybeSingle: async () => ({
91+
data: {
92+
...escalation,
93+
status: "sent",
94+
sent_at: "2026-07-25T12:00:00.000Z",
95+
sent_proof_evidence_id: proofId,
96+
response_due_at: "2026-08-01T12:00:00.000Z",
97+
},
98+
error: null,
99+
}),
100+
}),
101+
}),
102+
}),
103+
}),
104+
};
105+
});
106+
107+
mocks.createAdminClient.mockReturnValue({
108+
from: (table: string) => {
109+
if (table === "escalations") {
110+
return {
111+
select: () => ({
112+
eq: () => ({
113+
eq: () => ({
114+
maybeSingle: () => mocks.escalationSelect(),
115+
}),
116+
}),
117+
}),
118+
update: (payload: unknown) => {
119+
void payload;
120+
return mocks.escalationUpdate();
121+
},
122+
};
123+
}
124+
if (table === "evidence") {
125+
return {
126+
select: () => ({
127+
eq: () => ({
128+
eq: () => ({
129+
maybeSingle: () => mocks.evidenceSelect(),
130+
}),
131+
}),
132+
}),
133+
};
134+
}
135+
if (table === "action_logs") {
136+
return {
137+
insert: (row: unknown) => {
138+
callOrder.push("action_log");
139+
return mocks.actionInsert(row);
140+
},
141+
};
142+
}
143+
throw new Error(`unexpected table ${table}`);
144+
},
145+
});
146+
}
147+
148+
describe("mark-sent atomicity (prod QA blocker regression)", () => {
149+
beforeEach(() => {
150+
callOrder.length = 0;
151+
vi.clearAllMocks();
152+
resetRateLimitMemory();
153+
mocks.requireRequestAuth.mockResolvedValue(auth);
154+
mocks.assertCaseAccess.mockResolvedValue(undefined);
155+
mocks.checkProofGates.mockResolvedValue({ passed: true, missing: [] });
156+
mocks.assertProofGate.mockImplementation(() => undefined);
157+
mocks.runCaseTick.mockResolvedValue(undefined);
158+
mocks.transitionCase.mockImplementation(async () => {
159+
callOrder.push("transition");
160+
return { id: caseId, status: "awaiting_response" };
161+
});
162+
});
163+
164+
it("calls transitionCase before writing escalation.status=sent", async () => {
165+
wireAdmin({
166+
id: escalationId,
167+
case_id: caseId,
168+
level: "L1",
169+
status: "approved",
170+
sent_at: null,
171+
sent_proof_evidence_id: null,
172+
response_due_at: null,
173+
});
174+
175+
const response = await markSent(markSentRequest(), {
176+
params: Promise.resolve({ id: caseId, eid: escalationId }),
177+
});
178+
179+
expect(response.status).toBe(200);
180+
expect(mocks.transitionCase).toHaveBeenCalledWith(
181+
expect.objectContaining({
182+
caseId,
183+
toStatus: "awaiting_response",
184+
trigger: "user.mark_sent",
185+
payload: expect.objectContaining({
186+
escalation_level: "L1",
187+
proof_evidence_id: proofId,
188+
}),
189+
}),
190+
);
191+
expect(callOrder.indexOf("transition")).toBeGreaterThanOrEqual(0);
192+
expect(callOrder.indexOf("escalation_update")).toBeGreaterThan(
193+
callOrder.indexOf("transition"),
194+
);
195+
});
196+
197+
it("does not write escalation.sent when transitionCase fails", async () => {
198+
wireAdmin({
199+
id: escalationId,
200+
case_id: caseId,
201+
level: "L1",
202+
status: "approved",
203+
sent_at: null,
204+
sent_proof_evidence_id: null,
205+
response_due_at: null,
206+
});
207+
mocks.transitionCase.mockImplementation(async () => {
208+
callOrder.push("transition");
209+
throw new ApiError(
210+
422,
211+
"guard_failed",
212+
"invalid_transition: intake_scoping -> awaiting_response via user.mark_sent",
213+
{ guard: "invalid_transition" },
214+
);
215+
});
216+
217+
const response = await markSent(markSentRequest(), {
218+
params: Promise.resolve({ id: caseId, eid: escalationId }),
219+
});
220+
221+
expect(response.status).toBe(422);
222+
expect(callOrder).toEqual(["transition"]);
223+
expect(callOrder).not.toContain("escalation_update");
224+
expect(mocks.escalationUpdate).not.toHaveBeenCalled();
225+
});
226+
227+
it("source order: transitionCase appears before escalations update in mark-sent route", () => {
228+
const source = readFileSync(
229+
join(
230+
process.cwd(),
231+
"app/api/v1/cases/[id]/escalations/[eid]/mark-sent/route.ts",
232+
),
233+
"utf8",
234+
);
235+
const transitionAt = source.indexOf("await transitionCase(");
236+
const sentUpdateAt = source.indexOf('status: "sent"');
237+
expect(transitionAt).toBeGreaterThan(0);
238+
expect(sentUpdateAt).toBeGreaterThan(transitionAt);
239+
});
240+
241+
it("migration 022 allows mark_sent from intake_scoping", () => {
242+
const sql = readFileSync(
243+
join(
244+
process.cwd(),
245+
"supabase/migrations/022_mark_sent_from_prep_statuses.sql",
246+
),
247+
"utf8",
248+
);
249+
expect(sql).toContain("intake_scoping");
250+
expect(sql).toContain("user.mark_sent");
251+
expect(sql).toContain("awaiting_response");
252+
});
253+
});

tests/e2e/mark-sent.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,25 @@ test.describe("mark-sent proof gates @smoke", () => {
4141
const body = await response.json();
4242
expect(["validation_failed", "unauthorized"]).toContain(body.error.code);
4343
});
44+
45+
test("mark-sent never returns success without auth or real case (no silent 200)", async ({
46+
request,
47+
}) => {
48+
const response = await request.post(
49+
`/api/v1/cases/00000000-0000-4000-8000-000000000099/escalations/00000000-0000-4000-8000-000000000098/mark-sent`,
50+
{
51+
headers: {
52+
"Content-Type": "application/json",
53+
"Idempotency-Key": "550e8400-e29b-41d4-a716-446655440077",
54+
},
55+
data: {
56+
proof_evidence_id: "550e8400-e29b-41d4-a716-446655440001",
57+
},
58+
},
59+
);
60+
// Unauthenticated or missing case must not look like a successful send.
61+
expect(response.status()).not.toBe(200);
62+
expect([400, 401, 403, 404, 422]).toContain(response.status());
63+
});
4464
});
65+

0 commit comments

Comments
 (0)