Initial Commit
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user