| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { |
| 3 | deleteDraft, |
| 4 | draftKey, |
| 5 | getDraft, |
| 6 | hasFreshDraft, |
| 7 | listDrafts, |
| 8 | parseDraftKey, |
| 9 | saveDraft, |
| 10 | type AgentDraft, |
| 11 | } from "./community-agent"; |
| 12 | |
| 13 | class FakeKv { |
| 14 | readonly values = new Map<string, string>(); |
| 15 | readonly deleted: string[] = []; |
| 16 | |
| 17 | async get(key: string): Promise<string | null> { |
| 18 | return this.values.get(key) ?? null; |
| 19 | } |
| 20 | |
| 21 | async put(key: string, value: string): Promise<void> { |
| 22 | this.values.set(key, value); |
| 23 | } |
| 24 | |
| 25 | async list(options?: { prefix?: string; limit?: number }): Promise<{ keys: { name: string }[] }> { |
| 26 | const prefix = options?.prefix ?? ""; |
| 27 | const limit = options?.limit ?? Number.POSITIVE_INFINITY; |
| 28 | return { |
| 29 | keys: [...this.values.keys()] |
| 30 | .filter((key) => key.startsWith(prefix)) |
| 31 | .slice(0, limit) |
| 32 | .map((name) => ({ name })), |
| 33 | }; |
| 34 | } |
| 35 | |
| 36 | async delete(key: string): Promise<void> { |
| 37 | this.deleted.push(key); |
| 38 | this.values.delete(key); |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | function draft(overrides: Partial<AgentDraft> = {}): AgentDraft { |
| 43 | return { |
| 44 | id: "42", |
| 45 | type: "triage", |
| 46 | bodyEn: "review", |
| 47 | bodyZh: "审阅", |
| 48 | generatedAt: "2026-07-17T00:00:00.000Z", |
| 49 | posted: false, |
| 50 | ...overrides, |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | describe("community-agent draft boundary", () => { |
| 55 | it("uses the same canonical key for save and freshness checks", async () => { |
| 56 | const kv = new FakeKv(); |
| 57 | await saveDraft(kv, draft()); |
| 58 | |
| 59 | expect([...kv.values.keys()]).toEqual(["draft:triage:42"]); |
| 60 | await expect(hasFreshDraft(kv, "triage", "42", "2026-07-16T00:00:00.000Z")).resolves.toBe(true); |
| 61 | await expect(hasFreshDraft(kv, "issue", "42", "2026-07-16T00:00:00.000Z")).resolves.toBe(false); |
| 62 | }); |
| 63 | |
| 64 | it("uses the canonical PR review namespace for freshness checks", async () => { |
| 65 | const kv = new FakeKv(); |
| 66 | await saveDraft(kv, draft({ type: "pr-review" })); |
| 67 | |
| 68 | expect([...kv.values.keys()]).toEqual(["draft:pr-review:42"]); |
| 69 | await expect(hasFreshDraft(kv, "pr-review", "42", "2026-07-16T00:00:00.000Z")).resolves.toBe(true); |
| 70 | await expect(hasFreshDraft(kv, "pr", "42", "2026-07-16T00:00:00.000Z")).resolves.toBe(false); |
| 71 | }); |
| 72 | |
| 73 | it("accepts only canonical draft namespaces and bounded ids", () => { |
| 74 | expect(parseDraftKey("draft:pr-review:123")).toEqual({ type: "pr-review", id: "123" }); |
| 75 | expect(parseDraftKey("dispatch:latest")).toBeNull(); |
| 76 | expect(parseDraftKey("draft:unknown:123")).toBeNull(); |
| 77 | expect(parseDraftKey("draft:triage:bad:id")).toBeNull(); |
| 78 | expect(() => draftKey("triage", "bad:id")).toThrow("invalid draft id"); |
| 79 | }); |
| 80 | |
| 81 | it("rejects parseable non-drafts and key/object identity mismatches", async () => { |
| 82 | const kv = new FakeKv(); |
| 83 | kv.values.set("draft:triage:42", JSON.stringify({ generatedAt: "2026-07-17T00:00:00.000Z" })); |
| 84 | kv.values.set("draft:triage:43", JSON.stringify(draft())); |
| 85 | |
| 86 | await expect(getDraft(kv, "dispatch:latest")).resolves.toBeNull(); |
| 87 | await expect(getDraft(kv, "draft:triage:42")).resolves.toBeNull(); |
| 88 | await expect(getDraft(kv, "draft:triage:43")).resolves.toBeNull(); |
| 89 | await expect(listDrafts(kv)).resolves.toEqual([]); |
| 90 | }); |
| 91 | |
| 92 | it("never lets the draft deletion helper cross into another KV namespace", async () => { |
| 93 | const kv = new FakeKv(); |
| 94 | kv.values.set("dispatch:latest", JSON.stringify({ state: "running" })); |
| 95 | |
| 96 | await expect(deleteDraft(kv, "dispatch:latest")).rejects.toThrow("invalid draft key"); |
| 97 | expect(kv.deleted).toEqual([]); |
| 98 | expect(kv.values.has("dispatch:latest")).toBe(true); |
| 99 | |
| 100 | await saveDraft(kv, draft()); |
| 101 | await expect(deleteDraft(kv, "draft:triage:42")).resolves.toBeUndefined(); |
| 102 | expect(kv.deleted).toEqual(["draft:triage:42"]); |
| 103 | }); |
| 104 | }); |
| 105 |