import { InMemoryDeliveryEventStore } from "@suaveplan/delivery-tracking"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { Hono } from "hono"; import { CAMPAIGNS_API_CODES, CampaignsApiError, } from "../../error-codes/error-codes.js"; import { InMemoryCampaignSuppressionStore } from "../../repositories/suppression/suppression-memory.js"; import { buildDeliveryWebhookRoute, buildUnsubscribeRoute } from "./routes.js"; import "../../context/context.js"; function makeFixtures(principal: object | null = { id: "u", tenantId: "t" }) { let nextId = 0; const store = new InMemoryCampaignSuppressionStore({ clock: () => 1_700_000_000_000, idGenerator: () => `s${++nextId}`, }); const eventStore = new InMemoryDeliveryEventStore(); const app = new Hono(); app.use("*", async (c, next) => { if (principal !== null) c.set("user", principal as never); c.set("campaignSuppressionStore", store); c.set("deliveryEventStore", eventStore); c.set("eventBus", { emit: () => undefined } as never); await next(); }); app.onError((err, c) => { if (err instanceof CampaignsApiError) { const status = ((err.context as { statusCode?: number } | undefined) ?.statusCode ?? 500) as 400 | 401 | 500; return c.json({ code: err.code, error: err.message }, status); } return c.json({ error: String(err) }, 500); }); for (const e of [buildDeliveryWebhookRoute(), buildUnsubscribeRoute()]) { const method = e.route.method as "get" | "post"; app[method](e.route.path, (c) => e.handler(c as never)); } return { app, store, eventStore }; } describe("webhook delivery route", () => { it("records a delivered event without suppression", { timeout: 5000, }, async () => { const { app, store, eventStore } = makeFixtures(); const res = await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: "m1", eventType: "delivered", contactId: "u1", campaignId: "cmp1", }), headers: { "content-type": "application/json" }, }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.suppressed).toBe(false); expect(await eventStore.getByCampaign("cmp1")).toHaveLength(1); expect(await store.count("t")).toBe(0); }); it("suppresses on unsubscribed event", { timeout: 5000 }, async () => { const { app, store } = makeFixtures(); const res = await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: "m1", eventType: "unsubscribed", contactId: "u1", campaignId: "cmp1", }), headers: { "content-type": "application/json" }, }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.suppressed).toBe(true); expect(await store.count("t")).toBe(1); }); it("suppresses on bounced event", { timeout: 5000 }, async () => { const { app, store } = makeFixtures(); await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: "m1", eventType: "bounced", contactId: "u1", }), headers: { "content-type": "application/json" }, }); expect(await store.count("t")).toBe(1); }); it("suppresses on complained event", { timeout: 5000 }, async () => { const { app, store } = makeFixtures(); await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: "m1", eventType: "complained", contactId: "u1", }), headers: { "content-type": "application/json" }, }); expect(await store.count("t")).toBe(1); }); it("is idempotent — second unsubscribe does not raise", { timeout: 5000, }, async () => { const { app, store } = makeFixtures(); for (let i = 0; i < 2; i++) { const res = await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: `m${i}`, eventType: "unsubscribed", contactId: "u1", }), headers: { "content-type": "application/json" }, }); expect(res.status).toBe(200); expect((await res.json()).data.suppressed).toBe(true); } expect(await store.count("t")).toBe(1); }); it("accepts a timestamp from the payload", { timeout: 5000, }, async () => { const { app, eventStore } = makeFixtures(); await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: "m1", eventType: "delivered", contactId: "u1", campaignId: "cmp1", timestamp: "2026-01-01T00:00:00Z", }), headers: { "content-type": "application/json" }, }); const events = await eventStore.getByCampaign("cmp1"); expect(events[0]?.timestamp.toISOString()).toBe( "2026-01-01T00:00:00.000Z" ); }); it("rejects malformed body with WEBHOOK_REJECTED", { timeout: 5000, }, async () => { const { app } = makeFixtures(); const res = await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: "", eventType: "delivered" }), headers: { "content-type": "application/json" }, }); expect(res.status).toBe(400); expect((await res.json()).code).toBe( CAMPAIGNS_API_CODES.WEBHOOK_REJECTED ); }); it("401 without a principal", { timeout: 5000 }, async () => { const { app } = makeFixtures(null); const res = await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: "m1", eventType: "delivered", contactId: "u1", }), headers: { "content-type": "application/json" }, }); expect(res.status).toBe(401); }); it("POST /webhooks/delivery 400 on malformed JSON body", { timeout: 5000, }, async () => { const { app } = makeFixtures(); const res = await app.request("/webhooks/delivery", { method: "POST", body: "not-json{", headers: { "content-type": "application/json" }, }); expect(res.status).toBe(400); }); it("500 when event store slot empty", { timeout: 5000 }, async () => { const app = new Hono(); app.use("*", async (c, next) => { c.set("user", { id: "u", tenantId: "t" } as never); await next(); }); app.onError((err, c) => c.json({ code: (err as CampaignsApiError).code }, 500) ); const e = buildDeliveryWebhookRoute(); app.post(e.route.path, (c) => e.handler(c as never)); const res = await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: "m", eventType: "delivered", contactId: "u1", }), headers: { "content-type": "application/json" }, }); expect((await res.json()).code).toBe( CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE ); }); it("500 when suppression store slot empty", { timeout: 5000, }, async () => { const eventStore = new InMemoryDeliveryEventStore(); const app = new Hono(); app.use("*", async (c, next) => { c.set("user", { id: "u", tenantId: "t" } as never); c.set("deliveryEventStore", eventStore); await next(); }); app.onError((err, c) => c.json({ code: (err as CampaignsApiError).code }, 500) ); const e = buildDeliveryWebhookRoute(); app.post(e.route.path, (c) => e.handler(c as never)); const res = await app.request("/webhooks/delivery", { method: "POST", body: JSON.stringify({ messageId: "m", eventType: "delivered", contactId: "u1", }), headers: { "content-type": "application/json" }, }); expect((await res.json()).code).toBe( CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE ); }); }); describe("webhook unsubscribe route", () => { it("accepts a valid token", { timeout: 5000 }, async () => { const { app, store } = makeFixtures(); const token = btoa(JSON.stringify({ contactId: "u1" })); const res = await app.request(`/webhooks/unsubscribe/${token}`); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.contactId).toBe("u1"); expect(body.data.campaignId).toBeNull(); expect(await store.count("t")).toBe(1); }); it("includes campaignId metadata when supplied", { timeout: 5000, }, async () => { const { app, store } = makeFixtures(); const token = btoa( JSON.stringify({ contactId: "u1", campaignId: "cmp1" }) ); const res = await app.request(`/webhooks/unsubscribe/${token}`); expect(res.status).toBe(200); const entries = await store.list({ tenantId: "t" }); expect(entries[0]?.metadata).toEqual({ campaignId: "cmp1" }); }); it("is idempotent — repeat call does not double-insert", { timeout: 5000, }, async () => { const { app, store } = makeFixtures(); const token = btoa(JSON.stringify({ contactId: "u1" })); await app.request(`/webhooks/unsubscribe/${token}`); await app.request(`/webhooks/unsubscribe/${token}`); expect(await store.count("t")).toBe(1); }); it("rejects an undecodable token", { timeout: 5000 }, async () => { const { app } = makeFixtures(); const res = await app.request("/webhooks/unsubscribe/ÿÿÿÿ"); expect(res.status).toBe(400); expect((await res.json()).code).toBe( CAMPAIGNS_API_CODES.INVALID_UNSUBSCRIBE_TOKEN ); }); it("rejects a token without contactId", { timeout: 5000 }, async () => { const { app } = makeFixtures(); const token = btoa(JSON.stringify({ campaignId: "cmp1" })); const res = await app.request(`/webhooks/unsubscribe/${token}`); expect(res.status).toBe(400); }); it("401 without a principal", { timeout: 5000 }, async () => { const { app } = makeFixtures(null); const token = btoa(JSON.stringify({ contactId: "u1" })); const res = await app.request(`/webhooks/unsubscribe/${token}`); expect(res.status).toBe(401); }); it("500 when suppression store slot empty", { timeout: 5000, }, async () => { const app = new Hono(); app.use("*", async (c, next) => { c.set("user", { id: "u", tenantId: "t" } as never); await next(); }); app.onError((err, c) => c.json({ code: (err as CampaignsApiError).code }, 500) ); const e = buildUnsubscribeRoute(); app.get(e.route.path, (c) => e.handler(c as never)); const token = btoa(JSON.stringify({ contactId: "u1" })); const res = await app.request(`/webhooks/unsubscribe/${token}`); expect((await res.json()).code).toBe( CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE ); }); });