@adjudicate/adapter-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Plan } from "@adjudicate/core/llm";
3
+ import {
4
+ buildEnvelopeFromToolUse,
5
+ classifyIncomingToolUse,
6
+ } from "../src/bridge.js";
7
+
8
+ const plan: Plan = {
9
+ visibleReadTools: ["list_charges", "get_charge"],
10
+ allowedIntents: ["pix.charge.create", "pix.charge.refund"],
11
+ };
12
+
13
+ describe("classifyIncomingToolUse", () => {
14
+ it("classifies a planner-advertised READ tool", () => {
15
+ const result = classifyIncomingToolUse(
16
+ { name: "list_charges", input: { limit: 5 } },
17
+ plan,
18
+ );
19
+ expect(result).toEqual({
20
+ kind: "read",
21
+ name: "list_charges",
22
+ input: { limit: 5 },
23
+ });
24
+ });
25
+
26
+ it("classifies a planner-advertised intent kind", () => {
27
+ const result = classifyIncomingToolUse(
28
+ { name: "pix.charge.create", input: { amountCentavos: 5000 } },
29
+ plan,
30
+ );
31
+ expect(result).toEqual({
32
+ kind: "intent",
33
+ intentKind: "pix.charge.create",
34
+ payload: { amountCentavos: 5000 },
35
+ });
36
+ });
37
+
38
+ it("returns out_of_plan for an unknown tool name", () => {
39
+ const result = classifyIncomingToolUse(
40
+ { name: "make_coffee", input: {} },
41
+ plan,
42
+ );
43
+ expect(result).toEqual({ kind: "out_of_plan", name: "make_coffee" });
44
+ });
45
+
46
+ it("does not let a TRUSTED-only kind leak when planner has not advertised it", () => {
47
+ // pix.charge.confirm is TRUSTED-only; the planner correctly omits it.
48
+ // The bridge must surface this as out_of_plan, not as an intent.
49
+ const restrictivePlan: Plan = {
50
+ visibleReadTools: [],
51
+ allowedIntents: ["pix.charge.create"],
52
+ };
53
+ const result = classifyIncomingToolUse(
54
+ { name: "pix.charge.confirm", input: { chargeId: "x" } },
55
+ restrictivePlan,
56
+ );
57
+ expect(result.kind).toBe("out_of_plan");
58
+ });
59
+ });
60
+
61
+ describe("buildEnvelopeFromToolUse", () => {
62
+ it("constructs an envelope with principal=llm and supplied taint", () => {
63
+ const envelope = buildEnvelopeFromToolUse({
64
+ intentKind: "pix.charge.create",
65
+ payload: { amountCentavos: 5000 },
66
+ sessionId: "s-1",
67
+ taint: "UNTRUSTED",
68
+ nonce: "tu-abc-123",
69
+ });
70
+ expect(envelope.kind).toBe("pix.charge.create");
71
+ expect(envelope.actor).toEqual({ principal: "llm", sessionId: "s-1" });
72
+ expect(envelope.taint).toBe("UNTRUSTED");
73
+ expect(envelope.nonce).toBe("tu-abc-123");
74
+ expect(envelope.intentHash).toMatch(/^[0-9a-f]{64}$/);
75
+ });
76
+
77
+ it("produces a stable intentHash across retries with the same nonce", () => {
78
+ const a = buildEnvelopeFromToolUse({
79
+ intentKind: "pix.charge.create",
80
+ payload: { amountCentavos: 5000 },
81
+ sessionId: "s-1",
82
+ taint: "UNTRUSTED",
83
+ nonce: "tu-stable",
84
+ });
85
+ const b = buildEnvelopeFromToolUse({
86
+ intentKind: "pix.charge.create",
87
+ payload: { amountCentavos: 5000 },
88
+ sessionId: "s-1",
89
+ taint: "UNTRUSTED",
90
+ nonce: "tu-stable",
91
+ });
92
+ expect(a.intentHash).toBe(b.intentHash);
93
+ });
94
+
95
+ it("produces different intentHash when the nonce changes", () => {
96
+ const a = buildEnvelopeFromToolUse({
97
+ intentKind: "pix.charge.create",
98
+ payload: { amountCentavos: 5000 },
99
+ sessionId: "s-1",
100
+ taint: "UNTRUSTED",
101
+ nonce: "tu-nonce-a",
102
+ });
103
+ const b = buildEnvelopeFromToolUse({
104
+ intentKind: "pix.charge.create",
105
+ payload: { amountCentavos: 5000 },
106
+ sessionId: "s-1",
107
+ taint: "UNTRUSTED",
108
+ nonce: "tu-nonce-b",
109
+ });
110
+ expect(a.intentHash).not.toBe(b.intentHash);
111
+ });
112
+ });
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ decisionDefer,
4
+ decisionEscalate,
5
+ decisionExecute,
6
+ decisionRefuse,
7
+ decisionRequestConfirmation,
8
+ decisionRewrite,
9
+ refuse,
10
+ type IntentEnvelope,
11
+ } from "@adjudicate/core";
12
+ import { translateDecision } from "../src/decisions.js";
13
+ import {
14
+ createInMemoryConfirmationStore,
15
+ createInMemoryDeferStore,
16
+ } from "../src/persistence.js";
17
+ import type { AdopterExecutor } from "../src/types.js";
18
+
19
+ interface Payload {
20
+ amountCentavos: number;
21
+ }
22
+ interface State {
23
+ // intentionally empty — kernel state is not exercised in these unit tests
24
+ }
25
+
26
+ const envelope: IntentEnvelope<"pix.charge.refund", Payload> = {
27
+ version: 2,
28
+ kind: "pix.charge.refund",
29
+ payload: { amountCentavos: 5000 },
30
+ createdAt: new Date().toISOString(),
31
+ nonce: "n-1",
32
+ actor: { principal: "llm", sessionId: "s-1" },
33
+ taint: "UNTRUSTED",
34
+ intentHash: "f".repeat(64),
35
+ };
36
+
37
+ const rewrittenEnvelope: IntentEnvelope<"pix.charge.refund", Payload> = {
38
+ ...envelope,
39
+ payload: { amountCentavos: 3000 },
40
+ intentHash: "e".repeat(64),
41
+ };
42
+
43
+ function buildContext(opts?: { executor?: AdopterExecutor<"pix.charge.refund", Payload, State> }) {
44
+ const executor: AdopterExecutor<"pix.charge.refund", Payload, State> =
45
+ opts?.executor ?? {
46
+ invokeRead: vi.fn(async () => ({})),
47
+ invokeIntent: vi.fn(async () => ({ refundId: "r-1", refunded: 5000 })),
48
+ };
49
+ return {
50
+ envelope,
51
+ toolUseId: "tu-1",
52
+ sessionId: "s-1",
53
+ state: {} as State,
54
+ executor,
55
+ deferStore: createInMemoryDeferStore(),
56
+ confirmationStore: createInMemoryConfirmationStore<unknown>(),
57
+ historySnapshot: [] as unknown,
58
+ rk: (raw: string) => raw,
59
+ generateToken: () => "ct-fixed",
60
+ };
61
+ }
62
+
63
+ describe("translateDecision (adapter-core)", () => {
64
+ it("EXECUTE → invokes executor, returns JSON tool_result, continues", async () => {
65
+ const ctx = buildContext();
66
+ const t = await translateDecision({
67
+ ...ctx,
68
+ decision: decisionExecute([]),
69
+ });
70
+ expect(t.loopAction).toEqual({ kind: "continue" });
71
+ expect(t.toolResult?.toolUseId).toBe("tu-1");
72
+ expect(t.toolResult?.isError).toBeUndefined();
73
+ expect(ctx.executor.invokeIntent).toHaveBeenCalledWith(envelope, {});
74
+ const content = JSON.parse(t.toolResult?.content as string);
75
+ expect(content).toMatchObject({
76
+ ok: true,
77
+ result: { refundId: "r-1" },
78
+ });
79
+ });
80
+
81
+ it("REFUSE → tool_result with userFacing text, isError=true, continues", async () => {
82
+ const ctx = buildContext();
83
+ const t = await translateDecision({
84
+ ...ctx,
85
+ decision: decisionRefuse(
86
+ refuse(
87
+ "BUSINESS_RULE",
88
+ "pix.charge.amount_invalid",
89
+ "That amount is not allowed.",
90
+ ),
91
+ [],
92
+ ),
93
+ });
94
+ expect(t.loopAction).toEqual({ kind: "continue" });
95
+ expect(t.toolResult?.isError).toBe(true);
96
+ expect(t.toolResult?.content).toBe("That amount is not allowed.");
97
+ expect(ctx.executor.invokeIntent).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("REWRITE → invokes executor with rewritten envelope, surfaces note, continues", async () => {
101
+ const ctx = buildContext();
102
+ const t = await translateDecision({
103
+ ...ctx,
104
+ decision: decisionRewrite(rewrittenEnvelope, "amount clamped to original", []),
105
+ });
106
+ expect(t.loopAction).toEqual({ kind: "continue" });
107
+ expect(ctx.executor.invokeIntent).toHaveBeenCalledWith(
108
+ rewrittenEnvelope,
109
+ {},
110
+ );
111
+ const content = JSON.parse(t.toolResult?.content as string);
112
+ expect(content).toMatchObject({
113
+ ok: true,
114
+ note: expect.stringContaining("amount clamped to original"),
115
+ });
116
+ });
117
+
118
+ it("REQUEST_CONFIRMATION → persists pending entry, returns pause_for_user_confirmation", async () => {
119
+ const ctx = buildContext();
120
+ const t = await translateDecision({
121
+ ...ctx,
122
+ decision: decisionRequestConfirmation(
123
+ "Confirm a refund of R$ 600?",
124
+ [],
125
+ ),
126
+ });
127
+ expect(t.loopAction).toEqual({
128
+ kind: "pause_for_user_confirmation",
129
+ prompt: "Confirm a refund of R$ 600?",
130
+ token: "ct-fixed",
131
+ });
132
+ const taken = await ctx.confirmationStore.take("ct-fixed");
133
+ expect(taken).not.toBeNull();
134
+ expect(taken?.envelope).toEqual(envelope);
135
+ expect(t.toolResult?.content).toContain("Confirm a refund of R$ 600?");
136
+ expect(ctx.executor.invokeIntent).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it("ESCALATE → returns complete_for_escalation; executor not called", async () => {
140
+ const ctx = buildContext();
141
+ const t = await translateDecision({
142
+ ...ctx,
143
+ decision: decisionEscalate(
144
+ "supervisor",
145
+ "Refund above threshold",
146
+ [],
147
+ ),
148
+ });
149
+ expect(t.loopAction).toEqual({
150
+ kind: "complete_for_escalation",
151
+ to: "supervisor",
152
+ reason: "Refund above threshold",
153
+ });
154
+ expect(t.toolResult?.content).toContain("Escalated to supervisor");
155
+ expect(ctx.executor.invokeIntent).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it("DEFER → parks in deferStore, returns pause_for_defer", async () => {
159
+ const ctx = buildContext();
160
+ const t = await translateDecision({
161
+ ...ctx,
162
+ decision: decisionDefer("payment.confirmed", 15 * 60 * 1000, []),
163
+ });
164
+ expect(t.loopAction).toEqual({
165
+ kind: "pause_for_defer",
166
+ signal: "payment.confirmed",
167
+ intentHash: envelope.intentHash,
168
+ });
169
+ // Parked envelope should be retrievable from the store.
170
+ const parkedRaw = await ctx.deferStore.get("defer:pending:s-1");
171
+ expect(parkedRaw).not.toBeNull();
172
+ const parked = JSON.parse(parkedRaw as string);
173
+ expect(parked.envelope.intentHash).toBe(envelope.intentHash);
174
+ expect(parked.signal).toBe("payment.confirmed");
175
+ expect(ctx.executor.invokeIntent).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("EXECUTE with throwing executor → isError tool_result, loop continues", async () => {
179
+ const executor: AdopterExecutor<"pix.charge.refund", Payload, State> = {
180
+ invokeRead: vi.fn(async () => ({})),
181
+ invokeIntent: vi.fn(async () => {
182
+ throw new Error("provider down");
183
+ }),
184
+ };
185
+ const ctx = buildContext({ executor });
186
+ const t = await translateDecision({
187
+ ...ctx,
188
+ decision: decisionExecute([]),
189
+ });
190
+ expect(t.loopAction).toEqual({ kind: "continue" });
191
+ expect(t.toolResult?.isError).toBe(true);
192
+ expect(t.toolResult?.content).toContain("provider down");
193
+ });
194
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Redis-backed `ConfirmationStore` — restart-durable pending confirmations.
3
+ *
4
+ * Tests cover:
5
+ * - Put + take roundtrip preserves envelope, sessionId, history, prompt
6
+ * - History serialization uses the configurable serializer
7
+ * - take() is single-use (idempotent yes-then-yes returns null)
8
+ * - Missing token returns null
9
+ * - Malformed wire payload returns null without throwing
10
+ * - Key namespacing is applied via keyFor
11
+ * - TTL is forwarded to Redis as `EX`
12
+ */
13
+
14
+ import { describe, expect, it } from "vitest";
15
+ import type { IntentEnvelope } from "@adjudicate/core";
16
+ import { createRedisConfirmationStore } from "../src/persistence-redis.js";
17
+ import type { PendingConfirmation } from "../src/persistence.js";
18
+
19
+ interface Entry {
20
+ readonly value: string;
21
+ readonly expiresAt: number | null;
22
+ }
23
+
24
+ function fakeRedis() {
25
+ const store = new Map<string, Entry>();
26
+ const calls: Array<{ op: string; key: string; ttl?: number }> = [];
27
+ return {
28
+ store,
29
+ calls,
30
+ client: {
31
+ async get(key: string) {
32
+ calls.push({ op: "get", key });
33
+ const e = store.get(key);
34
+ if (e === undefined) return null;
35
+ if (e.expiresAt !== null && e.expiresAt < Date.now()) {
36
+ store.delete(key);
37
+ return null;
38
+ }
39
+ return e.value;
40
+ },
41
+ async set(
42
+ key: string,
43
+ value: string,
44
+ opts?: { NX?: boolean; EX?: number },
45
+ ) {
46
+ calls.push({ op: "set", key, ttl: opts?.EX });
47
+ const expiresAt =
48
+ opts?.EX !== undefined ? Date.now() + opts.EX * 1000 : null;
49
+ store.set(key, { value, expiresAt });
50
+ return "OK";
51
+ },
52
+ async del(key: string) {
53
+ calls.push({ op: "del", key });
54
+ return store.delete(key) ? 1 : 0;
55
+ },
56
+ },
57
+ };
58
+ }
59
+
60
+ function envelopeOf(): IntentEnvelope {
61
+ return {
62
+ version: 2,
63
+ kind: "test.intent",
64
+ payload: { amount: 100 },
65
+ createdAt: "2026-05-20T00:00:00.000Z",
66
+ nonce: "nonce-1",
67
+ actor: { principal: "user", sessionId: "s-1" },
68
+ taint: "UNTRUSTED",
69
+ intentHash: "deadbeef",
70
+ };
71
+ }
72
+
73
+ function pendingOf(overrides: Partial<PendingConfirmation<string[]>> = {}): PendingConfirmation<string[]> {
74
+ return {
75
+ envelope: envelopeOf(),
76
+ sessionId: "s-1",
77
+ assistantHistorySnapshot: ["turn-1", "turn-2"],
78
+ toolUseId: "tu-1",
79
+ prompt: "Confirm transfer of 100?",
80
+ ...overrides,
81
+ };
82
+ }
83
+
84
+ describe("createRedisConfirmationStore", () => {
85
+ it("put + take roundtrip preserves the full pending confirmation", async () => {
86
+ const { client } = fakeRedis();
87
+ const store = createRedisConfirmationStore<string[]>({ client });
88
+
89
+ await store.put("tok-1", pendingOf(), 300);
90
+ const taken = await store.take("tok-1");
91
+
92
+ expect(taken).not.toBeNull();
93
+ expect(taken!.envelope.intentHash).toBe("deadbeef");
94
+ expect(taken!.sessionId).toBe("s-1");
95
+ expect(taken!.assistantHistorySnapshot).toEqual(["turn-1", "turn-2"]);
96
+ expect(taken!.toolUseId).toBe("tu-1");
97
+ expect(taken!.prompt).toBe("Confirm transfer of 100?");
98
+ });
99
+
100
+ it("take() is single-use — second take returns null", async () => {
101
+ const { client } = fakeRedis();
102
+ const store = createRedisConfirmationStore<string[]>({ client });
103
+
104
+ await store.put("tok-1", pendingOf(), 300);
105
+ const first = await store.take("tok-1");
106
+ const second = await store.take("tok-1");
107
+
108
+ expect(first).not.toBeNull();
109
+ expect(second).toBeNull();
110
+ });
111
+
112
+ it("missing token returns null without throwing", async () => {
113
+ const { client } = fakeRedis();
114
+ const store = createRedisConfirmationStore<string[]>({ client });
115
+
116
+ const taken = await store.take("never-existed");
117
+ expect(taken).toBeNull();
118
+ });
119
+
120
+ it("malformed wire payload returns null", async () => {
121
+ const { client, store } = fakeRedis();
122
+ store.set("confirm:tok-1", { value: "not-json", expiresAt: null });
123
+ const confirmStore = createRedisConfirmationStore<string[]>({ client });
124
+ const taken = await confirmStore.take("tok-1");
125
+ expect(taken).toBeNull();
126
+ });
127
+
128
+ it("keyFor namespaces the key in Redis", async () => {
129
+ const { client, calls } = fakeRedis();
130
+ const store = createRedisConfirmationStore<string[]>({
131
+ client,
132
+ keyFor: (s) => `ENV:adjudicate:${s}`,
133
+ });
134
+
135
+ await store.put("tok-1", pendingOf(), 300);
136
+ expect(calls.some((c) => c.key === "ENV:adjudicate:confirm:tok-1")).toBe(true);
137
+ });
138
+
139
+ it("TTL is forwarded to Redis as EX seconds", async () => {
140
+ const { client, calls } = fakeRedis();
141
+ const store = createRedisConfirmationStore<string[]>({ client });
142
+
143
+ await store.put("tok-1", pendingOf(), 900);
144
+ const setCall = calls.find((c) => c.op === "set");
145
+ expect(setCall?.ttl).toBe(900);
146
+ });
147
+
148
+ it("custom history serializer is honored on both put and take", async () => {
149
+ interface RichHistory {
150
+ readonly entries: ReadonlyArray<{ readonly role: string; readonly text: string }>;
151
+ }
152
+ const { client } = fakeRedis();
153
+ let serialized = 0;
154
+ let deserialized = 0;
155
+ const store = createRedisConfirmationStore<RichHistory>({
156
+ client,
157
+ serializeHistory: (h) => {
158
+ serialized++;
159
+ return JSON.stringify(h);
160
+ },
161
+ deserializeHistory: (s) => {
162
+ deserialized++;
163
+ return JSON.parse(s) as RichHistory;
164
+ },
165
+ });
166
+
167
+ const history: RichHistory = {
168
+ entries: [
169
+ { role: "user", text: "send 100" },
170
+ { role: "assistant", text: "Confirming..." },
171
+ ],
172
+ };
173
+
174
+ await store.put("tok-1", {
175
+ envelope: envelopeOf(),
176
+ sessionId: "s-1",
177
+ assistantHistorySnapshot: history,
178
+ toolUseId: "tu-1",
179
+ prompt: "Confirm?",
180
+ }, 300);
181
+ expect(serialized).toBe(1);
182
+
183
+ const taken = await store.take("tok-1");
184
+ expect(deserialized).toBe(1);
185
+ expect(taken!.assistantHistorySnapshot).toEqual(history);
186
+ });
187
+
188
+ it("a Redis TTL expiry during take returns null", async () => {
189
+ const { client, store } = fakeRedis();
190
+ const confirmStore = createRedisConfirmationStore<string[]>({ client });
191
+
192
+ // Put with an expired entry directly
193
+ store.set("confirm:tok-1", {
194
+ value: JSON.stringify({
195
+ envelope: envelopeOf(),
196
+ sessionId: "s-1",
197
+ historyJson: JSON.stringify([]),
198
+ toolUseId: "tu-1",
199
+ prompt: "?",
200
+ }),
201
+ expiresAt: Date.now() - 1000,
202
+ });
203
+
204
+ const taken = await confirmStore.take("tok-1");
205
+ expect(taken).toBeNull();
206
+ });
207
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createInMemoryConfirmationStore,
4
+ createInMemoryDeferStore,
5
+ type PendingConfirmation,
6
+ } from "../src/persistence.js";
7
+ import type { IntentEnvelope } from "@adjudicate/core";
8
+
9
+ describe("createInMemoryDeferStore — DeferRedis surface", () => {
10
+ it("set with NX returns OK on first write, null on collision", async () => {
11
+ const store = createInMemoryDeferStore();
12
+ const a = await store.set("k", "v1", { NX: true, EX: 60 });
13
+ const b = await store.set("k", "v2", { NX: true, EX: 60 });
14
+ expect(a).toBe("OK");
15
+ expect(b).toBeNull();
16
+ expect(await store.get("k")).toBe("v1");
17
+ });
18
+
19
+ it("set with EX expires the value", async () => {
20
+ const store = createInMemoryDeferStore();
21
+ await store.set("ek", "v", { NX: true, EX: 0 });
22
+ // EX=0 → expiresAt = Date.now(); subsequent get sees expired entry.
23
+ // Wait one event-loop tick to be defensive in case the comparison
24
+ // uses strict `>`.
25
+ await new Promise((r) => setTimeout(r, 1));
26
+ expect(await store.get("ek")).toBeNull();
27
+ });
28
+
29
+ it("get returns null for unknown key", async () => {
30
+ const store = createInMemoryDeferStore();
31
+ expect(await store.get("missing")).toBeNull();
32
+ });
33
+
34
+ it("del removes the key", async () => {
35
+ const store = createInMemoryDeferStore();
36
+ await store.set("k", "v", { NX: true, EX: 60 });
37
+ expect(await store.del("k")).toBe(1);
38
+ expect(await store.get("k")).toBeNull();
39
+ expect(await store.del("k")).toBe(0);
40
+ });
41
+ });
42
+
43
+ describe("createInMemoryDeferStore — ParkRedis surface", () => {
44
+ it("incr returns the new value; decr brings it back", async () => {
45
+ const store = createInMemoryDeferStore();
46
+ expect(await store.incr("c")).toBe(1);
47
+ expect(await store.incr("c")).toBe(2);
48
+ expect(await store.decr("c")).toBe(1);
49
+ expect(await store.decr("c")).toBe(0);
50
+ expect(await store.decr("c")).toBe(-1); // negative is allowed by spec
51
+ });
52
+
53
+ it("set with EX (no NX) writes unconditionally", async () => {
54
+ const store = createInMemoryDeferStore();
55
+ const result = await store.set("p", "envelope-blob", { EX: 60 });
56
+ expect(result).toBe("OK");
57
+ expect(await store.get("p")).toBe("envelope-blob");
58
+ });
59
+ });
60
+
61
+ describe("createInMemoryConfirmationStore", () => {
62
+ const stubEnvelope: IntentEnvelope = {
63
+ version: 2,
64
+ kind: "test.kind",
65
+ payload: {},
66
+ createdAt: new Date().toISOString(),
67
+ nonce: "n",
68
+ actor: { principal: "llm", sessionId: "s-1" },
69
+ taint: "UNTRUSTED",
70
+ intentHash: "0".repeat(64),
71
+ };
72
+ const pending: PendingConfirmation = {
73
+ envelope: stubEnvelope,
74
+ sessionId: "s-1",
75
+ assistantHistorySnapshot: [],
76
+ toolUseId: "tu-1",
77
+ prompt: "Confirm?",
78
+ };
79
+
80
+ it("put then take returns the pending entry once", async () => {
81
+ const store = createInMemoryConfirmationStore();
82
+ await store.put("token-1", pending, 60);
83
+ const taken = await store.take("token-1");
84
+ expect(taken).toEqual(pending);
85
+ });
86
+
87
+ it("second take of the same token returns null (idempotent yes-then-yes)", async () => {
88
+ const store = createInMemoryConfirmationStore();
89
+ await store.put("token-1", pending, 60);
90
+ await store.take("token-1");
91
+ expect(await store.take("token-1")).toBeNull();
92
+ });
93
+
94
+ it("take returns null after TTL expiry", async () => {
95
+ const store = createInMemoryConfirmationStore();
96
+ await store.put("token-expire", pending, 0);
97
+ await new Promise((r) => setTimeout(r, 1));
98
+ expect(await store.take("token-expire")).toBeNull();
99
+ });
100
+
101
+ it("take returns null for an unknown token", async () => {
102
+ const store = createInMemoryConfirmationStore();
103
+ expect(await store.take("nope")).toBeNull();
104
+ });
105
+ });