277 lines
9.5 KiB
TypeScript
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");
|
|
});
|
|
});
|