From 29dc18d33d50cf23ea8f1a1b57086cddd25fb12d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 12:02:05 +0100 Subject: [PATCH] test(plugin-state): seed limit fixtures in one transaction --- .../plugin-state-store.e2e.test.ts | 27 +++++--- .../plugin-state-store.test-helpers.ts | 67 +++++++++++++++++++ src/plugin-state/plugin-state-store.test.ts | 43 ++++++++---- 3 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 src/plugin-state/plugin-state-store.test-helpers.ts diff --git a/src/plugin-state/plugin-state-store.e2e.test.ts b/src/plugin-state/plugin-state-store.e2e.test.ts index c64e18e394d..95bc5ffa593 100644 --- a/src/plugin-state/plugin-state-store.e2e.test.ts +++ b/src/plugin-state/plugin-state-store.e2e.test.ts @@ -12,6 +12,7 @@ import { } from "./plugin-state-store.js"; import { resolvePluginStateDir, resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js"; import { MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN } from "./plugin-state-store.sqlite.js"; +import { seedPluginStateEntriesForTests } from "./plugin-state-store.test-helpers.js"; afterEach(() => { vi.useRealTimers(); @@ -198,21 +199,25 @@ describe("limits", () => { // namespace eviction never fires (each namespace has generous room). const nsCount = 10; const perNs = MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN / nsCount; // 100 - const stores = Array.from({ length: nsCount }, (_, i) => - createPluginStateKeyedStore("fixture-plugin", { - namespace: `ns-${i}`, - maxEntries: perNs + 1, + seedPluginStateEntriesForTests( + Array.from({ length: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN }, (_, index) => { + const ns = Math.floor(index / perNs); + const k = index % perNs; + return { + pluginId: "fixture-plugin", + namespace: `ns-${ns}`, + key: `k-${k}`, + value: { ns, k }, + }; }), ); - - for (let ns = 0; ns < nsCount; ns += 1) { - for (let k = 0; k < perNs; k += 1) { - await stores[ns].register(`k-${k}`, { ns, k }); - } - } + const store = createPluginStateKeyedStore("fixture-plugin", { + namespace: "ns-0", + maxEntries: perNs + 1, + }); // One more row tips over the plugin-wide limit. - await expect(stores[0].register("overflow", { boom: true })).rejects.toMatchObject({ + await expect(store.register("overflow", { boom: true })).rejects.toMatchObject({ code: "PLUGIN_STATE_LIMIT_EXCEEDED", }); }); diff --git a/src/plugin-state/plugin-state-store.test-helpers.ts b/src/plugin-state/plugin-state-store.test-helpers.ts new file mode 100644 index 00000000000..3c1bc5817e9 --- /dev/null +++ b/src/plugin-state/plugin-state-store.test-helpers.ts @@ -0,0 +1,67 @@ +import { requireNodeSqlite } from "../infra/node-sqlite.js"; +import { resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js"; +import { closePluginStateSqliteStore, probePluginStateStore } from "./plugin-state-store.sqlite.js"; + +export type PluginStateSeedEntry = { + pluginId: string; + namespace: string; + key: string; + value: unknown; + createdAt?: number; + expiresAt?: number | null; +}; + +export function seedPluginStateEntriesForTests(entries: PluginStateSeedEntry[]): void { + if (entries.length === 0) { + return; + } + + probePluginStateStore(); + closePluginStateSqliteStore(); + + const { DatabaseSync } = requireNodeSqlite(); + const db = new DatabaseSync(resolvePluginStateSqlitePath()); + const insertEntry = db.prepare(` + INSERT INTO plugin_state_entries ( + plugin_id, + namespace, + entry_key, + value_json, + created_at, + expires_at + ) VALUES ( + @plugin_id, + @namespace, + @entry_key, + @value_json, + @created_at, + @expires_at + ) + `); + const now = Date.now(); + + db.exec("BEGIN IMMEDIATE"); + try { + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const valueJson = JSON.stringify(entry.value); + if (valueJson == null) { + throw new Error("plugin state seed value must be JSON serializable"); + } + insertEntry.run({ + plugin_id: entry.pluginId, + namespace: entry.namespace, + entry_key: entry.key, + value_json: valueJson, + created_at: entry.createdAt ?? now + index, + expires_at: entry.expiresAt ?? null, + }); + } + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } finally { + db.close(); + } +} diff --git a/src/plugin-state/plugin-state-store.test.ts b/src/plugin-state/plugin-state-store.test.ts index a0232b38e29..3babcb9626d 100644 --- a/src/plugin-state/plugin-state-store.test.ts +++ b/src/plugin-state/plugin-state-store.test.ts @@ -12,6 +12,7 @@ import { sweepExpiredPluginStateEntries, } from "./plugin-state-store.js"; import { resolvePluginStateDir, resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js"; +import { seedPluginStateEntriesForTests } from "./plugin-state-store.test-helpers.js"; afterEach(() => { vi.useRealTimers(); @@ -126,22 +127,38 @@ describe("plugin state keyed store", () => { it("rejects when the per-plugin live row ceiling would be exceeded without evicting siblings", async () => { await withOpenClawTestState({ label: "plugin-state-plugin-limit" }, async () => { - const stores = Array.from({ length: 10 }, (_, index) => - createPluginStateKeyedStore("discord", { - namespace: `ns-${index}`, - maxEntries: 101, - }), - ); - for (let namespaceIndex = 0; namespaceIndex < stores.length; namespaceIndex += 1) { - for (let entryIndex = 0; entryIndex < 100; entryIndex += 1) { - await stores[namespaceIndex].register(`k-${entryIndex}`, { namespaceIndex, entryIndex }); - } - } + seedPluginStateEntriesForTests([ + ...Array.from({ length: 999 }, (_, entryIndex) => ({ + pluginId: "discord", + namespace: "limit", + key: `k-${entryIndex}`, + value: { namespaceIndex: 0, entryIndex }, + })), + { + pluginId: "discord", + namespace: "sibling", + key: "k-0", + value: { namespaceIndex: 1, entryIndex: 0 }, + }, + ]); - await expect(stores[0].register("overflow", { overflow: true })).rejects.toMatchObject({ + const limitStore = createPluginStateKeyedStore("discord", { + namespace: "limit", + maxEntries: 1_001, + }); + const siblingStore = createPluginStateKeyedStore("discord", { + namespace: "sibling", + maxEntries: 10, + }); + + await expect(limitStore.register("overflow", { overflow: true })).rejects.toMatchObject({ code: "PLUGIN_STATE_LIMIT_EXCEEDED", }); - await expect(stores[1].lookup("k-0")).resolves.toEqual({ namespaceIndex: 1, entryIndex: 0 }); + await expect(siblingStore.lookup("k-0")).resolves.toEqual({ + namespaceIndex: 1, + entryIndex: 0, + }); + await expect(limitStore.lookup("overflow")).resolves.toBeUndefined(); }); });