Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,324 @@
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
);
});
});