/** * 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"); }); });