返回 CodeWhale
community-agent-review-state.test.ts
根目录 / web / lib / community-agent-review-state.test.ts
1 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3 const mocks = vi.hoisted(() => ({
4 agentChat: vi.fn(),
5 getAgentEnv: vi.fn(),
6 validateSession: vi.fn(),
7 fetchRepoStats: vi.fn(),
8 }));
9
10 vi.mock("@/lib/community-agent", async (importOriginal) => {
11 const actual = await importOriginal<typeof import("./community-agent")>();
12 return {
13 ...actual,
14 agentChat: mocks.agentChat,
15 getAgentEnv: mocks.getAgentEnv,
16 validateSession: mocks.validateSession,
17 };
18 });
19
20 vi.mock("@/lib/github", async (importOriginal) => {
21 const actual = await importOriginal<typeof import("./github")>();
22 return { ...actual, fetchRepoStats: mocks.fetchRepoStats };
23 });
24
25 import { POST as adminPost } from "../app/api/admin/post/route";
26 import {
27 isPublishedDigest,
28 listDrafts,
29 MAX_LISTED_DRAFTS,
30 reviewedBodyHash,
31 saveDraft,
32 type AgentDraft,
33 } from "./community-agent";
34 import { runDigest, runDupes, runTriage } from "./community-agent-tasks";
35 import { FakeDraftClaimLock } from "./draft-claim-lock.fake";
36
37 /** In-memory KV that pages like Cloudflare KV (max 1000 keys per list call). */
38 class FakeKv {
39 readonly values = new Map<string, string>();
40 failPutsMatching: RegExp | null = null;
41 /** Yield to the event loop inside every call, like a network round trip. */
42 yieldEachOp = false;
43 pageSize = 1000;
44 failListAtCursor: string | null = null;
45 /** Awaited before a put lands, to hold a request at an exact step. */
46 beforePut: ((key: string, value: string) => Promise<void>) | null = null;
47
48 private async tick() {
49 if (this.yieldEachOp) await new Promise((resolve) => setTimeout(resolve, 0));
50 }
51
52 async get(key: string): Promise<string | null> {
53 await this.tick();
54 return this.values.get(key) ?? null;
55 }
56
57 async put(key: string, value: string): Promise<void> {
58 await this.tick();
59 if (this.beforePut) await this.beforePut(key, value);
60 if (this.failPutsMatching?.test(key)) throw new Error("kv put failed");
61 this.values.set(key, value);
62 }
63
64 async list(options?: { prefix?: string; limit?: number; cursor?: string }) {
65 if (options?.cursor && options.cursor === this.failListAtCursor) throw new Error("kv list failed");
66 const prefix = options?.prefix ?? "";
67 const limit = Math.min(options?.limit ?? 1000, this.pageSize);
68 const start = options?.cursor ? Number(options.cursor) : 0;
69 const all = [...this.values.keys()].filter((k) => k.startsWith(prefix)).sort();
70 const page = all.slice(start, start + limit);
71 const next = start + page.length;
72 const complete = next >= all.length;
73 return {
74 keys: page.map((name) => ({ name })),
75 list_complete: complete,
76 ...(complete ? {} : { cursor: String(next) }),
77 };
78 }
79
80 async delete(key: string): Promise<void> {
81 this.values.delete(key);
82 }
83 }
84
85 function draft(overrides: Partial<AgentDraft> = {}): AgentDraft {
86 return {
87 id: "42",
88 type: "triage",
89 targetNumber: 42,
90 bodyEn: "English body",
91 bodyZh: "中文正文",
92 generatedAt: "2026-01-01T00:00:00.000Z",
93 posted: false,
94 ...overrides,
95 };
96 }
97
98 function jsonResponse(value: unknown, status = 200): Response {
99 return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
100 }
101
102 function inputUrl(input: string | URL | Request): string {
103 if (typeof input === "string") return input;
104 return input instanceof URL ? input.toString() : input.url;
105 }
106
107 function adminRequest(body: BodyInit, headers: Record<string, string> = {}): Request {
108 return new Request("https://codewhale.net/api/admin/post", {
109 method: "POST",
110 headers: {
111 "content-type": "application/json",
112 cookie: "mt_sid=test-session",
113 origin: "https://codewhale.net",
114 ...headers,
115 },
116 body,
117 // Required by undici for a streamed request body.
118 ...(body instanceof ReadableStream ? { duplex: "half" } : {}),
119 } as RequestInit);
120 }
121
122 function postBody(value: Record<string, unknown>): string {
123 return JSON.stringify(value);
124 }
125
126 /**
127 * An admin action as the admin page sends it: bound to the stored draft text
128 * in the selected language, as shown to the maintainer.
129 */
130 async function act(kv: FakeKv, fields: Record<string, unknown>): Promise<Response> {
131 let reviewedSha256 = fields.reviewedSha256;
132 if (reviewedSha256 === undefined) {
133 const raw = kv.values.get(String(fields.draftKey));
134 const stored = raw ? (JSON.parse(raw) as AgentDraft) : null;
135 reviewedSha256 = await reviewedBodyHash(
136 stored ? (fields.lang === "zh" ? stored.bodyZh : stored.bodyEn) : ""
137 );
138 }
139 return adminPost(adminRequest(postBody({ ...fields, reviewedSha256 })));
140 }
141
142 function stubAdminEnv(kv: FakeKv, lock?: FakeDraftClaimLock) {
143 mocks.getAgentEnv.mockResolvedValue({
144 CURATED_KV: kv,
145 ...(lock ? { DRAFT_CLAIM_LOCK: lock } : {}),
146 MAINTAINER_TOKEN: "configured",
147 MAINTAINER_GITHUB_PAT: "ghp_test",
148 GITHUB_REPO: "codewhale-hq/CodeWhale",
149 });
150 }
151
152 /**
153 * GitHub stub that records every comment/issue creation. Reads (the lookup
154 * for an earlier unknown-outcome post) answer `existing` and are not posts.
155 */
156 function stubGitHub(status = 201, existing: unknown[] = []) {
157 const posts: string[] = [];
158 const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
159 const url = inputUrl(input);
160 if ((init?.method ?? "GET") === "GET") return jsonResponse(existing);
161 posts.push(url);
162 if (url.endsWith("/issues")) {
163 return jsonResponse({ number: 900, html_url: "https://github.com/codewhale-hq/CodeWhale/issues/900" }, status);
164 }
165 return jsonResponse({ id: 1 }, status);
166 });
167 vi.stubGlobal("fetch", fetchMock);
168 return posts;
169 }
170
171 const DIGEST_MODEL_OUTPUT = {
172 titleEn: "Weekly Digest",
173 titleZh: "每周摘要",
174 summaryEn: "A quiet week.",
175 summaryZh: "平静的一周。",
176 sections: [{ heading: "Shipped", items: ["PR #1: fix"] }],
177 };
178
179 function stubDigestSources() {
180 mocks.fetchRepoStats.mockResolvedValue({ stars: 1, forks: 1 });
181 vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
182 const url = inputUrl(input);
183 if (url.includes("/issues?") || url.includes("/pulls?")) return jsonResponse([]);
184 throw new Error(`unexpected URL: ${url}`);
185 }));
186 }
187
188 function onlyKey(kv: FakeKv, prefix: string): string {
189 const keys = [...kv.values.keys()].filter((k) => k.startsWith(prefix));
190 expect(keys).toHaveLength(1);
191 return keys[0];
192 }
193
194 beforeEach(() => {
195 mocks.agentChat.mockReset();
196 mocks.getAgentEnv.mockReset();
197 mocks.fetchRepoStats.mockReset();
198 mocks.validateSession.mockReset();
199 mocks.validateSession.mockResolvedValue(true);
200 });
201
202 afterEach(() => {
203 vi.unstubAllGlobals();
204 });
205
206 describe("weekly digest publication requires maintainer approval", () => {
207 it("stages the cron digest unapproved and publishes it only when the maintainer posts it", async () => {
208 const kv = new FakeKv();
209 stubDigestSources();
210 // Model output cannot self-approve.
211 mocks.agentChat.mockResolvedValue({
212 content: JSON.stringify({ ...DIGEST_MODEL_OUTPUT, approved: true }),
213 usage: { input: 1, output: 1 },
214 });
215
216 await expect(runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: true });
217 const recordKey = onlyKey(kv, "digest:weekly-");
218 const staged: unknown = JSON.parse(kv.values.get(recordKey)!);
219 expect(staged).toMatchObject({ approved: false });
220 expect(isPublishedDigest(staged)).toBe(false);
221
222 stubAdminEnv(kv);
223 const posts = stubGitHub();
224 const draftKey = onlyKey(kv, "draft:digest:");
225 const res = await act(kv, { action: "post", draftKey, lang: "en" });
226 await expect(res.json()).resolves.toMatchObject({ ok: true, published: true, number: 900 });
227 expect(posts).toHaveLength(1);
228 const published = JSON.parse(kv.values.get(recordKey)!);
229 expect(isPublishedDigest(published)).toBe(true);
230 // Only the reviewed language is published.
231 expect(published).toMatchObject({ approvedLang: "en" });
232 });
233
234 it("records the Chinese review as the published language when posted from the zh admin", async () => {
235 const kv = new FakeKv();
236 stubDigestSources();
237 mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
238 await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
239
240 stubAdminEnv(kv);
241 stubGitHub();
242 const draftKey = onlyKey(kv, "draft:digest:");
243 const res = await act(kv, { action: "post", draftKey, lang: "zh" });
244 await expect(res.json()).resolves.toMatchObject({ ok: true, published: true });
245 expect(JSON.parse(kv.values.get(onlyKey(kv, "digest:weekly-"))!)).toMatchObject({ approvedLang: "zh" });
246 });
247
248 it("refuses to discard a posted digest, which would silently unpublish it", async () => {
249 const kv = new FakeKv();
250 stubDigestSources();
251 mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
252 await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
253
254 stubAdminEnv(kv);
255 stubGitHub();
256 const draftKey = onlyKey(kv, "draft:digest:");
257 expect((await act(kv, { action: "post", draftKey, lang: "en" })).status).toBe(200);
258
259 const discard = await act(kv, { action: "discard", draftKey });
260 expect(discard.status).toBe(409);
261 expect(isPublishedDigest(JSON.parse(kv.values.get(onlyKey(kv, "digest:weekly-"))!))).toBe(true);
262 });
263
264 it("does not publish an edited digest's unedited model text", async () => {
265 const kv = new FakeKv();
266 stubDigestSources();
267 mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
268 await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
269
270 stubAdminEnv(kv);
271 stubGitHub();
272 const draftKey = onlyKey(kv, "draft:digest:");
273 const res = await act(kv, { action: "post", draftKey, lang: "en", editedBody: "# Edited" });
274 // The maintainer is told why the digest is not on /digest.
275 await expect(res.json()).resolves.toMatchObject({
276 ok: true,
277 published: false,
278 warning: expect.stringContaining("/digest"),
279 });
280 expect(isPublishedDigest(JSON.parse(kv.values.get(onlyKey(kv, "digest:weekly-"))!))).toBe(false);
281 });
282
283 it("discarding a digest removes the staged record and the cron does not regenerate it", async () => {
284 const kv = new FakeKv();
285 stubDigestSources();
286 mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
287 await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
288
289 stubAdminEnv(kv);
290 const draftKey = onlyKey(kv, "draft:digest:");
291 const res = await act(kv, { action: "discard", draftKey });
292 await expect(res.json()).resolves.toMatchObject({ ok: true, action: "discarded" });
293 expect([...kv.values.keys()].filter((k) => k.startsWith("digest:weekly-"))).toEqual([]);
294
295 mocks.agentChat.mockClear();
296 await expect(runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ skipped: true });
297 expect(mocks.agentChat).not.toHaveBeenCalled();
298 expect(kv.values.has(draftKey)).toBe(false);
299 });
300
301 it("surfaces a GitHub failure on a digest post as a 502 and does not publish", async () => {
302 const kv = new FakeKv();
303 stubDigestSources();
304 mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
305 await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
306
307 stubAdminEnv(kv);
308 stubGitHub(422);
309 const draftKey = onlyKey(kv, "draft:digest:");
310 const res = await act(kv, { action: "post", draftKey, lang: "en" });
311 expect(res.status).toBe(502);
312 const payload = await res.json();
313 expect(payload.ok).toBeUndefined();
314 expect(payload.error).toMatch(/^GitHub 422/);
315 expect(isPublishedDigest(JSON.parse(kv.values.get(onlyKey(kv, "digest:weekly-"))!))).toBe(false);
316 });
317
318 it("finds a digest issue an unknown-outcome attempt created and publishes without a second issue", async () => {
319 const kv = new FakeKv();
320 stubDigestSources();
321 mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
322 await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
323 stubAdminEnv(kv);
324 const draftKey = onlyKey(kv, "draft:digest:");
325 stubGitHub(504);
326 expect((await act(kv, { action: "post", draftKey, lang: "en" })).status).toBe(502);
327 // The claim and "posting" marker lapse (15 minutes in production).
328 for (const key of [...kv.values.keys()]) if (/^draft-(claim|resolved):digest:/.test(key)) kv.values.delete(key);
329
330 const body = JSON.parse(kv.values.get(draftKey)!).bodyEn as string;
331 const title = body.split("\n")[0].replace(/^#+\s*/, "").trim();
332 const posts = stubGitHub(201, [{ number: 901, html_url: "https://github.com/codewhale-hq/CodeWhale/issues/901", title, body }]);
333 const retry = await act(kv, { action: "post", draftKey, lang: "en" });
334 await expect(retry.json()).resolves.toMatchObject({ ok: true, number: 901, published: true });
335 expect(posts).toEqual([]);
336 });
337
338 it("never publishes an older record when a later run saved its draft but not its record", async () => {
339 const kv = new FakeKv();
340 stubDigestSources();
341 mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
342 await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
343 const recordKey = onlyKey(kv, "digest:weekly-");
344 const recordA = kv.values.get(recordKey)!;
345
346 // Run B saves its draft, then its record write fails: draft B + record A.
347 mocks.agentChat.mockResolvedValue({
348 content: JSON.stringify({ ...DIGEST_MODEL_OUTPUT, titleEn: "Digest B", summaryEn: "Run B." }),
349 usage: { input: 1, output: 1 },
350 });
351 kv.failPutsMatching = /^digest:weekly-/;
352 await expect(runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: false });
353 kv.failPutsMatching = null;
354 const draftKey = onlyKey(kv, "draft:digest:");
355 expect(JSON.parse(kv.values.get(draftKey)!).bodyEn).toContain("Digest B");
356 expect(kv.values.get(recordKey)).toBe(recordA);
357
358 stubAdminEnv(kv);
359 const bodies: string[] = [];
360 vi.stubGlobal("fetch", vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
361 bodies.push(String(init?.body));
362 return jsonResponse({ number: 900, html_url: "https://github.com/codewhale-hq/CodeWhale/issues/900" }, 201);
363 }));
364 const res = await act(kv, { action: "post", draftKey, lang: "en" });
365 await expect(res.json()).resolves.toMatchObject({
366 ok: true,
367 published: false,
368 warning: expect.stringContaining("does not match the reviewed text"),
369 });
370 // GitHub got the reviewed B; /digest shows nothing rather than A.
371 expect(bodies).toHaveLength(1);
372 expect(JSON.parse(bodies[0]).title).toBe("Digest B");
373 expect(isPublishedDigest(JSON.parse(kv.values.get(recordKey)!))).toBe(false);
374 });
375
376 it("publishes only the fields of a record that matches the reviewed text", async () => {
377 const kv = new FakeKv();
378 stubDigestSources();
379 mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
380 await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
381 const recordKey = onlyKey(kv, "digest:weekly-");
382 kv.values.set(recordKey, JSON.stringify({ ...JSON.parse(kv.values.get(recordKey)!), extra: "<script>" }));
383
384 stubAdminEnv(kv);
385 stubGitHub();
386 const draftKey = onlyKey(kv, "draft:digest:");
387 await expect((await act(kv, { action: "post", draftKey, lang: "zh" })).json()).resolves.toMatchObject({
388 published: true,
389 });
390 const published = JSON.parse(kv.values.get(recordKey)!);
391 expect(isPublishedDigest(published)).toBe(true);
392 expect(published).not.toHaveProperty("extra");
393 expect(published).toMatchObject({ approvedLang: "zh", titleZh: DIGEST_MODEL_OUTPUT.titleZh });
394 });
395
396 it("hides legacy records that were never approved", () => {
397 const legacy = { ...DIGEST_MODEL_OUTPUT, weekId: "2026-W01", generatedAt: "2026-01-05T00:00:00.000Z" };
398 expect(isPublishedDigest(legacy)).toBe(false);
399 expect(isPublishedDigest({ ...legacy, approved: true })).toBe(false);
400 expect(isPublishedDigest({ ...legacy, approved: true, approvedLang: "en" })).toBe(true);
401 expect(
402 isPublishedDigest({ ...legacy, approved: true, approvedLang: "en", sections: [{ heading: "x", items: [1] }] })
403 ).toBe(false);
404 });
405 });
406
407 describe("resolved drafts are not resurrected by the cron", () => {
408 function stubIssues(updatedAt: string) {
409 vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
410 const url = inputUrl(input);
411 if (!url.includes("/issues?")) throw new Error(`unexpected URL: ${url}`);
412 return jsonResponse([{
413 number: 42,
414 title: "Issue",
415 body: "body",
416 updated_at: updatedAt,
417 html_url: "https://github.com/codewhale-hq/CodeWhale/issues/42",
418 labels: [],
419 }]);
420 }));
421 }
422
423 beforeEach(() => {
424 mocks.agentChat.mockResolvedValue({
425 content: JSON.stringify({ bodyEn: "review", bodyZh: "审阅" }),
426 usage: { input: 1, output: 1 },
427 });
428 });
429
430 it("does not redraft a discarded triage item", async () => {
431 const kv = new FakeKv();
432 await saveDraft(kv, draft());
433 stubAdminEnv(kv);
434 const res = await act(kv, { action: "discard", draftKey: "draft:triage:42" });
435 expect(res.status).toBe(200);
436
437 stubIssues("2020-01-01T00:00:00.000Z");
438 await expect(runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ processed: 0, skipped: 1 });
439 expect(mocks.agentChat).not.toHaveBeenCalled();
440 expect(kv.values.has("draft:triage:42")).toBe(false);
441 });
442
443 it("does not turn a posted triage draft back into a pending one after the issue updates", async () => {
444 const kv = new FakeKv();
445 await saveDraft(kv, draft());
446 stubAdminEnv(kv);
447 stubGitHub();
448 const res = await act(kv, { action: "post", draftKey: "draft:triage:42", lang: "en" });
449 expect(res.status).toBe(200);
450
451 // Our own comment bumps updated_at past the draft's generatedAt.
452 stubIssues(new Date().toISOString());
453 await runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
454 expect(mocks.agentChat).not.toHaveBeenCalled();
455 expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ posted: true });
456 });
457
458 it("drafts again when the item has new activity well after the maintainer's decision", async () => {
459 const kv = new FakeKv();
460 await saveDraft(kv, draft());
461 stubAdminEnv(kv);
462 stubGitHub();
463 expect((await act(kv, { action: "post", draftKey: "draft:triage:42" })).status).toBe(200);
464
465 // New commits or a reply a day later.
466 stubIssues(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString());
467 await expect(runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ processed: 1 });
468 expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ posted: false, bodyEn: "review" });
469
470 // The fresh draft can be posted.
471 const posts = stubGitHub();
472 expect((await act(kv, { action: "post", draftKey: "draft:triage:42" })).status).toBe(200);
473 expect(posts).toHaveLength(1);
474 });
475
476 it("does not let the dupes run overwrite a posted dupes draft", async () => {
477 const kv = new FakeKv();
478 kv.values.set("draft:dupes:7", JSON.stringify(draft({ id: "7", type: "dupes", targetNumber: 7, posted: true })));
479 vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(
480 [1, 2, 7].map((n) => ({ number: n, title: `t${n}`, updated_at: "2020-01-01T00:00:00.000Z", html_url: `u${n}` }))
481 )));
482 mocks.agentChat.mockResolvedValue({
483 content: JSON.stringify({ suggestions: [{ targetNumber: 1, duplicateNumber: 7, reason: "r", bodyEn: "dup", bodyZh: "重复" }] }),
484 usage: { input: 1, output: 1 },
485 });
486
487 await expect(runDupes({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: true, processed: 0 });
488 expect(JSON.parse(kv.values.get("draft:dupes:7")!)).toMatchObject({ posted: true });
489 });
490 });
491
492 describe("admin post action is idempotent and bounded", () => {
493 it("refuses to post a draft that is already posted", async () => {
494 const kv = new FakeKv();
495 kv.values.set("draft:triage:42", JSON.stringify(draft({ posted: true })));
496 stubAdminEnv(kv);
497 const posts = stubGitHub();
498
499 const res = await act(kv, { action: "post", draftKey: "draft:triage:42" });
500 expect(res.status).toBe(409);
501 expect(posts).toEqual([]);
502 });
503
504 it("returns ok when saving state fails after GitHub accepted the comment, and a retry does not post twice", async () => {
505 const kv = new FakeKv();
506 await saveDraft(kv, draft());
507 stubAdminEnv(kv);
508 const posts = stubGitHub();
509 kv.failPutsMatching = /^draft:triage:42$/;
510
511 const first = await act(kv, { action: "post", draftKey: "draft:triage:42" });
512 expect(first.status).toBe(200);
513 await expect(first.json()).resolves.toMatchObject({ ok: true, action: "posted", warning: expect.any(String) });
514
515 const retry = await act(kv, { action: "post", draftKey: "draft:triage:42" });
516 expect(retry.status).toBe(409);
517 expect(posts).toHaveLength(1);
518 });
519
520 it("releases the claim when GitHub rejects the post so the maintainer can retry", async () => {
521 const kv = new FakeKv();
522 await saveDraft(kv, draft());
523 stubAdminEnv(kv);
524 stubGitHub(422);
525
526 const failed = await act(kv, { action: "post", draftKey: "draft:triage:42" });
527 expect(failed.status).toBe(502);
528
529 const posts = stubGitHub();
530 const retry = await act(kv, { action: "post", draftKey: "draft:triage:42" });
531 expect(retry.status).toBe(200);
532 expect(posts).toHaveLength(1);
533 });
534
535 it.each([500, 502, 504, 429])(
536 "keeps the claim when GitHub answers %i, since the post may have been created",
537 async (status) => {
538 const kv = new FakeKv();
539 await saveDraft(kv, draft());
540 stubAdminEnv(kv);
541 const posts = stubGitHub(status);
542
543 const failed = await act(kv, { action: "post", draftKey: "draft:triage:42" });
544 expect(failed.status).toBe(502);
545 await expect(failed.json()).resolves.toMatchObject({ error: expect.stringContaining("check GitHub before retrying") });
546
547 stubGitHub();
548 const retry = await act(kv, { action: "post", draftKey: "draft:triage:42" });
549 expect(retry.status).toBe(409);
550 expect(posts).toHaveLength(1);
551 }
552 );
553
554 it("never posts twice when two requests for the same draft race", async () => {
555 const kv = new FakeKv();
556 await saveDraft(kv, draft());
557 kv.yieldEachOp = true;
558 stubAdminEnv(kv);
559 const posts = stubGitHub();
560
561 const results = await Promise.all([
562 act(kv, { action: "post", draftKey: "draft:triage:42" }),
563 act(kv, { action: "post", draftKey: "draft:triage:42" }),
564 ]);
565 const statuses = results.map((r) => r.status);
566 // This fake KV makes every write visible at once, so the claim is exact
567 // here; on Workers KV it is best effort (see claimDraft).
568 expect(statuses.filter((s) => s === 200)).toHaveLength(posts.length);
569 expect(posts.length).toBeLessThanOrEqual(1);
570 expect(statuses.every((s) => s === 200 || s === 409)).toBe(true);
571
572 // Refused claims are released, so the maintainer's retry posts exactly once.
573 if (posts.length === 0) {
574 expect((await act(kv, { action: "post", draftKey: "draft:triage:42" })).status).toBe(200);
575 }
576 expect(posts).toHaveLength(1);
577 expect((await act(kv, { action: "post", draftKey: "draft:triage:42" })).status).toBe(409);
578 expect(posts).toHaveLength(1);
579 });
580
581 /**
582 * Hold two admin actions at the reviewer's interleaving: both pass the
583 * "no decision yet" check, the first takes its claim and reaches GitHub,
584 * and only then does the second's claim write land.
585 */
586 function raceAfterAbsenceChecks(kv: FakeKv) {
587 let prechecks = 0;
588 let bothChecked!: () => void;
589 const bothCheckedP = new Promise<void>((resolve) => (bothChecked = resolve));
590 let firstAtGitHub!: () => void;
591 const firstAtGitHubP = new Promise<void>((resolve) => (firstAtGitHub = resolve));
592 const realGet = kv.get.bind(kv);
593 kv.get = async (key: string) => {
594 const value = await realGet(key);
595 if (key.startsWith("draft-resolved:") && ++prechecks === 2) bothChecked();
596 return value;
597 };
598 let claimPuts = 0;
599 kv.beforePut = async (key, value) => {
600 const isClaim = key.startsWith("draft-claim:") || (key.startsWith("draft-resolved:") && value.includes('"claim"'));
601 if (!isClaim) return;
602 // A discard only ever overlaps a post already at GitHub. Gate it on
603 // that directly so the test does not depend on which request the
604 // scheduler lets claim first (a discard claiming first would park the
605 // post forever waiting for a GitHub call that never happens).
606 if (value.startsWith("discard:") || value.includes('"discard')) {
607 await firstAtGitHubP;
608 return;
609 }
610 claimPuts += 1;
611 await (claimPuts === 1 ? bothCheckedP : firstAtGitHubP);
612 };
613 const posts: string[] = [];
614 vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
615 posts.push(inputUrl(input));
616 firstAtGitHub();
617 // Let the second request finish its claim while this post is in flight.
618 await new Promise((resolve) => setTimeout(resolve, 20));
619 return jsonResponse({ id: 1 }, 201);
620 }));
621 return posts;
622 }
623
624 it("refuses a second post whose claim lands while the first is at GitHub", async () => {
625 const kv = new FakeKv();
626 await saveDraft(kv, draft());
627 kv.yieldEachOp = true;
628 stubAdminEnv(kv);
629 const posts = raceAfterAbsenceChecks(kv);
630
631 const results = await Promise.all([
632 act(kv, { action: "post", draftKey: "draft:triage:42" }),
633 act(kv, { action: "post", draftKey: "draft:triage:42" }),
634 ]);
635
636 expect(results.map((r) => r.status).sort()).toEqual([200, 409]);
637 expect(posts).toHaveLength(1);
638 });
639
640 it("refuses a discard that overlaps an in-flight post of the same draft", async () => {
641 const kv = new FakeKv();
642 await saveDraft(kv, draft());
643 kv.yieldEachOp = true;
644 stubAdminEnv(kv);
645 const posts = raceAfterAbsenceChecks(kv);
646
647 const [post, discard] = await Promise.all([
648 act(kv, { action: "post", draftKey: "draft:triage:42" }),
649 act(kv, { action: "discard", draftKey: "draft:triage:42" }),
650 ]);
651
652 expect(post.status).toBe(200);
653 expect(discard.status).toBe(409);
654 expect(posts).toHaveLength(1);
655 expect(JSON.parse(kv.values.get("draft-resolved:triage:42")!)).toMatchObject({ state: "posted" });
656 expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ posted: true });
657 });
658
659 it("claims without KV list, whose results can lag writes by up to a minute", async () => {
660 const kv = new FakeKv();
661 await saveDraft(kv, draft());
662 stubAdminEnv(kv);
663 const posts = stubGitHub();
664 // A stale listing that never shows claim keys must not affect the claim.
665 const realList = kv.list.bind(kv);
666 kv.list = async (options) => {
667 if (options?.prefix?.startsWith("draft-claim:")) throw new Error("claim must not list");
668 return realList(options);
669 };
670
671 const res = await act(kv, { action: "post", draftKey: "draft:triage:42" });
672 expect(res.status).toBe(200);
673 expect(posts).toHaveLength(1);
674 expect([...kv.values.keys()].filter((k) => k.startsWith("draft-claim:"))).toEqual([]);
675 });
676
677 it("answers a claim it cannot read back as retryable, not as a lost race, and the retry posts", async () => {
678 const kv = new FakeKv();
679 await saveDraft(kv, draft());
680 stubAdminEnv(kv);
681 const posts = stubGitHub();
682 // The first read-back of the request's own claim misses the write.
683 const realGet = kv.get.bind(kv);
684 let claimReads = 0;
685 kv.get = async (key: string) => {
686 if (key.startsWith("draft-claim:") && ++claimReads === 2) return null;
687 return realGet(key);
688 };
689
690 const first = await act(kv, { action: "post", draftKey: "draft:triage:42" });
691 expect(first.status).toBe(503);
692 expect(posts).toEqual([]);
693 expect([...kv.values.keys()].filter((k) => k.startsWith("draft-claim:") || k.startsWith("draft-resolved:"))).toEqual([]);
694
695 const retry = await act(kv, { action: "post", draftKey: "draft:triage:42" });
696 expect(retry.status).toBe(200);
697 expect(posts).toHaveLength(1);
698 });
699
700 it("refuses a post while another request's claim is visible, and leaves that claim alone", async () => {
701 const kv = new FakeKv();
702 await saveDraft(kv, draft());
703 stubAdminEnv(kv);
704 const posts = stubGitHub();
705 kv.values.set("draft-claim:triage:42", "other-request");
706
707 for (const action of ["post", "discard"]) {
708 const res = await act(kv, { action, draftKey: "draft:triage:42" });
709 expect(res.status).toBe(409);
710 }
711 expect(posts).toEqual([]);
712 expect(kv.values.get("draft-claim:triage:42")).toBe("other-request");
713 });
714
715 it("treats a repeated discard as a no-op, including one that sees the marker only at claim time", async () => {
716 const kv = new FakeKv();
717 await saveDraft(kv, draft());
718 stubAdminEnv(kv);
719 stubGitHub();
720
721 const first = await act(kv, { action: "discard", draftKey: "draft:triage:42" });
722 expect(first.status).toBe(200);
723 // A double click whose request still reads the draft (its delete not yet
724 // visible), first past the marker, then with the marker missed by the
725 // route's own check and seen only by the claim.
726 const staleDraft = JSON.stringify(draft());
727 kv.values.set("draft:triage:42", staleDraft);
728 const again = await act(kv, { action: "discard", draftKey: "draft:triage:42" });
729 expect(again.status).toBe(200);
730 await expect(again.json()).resolves.toEqual({ ok: true, action: "discarded" });
731
732 kv.values.set("draft:triage:42", staleDraft);
733 const realGet = kv.get.bind(kv);
734 let markerReads = 0;
735 kv.get = async (key: string) => {
736 if (key.startsWith("draft-resolved:") && ++markerReads === 1) return null;
737 return realGet(key);
738 };
739 const racing = await act(kv, { action: "discard", draftKey: "draft:triage:42" });
740 expect(racing.status).toBe(200);
741 expect(JSON.parse(kv.values.get("draft-resolved:triage:42")!)).toMatchObject({ state: "discarded" });
742 expect([...kv.values.keys()].filter((k) => k.startsWith("draft-claim:"))).toEqual([]);
743 });
744
745 it("refuses an action on a draft regenerated after the page loaded, before calling GitHub", async () => {
746 const kv = new FakeKv();
747 await saveDraft(kv, draft({ bodyEn: "reviewed A" }));
748 stubAdminEnv(kv);
749 const shownA = await reviewedBodyHash("reviewed A");
750 // The cron regenerates the pending draft while the admin page shows A.
751 await saveDraft(kv, draft({ bodyEn: "unseen B", generatedAt: "2026-01-02T00:00:00.000Z" }));
752 const posts = stubGitHub();
753
754 for (const action of ["post", "discard"]) {
755 const res = await act(kv, { action, draftKey: "draft:triage:42", lang: "en", reviewedSha256: shownA });
756 expect(res.status).toBe(409);
757 await expect(res.json()).resolves.toMatchObject({ error: expect.stringContaining("changed") });
758 }
759 expect(posts).toEqual([]);
760 expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ bodyEn: "unseen B", posted: false });
761 expect([...kv.values.keys()].filter((k) => k.startsWith("draft-resolved:") || k.startsWith("draft-claim:"))).toEqual([]);
762 });
763
764 it("requires the reviewed-text hash", async () => {
765 const kv = new FakeKv();
766 await saveDraft(kv, draft());
767 stubAdminEnv(kv);
768 const posts = stubGitHub();
769
770 const res = await adminPost(adminRequest(postBody({ action: "post", draftKey: "draft:triage:42" })));
771 expect(res.status).toBe(400);
772 expect(posts).toEqual([]);
773 });
774
775 it("answers malformed JSON with a JSON 400", async () => {
776 const kv = new FakeKv();
777 stubAdminEnv(kv);
778
779 const res = await adminPost(adminRequest("{not json"));
780 expect(res.status).toBe(400);
781 await expect(res.json()).resolves.toEqual({ error: "invalid JSON body" });
782 });
783
784 it("counts streamed body bytes instead of trusting a missing Content-Length", async () => {
785 const kv = new FakeKv();
786 stubAdminEnv(kv);
787 const chunk = new TextEncoder().encode("x".repeat(40_000));
788 const stream = new ReadableStream<Uint8Array>({
789 start(controller) {
790 controller.enqueue(chunk);
791 controller.enqueue(chunk);
792 controller.close();
793 },
794 });
795
796 const res = await adminPost(adminRequest(stream));
797 expect(res.status).toBe(413);
798 });
799 });
800
801 describe("admin claims with the DRAFT_CLAIM_LOCK Durable Object bound", () => {
802 const KEY = "draft:triage:42";
803
804 async function lockedAdmin(status = 201) {
805 const kv = new FakeKv();
806 await saveDraft(kv, draft());
807 kv.yieldEachOp = true;
808 const lock = new FakeDraftClaimLock();
809 stubAdminEnv(kv, lock);
810 const claimKeysWritten: string[] = [];
811 kv.beforePut = async (key) => {
812 if (key.startsWith("draft-claim:")) claimKeysWritten.push(key);
813 };
814 const posts = stubGitHub(status);
815 return { kv, lock, posts, claimKeysWritten };
816 }
817
818 /** What a location whose KV has not yet seen the first request's writes reads. */
819 function forgetDecision(kv: FakeKv) {
820 kv.values.set(KEY, JSON.stringify(draft()));
821 kv.values.delete("draft-resolved:triage:42");
822 }
823
824 it("lets exactly one of several concurrent posts through, without KV claim keys", async () => {
825 const { kv, lock, posts, claimKeysWritten } = await lockedAdmin();
826
827 const results = await Promise.all(
828 Array.from({ length: 4 }, () => act(kv, { action: "post", draftKey: KEY }))
829 );
830
831 expect(results.map((r) => r.status).sort()).toEqual([200, 409, 409, 409]);
832 expect(posts).toHaveLength(1);
833 expect(claimKeysWritten).toEqual([]);
834 expect(lock.calls.filter((c) => c.op === "claim")).toHaveLength(4);
835 });
836
837 it("refuses a retry that reads stale KV after a recorded post, until the hold ends", async () => {
838 const { kv, lock, posts } = await lockedAdmin();
839 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(200);
840
841 forgetDecision(kv);
842 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(409);
843 expect(posts).toHaveLength(1);
844
845 // Past the hold the KV marker has propagated; a location that really
846 // lacks it (a reopened draft) may act again.
847 lock.now += 2 * 60 * 1000;
848 forgetDecision(kv);
849 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(200);
850 expect(posts).toHaveLength(2);
851 });
852
853 it("keeps the lease after an unknown GitHub outcome, and lets it expire", async () => {
854 const { kv, lock, posts } = await lockedAdmin(502);
855 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
856 stubGitHub();
857
858 // Even without the KV "posting" marker, the lease refuses a retry...
859 kv.values.delete("draft-resolved:triage:42");
860 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(409);
861 expect(posts).toHaveLength(1);
862
863 // ...until it expires, so the draft is not wedged for good.
864 lock.now += 15 * 60 * 1000;
865 const retryPosts = stubGitHub();
866 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(200);
867 expect(retryPosts).toHaveLength(1);
868 });
869
870 it("looks for the earlier attempt's comment after an unknown outcome instead of posting it twice", async () => {
871 const { kv, lock } = await lockedAdmin(502);
872 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
873 lock.now += 15 * 60 * 1000;
874 kv.values.delete("draft-resolved:triage:42");
875
876 // GitHub did create it: the late retry records it and posts nothing.
877 const posts = stubGitHub(201, [{ id: 7, body: "English body" }]);
878 const retry = await act(kv, { action: "post", draftKey: KEY });
879 expect(retry.status).toBe(200);
880 await expect(retry.json()).resolves.toMatchObject({ ok: true, action: "posted", warning: expect.stringContaining("not posted again") });
881 expect(posts).toEqual([]);
882 expect(JSON.parse(kv.values.get("draft-resolved:triage:42")!).state).toBe("posted");
883 expect(kv.values.has("draft-post-unknown:triage:42")).toBe(false);
884 });
885
886 it("posts nothing when the lookup for an earlier attempt fails, and frees the draft", async () => {
887 const { kv, lock } = await lockedAdmin(502);
888 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
889 lock.now += 15 * 60 * 1000;
890 kv.values.delete("draft-resolved:triage:42");
891
892 const posts: string[] = [];
893 vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
894 if ((init?.method ?? "GET") === "GET") return jsonResponse({ message: "down" }, 503);
895 posts.push(inputUrl(input));
896 return jsonResponse({ id: 1 }, 201);
897 }));
898 const blocked = await act(kv, { action: "post", draftKey: KEY });
899 expect(blocked.status).toBe(502);
900 await expect(blocked.json()).resolves.toMatchObject({ error: expect.stringContaining("nothing was posted") });
901 expect(posts).toEqual([]);
902
903 // Once GitHub answers and has no such comment, the retry posts once.
904 const retryPosts = stubGitHub();
905 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(200);
906 expect(retryPosts).toHaveLength(1);
907 });
908
909 it("frees the draft at once when GitHub definitely rejected the post", async () => {
910 const { kv } = await lockedAdmin(422);
911 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
912 const posts = stubGitHub();
913 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(200);
914 expect(posts).toHaveLength(1);
915 });
916
917 it("treats a double-clicked discard as one discard, concurrent or repeated", async () => {
918 const { kv, posts } = await lockedAdmin();
919
920 const both = await Promise.all([
921 act(kv, { action: "discard", draftKey: KEY }),
922 act(kv, { action: "discard", draftKey: KEY }),
923 ]);
924 expect(both.map((r) => r.status)).toEqual([200, 200]);
925 for (const res of both) await expect(res.json()).resolves.toEqual({ ok: true, action: "discarded" });
926 expect(JSON.parse(kv.values.get("draft-resolved:triage:42")!)).toMatchObject({ state: "discarded" });
927
928 // A third click served from stale KV hits the post-discard hold.
929 forgetDecision(kv);
930 expect((await act(kv, { action: "discard", draftKey: KEY })).status).toBe(200);
931 expect(posts).toEqual([]);
932 });
933
934 it("refuses a discard while a post holds the draft, and a post after a discard", async () => {
935 const { kv, posts } = await lockedAdmin();
936 // Send the discard only once the post holds the draft (it has reached
937 // GitHub), so the outcome does not depend on which request the
938 // scheduler delivers to the lock first.
939 const postP = act(kv, { action: "post", draftKey: KEY });
940 await vi.waitFor(() => expect(posts).toHaveLength(1));
941 const discard = await act(kv, { action: "discard", draftKey: KEY });
942 const post = await postP;
943 expect([post.status, discard.status]).toEqual([200, 409]);
944 expect(posts).toHaveLength(1);
945
946 const other = await lockedAdmin();
947 expect((await act(other.kv, { action: "discard", draftKey: KEY })).status).toBe(200);
948 forgetDecision(other.kv);
949 expect((await act(other.kv, { action: "post", draftKey: KEY })).status).toBe(409);
950 expect(other.posts).toEqual([]);
951 });
952
953 it("fails closed when the lock cannot be reached", async () => {
954 const { kv, lock, posts } = await lockedAdmin();
955 lock.get = () => ({ act: async () => { throw new Error("DO unavailable"); } }) as never;
956 const res = await act(kv, { action: "post", draftKey: KEY });
957 expect(res.status).toBe(500);
958 expect(posts).toEqual([]);
959 expect(kv.values.get("draft-resolved:triage:42")).toBeUndefined();
960 });
961 });
962
963 describe("admin draft queue", () => {
964 function fill(kv: FakeKv, count: number) {
965 for (let i = 1; i <= count; i++) {
966 kv.values.set(`draft:triage:${i}`, JSON.stringify(draft({ id: String(i), targetNumber: i })));
967 }
968 }
969
970 it("follows the KV list cursor past the first page", async () => {
971 const kv = new FakeKv();
972 kv.pageSize = 100;
973 fill(kv, 250);
974
975 await expect(listDrafts(kv)).resolves.toHaveLength(250);
976 });
977
978 it("reads at most MAX_LISTED_DRAFTS drafts in one request", async () => {
979 const kv = new FakeKv();
980 fill(kv, MAX_LISTED_DRAFTS + 1_000);
981 const get = vi.spyOn(kv, "get");
982
983 await expect(listDrafts(kv)).resolves.toHaveLength(MAX_LISTED_DRAFTS);
984 expect(get).toHaveBeenCalledTimes(MAX_LISTED_DRAFTS);
985 });
986
987 it("keeps the drafts already read when a later list page fails", async () => {
988 const kv = new FakeKv();
989 kv.pageSize = 100;
990 kv.failListAtCursor = "200";
991 fill(kv, 250);
992 vi.spyOn(console, "error").mockImplementation(() => {});
993
994 await expect(listDrafts(kv)).resolves.toHaveLength(200);
995 });
996 });
997
998 describe("post retry durability and generation admission", () => {
999 const KEY = "draft:triage:42";
1000 async function prepared() {
1001 const kv = new FakeKv();
1002 await saveDraft(kv, draft());
1003 const lock = new FakeDraftClaimLock();
1004 stubAdminEnv(kv, lock);
1005 return { kv, lock };
1006 }
1007 it("posts nothing when an earlier receipt cannot be read", async () => {
1008 const { kv } = await prepared();
1009 const originalGet = kv.get.bind(kv);
1010 kv.get = async (key) => { if (key.startsWith("draft-post-unknown:")) throw new Error("unavailable"); return originalGet(key); };
1011 const posts = stubGitHub();
1012 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(503);
1013 expect(posts).toEqual([]);
1014 });
1015 it("writes the attempt before dispatch and refuses an unavailable receipt write", async () => {
1016 const { kv } = await prepared();
1017 kv.failPutsMatching = /^draft-post-unknown:/;
1018 const posts = stubGitHub();
1019 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
1020 expect(posts).toEqual([]);
1021 });
1022 it("finds an earlier post past page one even when KV lost the receipt", async () => {
1023 const { kv, lock } = await prepared();
1024 stubGitHub(504);
1025 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
1026 lock.now += 15 * 60 * 1000;
1027 kv.values.delete("draft-resolved:triage:42");
1028 kv.values.delete("draft-post-unknown:triage:42");
1029 const posts: string[] = [], pages: string[] = [];
1030 vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
1031 const url = inputUrl(input);
1032 if (init?.method === "POST") { posts.push(url); return jsonResponse({ id: 2 }, 201); }
1033 const page = new URL(url).searchParams.get("page"); pages.push(page!);
1034 return jsonResponse(page === "1" ? Array.from({ length: 100 }, () => ({ body: "another body" })) : [{ id: 7, body: "English body" }]);
1035 }));
1036 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(200);
1037 expect(pages).toEqual(["1", "2"]);
1038 expect(posts).toEqual([]);
1039 });
1040 it("a bounded incomplete reconciliation never licenses another post", async () => {
1041 const { kv, lock } = await prepared();
1042 stubGitHub(504);
1043 await act(kv, { action: "post", draftKey: KEY });
1044 lock.now += 15 * 60 * 1000;
1045 kv.values.delete("draft-resolved:triage:42");
1046 const posts: string[] = [];
1047 vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
1048 if (init?.method === "POST") posts.push(inputUrl(input));
1049 return jsonResponse(Array.from({ length: 100 }, () => ({ body: "another body" })));
1050 }));
1051 expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
1052 expect(posts).toEqual([]);
1053 });
1054 it("refuses changed retry text while the earlier attempt is unresolved", async () => {
1055 const { kv, lock } = await prepared();
1056 stubGitHub(504);
1057 await act(kv, { action: "post", draftKey: KEY });
1058 lock.now += 15 * 60 * 1000;
1059 kv.values.delete("draft-resolved:triage:42");
1060 const posts = stubGitHub();
1061 expect((await act(kv, { action: "post", draftKey: KEY, editedBody: "different body" })).status).toBe(409);
1062 expect(posts).toEqual([]);
1063 });
1064 it("admits one overlapping generation batch and holds completed KV propagation", async () => {
1065 const kv = new FakeKv(), lock = new FakeDraftClaimLock();
1066 const env = { CURATED_KV: kv, DRAFT_CLAIM_LOCK: lock, DEEPSEEK_API_KEY: "k" };
1067 vi.stubGlobal("fetch", vi.fn(async () => jsonResponse([{ number: 42, title: "issue", body: "body", updated_at: "2020-01-01T00:00:00.000Z", html_url: "https://github.com/codewhale-hq/CodeWhale/issues/42", labels: [] }])));
1068 let finish!: (value: unknown) => void;
1069 mocks.agentChat.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue({ content: JSON.stringify({ bodyEn: "other", bodyZh: "另一个" }), usage: { input: 1, output: 1 } });
1070 const first = runTriage(env);
1071 await vi.waitFor(() => expect(mocks.agentChat).toHaveBeenCalledOnce());
1072 const second = await runTriage(env);
1073 expect(second).toMatchObject({ processed: 0, skipped: 1 });
1074 finish({ content: JSON.stringify({ bodyEn: "generated", bodyZh: "生成" }), usage: { input: 1, output: 1 } });
1075 expect(await first).toMatchObject({ processed: 1 });
1076 kv.values.delete(KEY); // another location's stale KV must not spend again.
1077 expect(await runTriage(env)).toMatchObject({ processed: 0, skipped: 1 });
1078 expect(mocks.agentChat).toHaveBeenCalledOnce();
1079 });
1080 it("missing or failed durable generation admission starts no provider call", async () => {
1081 const kv = new FakeKv();
1082 const reads = vi.fn(); vi.stubGlobal("fetch", reads);
1083 expect(await runTriage({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).toMatchObject({ skipped: true });
1084 const broken = { idFromName: () => ({ toString: () => "x" }), get: () => ({ act: async () => { throw new Error("down"); } }) };
1085 expect(await runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: broken, DEEPSEEK_API_KEY: "k" })).toMatchObject({ skipped: true });
1086 expect(reads).not.toHaveBeenCalled(); expect(mocks.agentChat).not.toHaveBeenCalled();
1087 });
1088 });
1089
1089 lines TYPESCRIPT