325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
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
|
|
);
|
|
});
|
|
});
|