返回 CodeWhale
draft-claim-lock.test.ts
根目录 / web / lib / draft-claim-lock.test.ts
1 import { describe, expect, it } from "vitest";
2 import { applyDraftLock, type DraftLockRequest, type DraftLockStorage } from "./draft-claim-lock";
3 import { FakeDraftClaimLock } from "./draft-claim-lock.fake";
4
5 /** Storage that yields on every call, so unserialized callers would interleave. */
6 class YieldingStorage implements DraftLockStorage {
7 readonly values = new Map<string, unknown>();
8 private async tick() {
9 await new Promise((resolve) => setTimeout(resolve, 0));
10 }
11 async get<T>(key: string) {
12 await this.tick();
13 return this.values.get(key) as T | undefined;
14 }
15 async put<T>(key: string, value: T) {
16 await this.tick();
17 this.values.set(key, value);
18 }
19 async delete(key: string) {
20 await this.tick();
21 return this.values.delete(key);
22 }
23 }
24
25 const claim = (token: string, action: "post" | "discard" = "post", leaseMs = 60_000): DraftLockRequest => ({
26 op: "claim",
27 token,
28 action,
29 leaseMs,
30 });
31
32 describe("DraftClaimLock", () => {
33 it("grants exactly one of many concurrent claims", async () => {
34 const ns = new FakeDraftClaimLock();
35 const lock = ns.get(ns.idFromName("draft-claim:triage:42"));
36 const results = await Promise.all(
37 Array.from({ length: 8 }, (_, i) => lock.act(claim(`post:${i}`)))
38 );
39 expect(results.filter((r) => r.ok)).toHaveLength(1);
40 expect(results.filter((r) => !r.ok)).toEqual(Array(7).fill({ ok: false, holder: "post" }));
41 });
42
43 it("without the object's one-event-at-a-time gate, the same logic would double-grant", async () => {
44 // Documents why the lock must run inside the Durable Object: the fake's
45 // serialization is what the input gate provides in production.
46 const storage = new YieldingStorage();
47 const results = await Promise.all([
48 applyDraftLock(storage, 0, claim("post:a")),
49 applyDraftLock(storage, 0, claim("post:b")),
50 ]);
51 expect(results.every((r) => r.ok)).toBe(true);
52 });
53
54 it("keeps one lock per draft identity", async () => {
55 const ns = new FakeDraftClaimLock();
56 const a = ns.get(ns.idFromName("draft-claim:triage:1"));
57 const b = ns.get(ns.idFromName("draft-claim:triage:2"));
58 expect(await a.act(claim("post:a"))).toEqual({ ok: true });
59 expect(await b.act(claim("post:b"))).toEqual({ ok: true });
60 expect(await ns.get(ns.idFromName("draft-claim:triage:1")).act(claim("post:c"))).toEqual({
61 ok: false,
62 holder: "post",
63 });
64 });
65
66 it("lets a lease expire so a crashed action does not wedge the draft", async () => {
67 const ns = new FakeDraftClaimLock();
68 const lock = ns.get(ns.idFromName("k"));
69 expect(await lock.act(claim("post:a", "post", 15 * 60_000))).toEqual({ ok: true });
70 ns.now += 15 * 60_000 - 1;
71 expect(await lock.act(claim("discard:b", "discard"))).toEqual({ ok: false, holder: "post" });
72 ns.now += 1;
73 expect(await lock.act(claim("discard:b", "discard"))).toEqual({ ok: true });
74 // The expired holder can no longer release the new holder's lease.
75 await lock.act({ op: "release", token: "post:a", holdMs: 0 });
76 expect(await lock.act(claim("post:c"))).toEqual({ ok: false, holder: "discard" });
77 });
78
79 it("frees the draft on release, and only for the holder's own token", async () => {
80 const ns = new FakeDraftClaimLock();
81 const lock = ns.get(ns.idFromName("k"));
82 await lock.act(claim("post:a"));
83 await lock.act({ op: "release", token: "post:other", holdMs: 0 });
84 expect(await lock.act(claim("post:b"))).toEqual({ ok: false, holder: "post" });
85 await lock.act({ op: "release", token: "post:a", holdMs: 0 });
86 expect(await lock.act(claim("post:b"))).toEqual({ ok: true });
87 });
88
89 it("holds a recorded decision for holdMs, then frees it", async () => {
90 const ns = new FakeDraftClaimLock();
91 const lock = ns.get(ns.idFromName("k"));
92 await lock.act(claim("discard:a", "discard", 15 * 60_000));
93 await lock.act({ op: "release", token: "discard:a", holdMs: 120_000 });
94 ns.now += 119_999;
95 expect(await lock.act(claim("post:b"))).toEqual({ ok: false, holder: "discard" });
96 ns.now += 1;
97 expect(await lock.act(claim("post:b"))).toEqual({ ok: true });
98 });
99 });
100
100 lines TYPESCRIPT