Files
2026-05-27 23:29:53 +01:00

277 lines
9.5 KiB
TypeScript

/**
* End-to-end integration test for `@suaveplan/api-plugin-campaigns`.
*
* Wires the plugin's full route surface against in-memory
* repositories + a Hono `OpenAPIHono` host, then fires real HTTP
* requests via `app.request(...)` to exercise the happy-path round
* trips for every route group. No mocks — the in-memory stores are
* the genuine reference implementations.
*/
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { type Campaign, InMemoryCampaignStore } from "@suaveplan/campaigns";
import { InMemoryDeliveryEventStore } from "@suaveplan/delivery-tracking";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildCampaignRoutes,
buildSuppressionRoutes,
buildWebhookRoutes,
CampaignsApiError,
InMemoryCampaignSuppressionStore,
} from "../../src/index.js";
function makeStack() {
const campaignStore = new InMemoryCampaignStore();
let nextId = 0;
const suppressionStore = new InMemoryCampaignSuppressionStore({
clock: () => 1_700_000_000_000,
idGenerator: () => `s${++nextId}`,
});
const deliveryEventStore = new InMemoryDeliveryEventStore();
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", { id: "u1", tenantId: "t1" } as never);
c.set("campaignRepository", campaignStore);
c.set("campaignSuppressionStore", suppressionStore);
c.set("deliveryEventStore", deliveryEventStore);
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 | 404 | 409 | 500;
return c.json({ code: err.code, error: err.message }, status);
}
return c.json({ error: String(err) }, 500);
});
const registry = createOpenAPIRegistry({
info: { title: "campaigns-test", version: "0.0.0" },
});
for (const entry of [
...buildCampaignRoutes(),
...buildSuppressionRoutes(),
...buildWebhookRoutes(),
]) {
const method = entry.route.method as
| "get"
| "post"
| "patch"
| "delete";
app[method](entry.route.path, (c) => entry.handler(c as never));
registry.registerRoute({
method: entry.route.method,
path: entry.route.path,
tags: entry.route.tags ?? [],
pluginName: "campaigns",
});
}
return {
app,
campaignStore,
suppressionStore,
deliveryEventStore,
registry,
};
}
describe("api-plugin-campaigns integration", () => {
it("rounds-trip CRUD: create, list, get, update, delete", {
timeout: 30_000,
}, async () => {
const { app } = makeStack();
const create = await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({
name: "Promo",
channel: "email",
audienceConfig: { groupIds: ["g1"] },
}),
headers: { "content-type": "application/json" },
});
expect(create.status).toBe(201);
const created = await create.json();
const id = created.data.id;
expect(id).toBeTruthy();
const list = await app.request("/campaigns");
expect(list.status).toBe(200);
expect((await list.json()).data.total).toBe(1);
const get = await app.request(`/campaigns/${id}`);
expect(get.status).toBe(200);
expect((await get.json()).data.name).toBe("Promo");
const patch = await app.request(`/campaigns/${id}`, {
method: "PATCH",
body: JSON.stringify({ name: "Promo v2" }),
headers: { "content-type": "application/json" },
});
expect(patch.status).toBe(200);
expect((await patch.json()).data.name).toBe("Promo v2");
const del = await app.request(`/campaigns/${id}`, {
method: "DELETE",
});
expect(del.status).toBe(204);
});
it("rounds-trip action: send → pause", {
timeout: 30_000,
}, async () => {
const { app } = makeStack();
const created = await (
await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({ name: "Promo" }),
headers: { "content-type": "application/json" },
})
).json();
const id = created.data.id;
const send = await app.request(`/campaigns/${id}/send`, {
method: "POST",
});
expect(send.status).toBe(200);
expect((await send.json()).data.status).toBe("sending");
const pause = await app.request(`/campaigns/${id}/pause`, {
method: "POST",
});
expect(pause.status).toBe(200);
expect((await pause.json()).data.status).toBe("paused");
});
it("rounds-trip suppression: create, check, list, delete", {
timeout: 30_000,
}, async () => {
const { app } = makeStack();
const created = await app.request("/suppression", {
method: "POST",
body: JSON.stringify({
channelValue: "a@b.c",
reason: "bounce",
}),
headers: { "content-type": "application/json" },
});
expect(created.status).toBe(201);
const entry = (await created.json()).data;
const check = await app.request(
"/suppression/check?channel=email&channelValue=a@b.c"
);
expect(check.status).toBe(200);
expect((await check.json()).data.suppressed).toBe(true);
const list = await app.request("/suppression");
expect((await list.json()).data.total).toBe(1);
const del = await app.request(`/suppression/${entry.id}`, {
method: "DELETE",
});
expect(del.status).toBe(204);
});
it("delivery webhook records event + suppresses on unsubscribe", {
timeout: 30_000,
}, async () => {
const { app, suppressionStore } = makeStack();
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);
expect((await res.json()).data.suppressed).toBe(true);
expect(await suppressionStore.count("t1")).toBe(1);
});
it("unsubscribe token round-trip persists suppression", {
timeout: 30_000,
}, async () => {
const { app, suppressionStore } = makeStack();
const token = btoa(JSON.stringify({ contactId: "u1" }));
const res = await app.request(`/webhooks/unsubscribe/${token}`);
expect(res.status).toBe(200);
expect((await res.json()).data.contactId).toBe("u1");
expect(await suppressionStore.count("t1")).toBe(1);
});
it("campaign duplicate produces a draft clone", {
timeout: 30_000,
}, async () => {
const { app, campaignStore } = makeStack();
await campaignStore.create({
id: "cmp1",
tenantId: "t1",
name: "Promo",
status: "completed",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: [] },
metadata: {},
stats: {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
},
createdAt: new Date(),
updatedAt: new Date(),
} as Campaign);
const res = await app.request("/campaigns/cmp1/duplicate", {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.status).toBe("draft");
expect(body.data.name).toBe("Promo (copy)");
});
it("test-send echoes recipients without persisting state", {
timeout: 30_000,
}, async () => {
const { app } = makeStack();
const created = await (
await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({ name: "Promo" }),
headers: { "content-type": "application/json" },
})
).json();
const res = await app.request(`/campaigns/${created.data.id}/test`, {
method: "POST",
body: JSON.stringify({ recipients: ["a@b.com", "d@e.com"] }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
expect((await res.json()).data.recipients).toEqual([
"a@b.com",
"d@e.com",
]);
});
it("OpenAPI registry contains every plugin route", {
timeout: 30_000,
}, () => {
const { registry } = makeStack();
const paths = registry.getRoutes().map((r) => r.path);
expect(paths).toContain("/campaigns");
expect(paths).toContain("/campaigns/:id/send");
expect(paths).toContain("/suppression/check");
expect(paths).toContain("/webhooks/delivery");
expect(paths).toContain("/webhooks/unsubscribe/:token");
});
});