// Persistent webhook dedup — the store that stops a settled-payment // webhook duplicate from double-crediting (BTCPay) or double-extending // (Zaprite) when the duplicate straddles a relay restart. import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { initWebhookDedup, isWebhookProcessed, markWebhookProcessed, } from "../webhook-dedup.js"; // The module is a singleton; each test inits a fresh dir to reset state. const freshDir = () => mkdtempSync(path.join(tmpdir(), "relay-dedup-")); describe("webhook dedup (persistent)", () => { test("unknown key is not processed; marking makes it processed", async () => { await initWebhookDedup({ dataDir: freshDir() }); assert.equal(isWebhookProcessed("store1|inv1"), false); await markWebhookProcessed("store1|inv1"); assert.equal(isWebhookProcessed("store1|inv1"), true); }); // Headline guarantee for this change. test("processed keys survive a restart (reload from disk)", async () => { const dir = freshDir(); await initWebhookDedup({ dataDir: dir }); await markWebhookProcessed("store1|inv-restart"); // Simulate a relay restart: re-init from the SAME data dir. await initWebhookDedup({ dataDir: dir }); assert.equal( isWebhookProcessed("store1|inv-restart"), true, "a key marked before restart must still be deduped after restart" ); }); test("BTCPay and Zaprite keys share the store without colliding", async () => { await initWebhookDedup({ dataDir: freshDir() }); await markWebhookProcessed("store1|inv9"); await markWebhookProcessed("zaprite:order9"); assert.equal(isWebhookProcessed("store1|inv9"), true); assert.equal(isWebhookProcessed("zaprite:order9"), true); assert.equal(isWebhookProcessed("zaprite:inv9"), false); }); test("marking is idempotent", async () => { await initWebhookDedup({ dataDir: freshDir() }); await markWebhookProcessed("k"); await markWebhookProcessed("k"); // must not throw or duplicate assert.equal(isWebhookProcessed("k"), true); }); test("stale entries past the retention window are pruned on load", async () => { const dir = freshDir(); const ancient = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(); const recent = new Date().toISOString(); writeFileSync( path.join(dir, "processed-webhooks.json"), JSON.stringify({ keys: { "store1|old": ancient, "store1|new": recent } }) ); await initWebhookDedup({ dataDir: dir }); assert.equal(isWebhookProcessed("store1|old"), false, "1y-old key should be pruned"); assert.equal(isWebhookProcessed("store1|new"), true, "recent key should survive"); }); });