mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
658 lines
23 KiB
TypeScript
658 lines
23 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { createHandlers } from "@/services/api/handlers.js";
|
|
import { globalEmitter } from "@/shared/index.js";
|
|
import { STREAMS } from "@/contracts/index.js";
|
|
|
|
/**
|
|
* Covers the operational surface of the API — system health, metrics, DLQ
|
|
* inspection/replay, workflow definitions and the SSE delivery stream. These
|
|
* handlers reach for `db` query chains and raw redis commands, so the doubles
|
|
* here are richer than the ones in api.test.ts.
|
|
*/
|
|
|
|
/** A drizzle-ish chain where every builder method returns itself and awaiting resolves `rows`. */
|
|
function queryChain(rows: any) {
|
|
const chain: any = new Proxy(
|
|
{},
|
|
{
|
|
get(_t, prop) {
|
|
if (prop === "then") {
|
|
return (resolve: any, reject: any) => Promise.resolve(rows).then(resolve, reject);
|
|
}
|
|
return () => chain;
|
|
},
|
|
},
|
|
);
|
|
return chain;
|
|
}
|
|
|
|
function createMockRes() {
|
|
const res = {
|
|
statusCode: 200,
|
|
headers: {} as Record<string, string>,
|
|
body: "",
|
|
written: [] as string[],
|
|
setHeader: vi.fn((k: string, v: string) => {
|
|
res.headers[k] = v;
|
|
}),
|
|
writeHead: vi.fn((code: number, headers?: any) => {
|
|
res.statusCode = code;
|
|
Object.assign(res.headers, headers || {});
|
|
return res;
|
|
}),
|
|
write: vi.fn((chunk: string) => {
|
|
res.written.push(chunk);
|
|
return true;
|
|
}),
|
|
end: vi.fn((data?: string) => {
|
|
if (data) res.body = data;
|
|
}),
|
|
} as any;
|
|
return res;
|
|
}
|
|
|
|
function createMockReq(body?: any) {
|
|
return {
|
|
on: vi.fn((event: string, cb: any) => {
|
|
if (event === "data" && body !== undefined) cb(Buffer.from(JSON.stringify(body)));
|
|
if (event === "end") cb();
|
|
}),
|
|
headers: {},
|
|
} as any;
|
|
}
|
|
|
|
const ctx = (over: any = {}) =>
|
|
({
|
|
projectId: "proj_1",
|
|
params: {},
|
|
query: new URLSearchParams(),
|
|
...over,
|
|
}) as any;
|
|
|
|
const parse = (res: any) => JSON.parse(res.body);
|
|
|
|
describe("API operational handlers", () => {
|
|
let deps: any;
|
|
let handlers: ReturnType<typeof createHandlers>;
|
|
/** Rows handed to successive `db.select()` calls, in order. */
|
|
let selectRows: any[];
|
|
|
|
beforeEach(() => {
|
|
selectRows = [];
|
|
deps = {
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
producers: { normal: { publish: vi.fn(), publishBatch: vi.fn() } },
|
|
userRepo: { findRecordById: vi.fn().mockResolvedValue({ id: "usr_1", language: "en" }) },
|
|
contactRepo: { findByUserId: vi.fn().mockResolvedValue([{ channel: "email" }]) },
|
|
templateRepo: {},
|
|
projectRepo: {},
|
|
workflowRepo: { cancelInstance: vi.fn().mockResolvedValue(true) },
|
|
segmentRepo: {},
|
|
db: {
|
|
select: vi.fn(() => queryChain(selectRows.length ? selectRows.shift() : [])),
|
|
insert: vi.fn(() => ({
|
|
values: () => ({ onConflictDoUpdate: () => Promise.resolve() }),
|
|
})),
|
|
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
transaction: vi.fn(async (cb: any) => cb({})),
|
|
},
|
|
redis: {
|
|
native: {
|
|
ping: vi.fn().mockResolvedValue("PONG"),
|
|
mget: vi.fn().mockResolvedValue([]),
|
|
xlen: vi.fn().mockResolvedValue(3),
|
|
xrevrange: vi.fn().mockResolvedValue([]),
|
|
xrange: vi.fn().mockResolvedValue([]),
|
|
xadd: vi.fn().mockResolvedValue("1-0"),
|
|
xdel: vi.fn().mockResolvedValue(1),
|
|
publish: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
handlers = createHandlers(deps);
|
|
});
|
|
|
|
describe("getSystemHealth", () => {
|
|
it("reports healthy when redis and db both answer", async () => {
|
|
const res = createMockRes();
|
|
await handlers.getSystemHealth(createMockReq(), res, ctx());
|
|
|
|
const body = parse(res);
|
|
expect(body.status).toBe("healthy");
|
|
expect(body.redis.ok).toBe(true);
|
|
expect(body.db.ok).toBe(true);
|
|
});
|
|
|
|
it("degrades when redis is unreachable and skips the heartbeat read", async () => {
|
|
deps.redis.native.ping.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
const res = createMockRes();
|
|
await handlers.getSystemHealth(createMockReq(), res, ctx());
|
|
|
|
const body = parse(res);
|
|
expect(body.status).toBe("degraded");
|
|
expect(body.redis.ok).toBe(false);
|
|
expect(deps.redis.native.mget).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("degrades when the database query throws", async () => {
|
|
deps.db.execute.mockRejectedValue(new Error("db down"));
|
|
const res = createMockRes();
|
|
await handlers.getSystemHealth(createMockReq(), res, ctx());
|
|
|
|
expect(parse(res).status).toBe("degraded");
|
|
expect(parse(res).db.ok).toBe(false);
|
|
});
|
|
|
|
it("parses worker heartbeats and marks silent workers unknown", async () => {
|
|
deps.redis.native.mget.mockResolvedValue([
|
|
JSON.stringify({ status: "ok", pid: 1 }),
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.getSystemHealth(createMockReq(), res, ctx());
|
|
|
|
const { workers } = parse(res);
|
|
expect(workers.enricher).toEqual({ status: "ok", pid: 1 });
|
|
expect(workers.engine).toEqual({ status: "unknown", message: "No heartbeat" });
|
|
});
|
|
|
|
it("marks every worker errored when the heartbeat read fails", async () => {
|
|
deps.redis.native.mget.mockRejectedValue(new Error("mget exploded"));
|
|
const res = createMockRes();
|
|
await handlers.getSystemHealth(createMockReq(), res, ctx());
|
|
|
|
const { workers } = parse(res);
|
|
expect(workers.enricher).toEqual({ status: "error", error: "mget exploded" });
|
|
expect(workers.events).toEqual({ status: "error", error: "mget exploded" });
|
|
});
|
|
});
|
|
|
|
describe("getSystemMetrics", () => {
|
|
it("returns stream depths and a computed success rate", async () => {
|
|
selectRows = [[{ count: 10 }], [{ count: 5 }]];
|
|
const res = createMockRes();
|
|
await handlers.getSystemMetrics(createMockReq(), res, ctx());
|
|
|
|
const body = parse(res);
|
|
expect(body.streams.INBOUND_NORMAL).toBe(3);
|
|
expect(body.deliveryStats).toMatchObject({ total: 10, delivered: 5, successRate: 50 });
|
|
});
|
|
|
|
it("treats an unreadable stream as depth zero", async () => {
|
|
deps.redis.native.xlen.mockRejectedValue(new Error("no such key"));
|
|
selectRows = [[{ count: 0 }], [{ count: 0 }]];
|
|
const res = createMockRes();
|
|
await handlers.getSystemMetrics(createMockReq(), res, ctx());
|
|
|
|
expect(parse(res).streams.INBOUND_NORMAL).toBe(0);
|
|
});
|
|
|
|
it("reports 100% success when nothing has been sent", async () => {
|
|
selectRows = [[], []];
|
|
const res = createMockRes();
|
|
await handlers.getSystemMetrics(createMockReq(), res, ctx());
|
|
|
|
expect(parse(res).deliveryStats).toMatchObject({ total: 0, successRate: 100 });
|
|
});
|
|
|
|
it("computes project-scoped failures without exposing global dead letter count", async () => {
|
|
selectRows = [[{ count: 20 }], [{ count: 18 }]];
|
|
// Global DEAD_LETTER depth is 3 in mock, but project failure is 20 - 18 = 2
|
|
const res = createMockRes();
|
|
await handlers.getSystemMetrics(createMockReq(), res, ctx({ projectId: "proj_1" }));
|
|
|
|
const body = parse(res);
|
|
expect(body.deliveryStats.failed).toBe(2);
|
|
expect(body.deliveryStats.delivered).toBe(18);
|
|
expect(body.deliveryStats.total).toBe(20);
|
|
});
|
|
|
|
it("omits global stream queue depths when called by a non-admin tenant key", async () => {
|
|
selectRows = [[{ count: 10 }], [{ count: 10 }]];
|
|
const res = createMockRes();
|
|
await handlers.getSystemMetrics(
|
|
createMockReq(),
|
|
res,
|
|
ctx({ projectId: "proj_1", isAdmin: false }),
|
|
);
|
|
|
|
const body = parse(res);
|
|
expect(body.streams).toEqual({});
|
|
expect(body.deliveryStats.total).toBe(10);
|
|
expect(body.deliveryStats.delivered).toBe(10);
|
|
expect(body.deliveryStats.failed).toBe(0);
|
|
});
|
|
|
|
it("uses consumer group lag and pending entries when xinfo is available", async () => {
|
|
selectRows = [[{ count: 0 }], [{ count: 0 }]];
|
|
deps.redis.native.xinfo = vi.fn().mockImplementation(async (_cmd: string, stream: string) => {
|
|
if (stream.includes("outbound:critical")) {
|
|
return [["name", "notifkit:group:delivery", "pending", 0, "lag", 0]];
|
|
}
|
|
if (stream.includes("outbound:normal")) {
|
|
return [["name", "notifkit:group:delivery", "pending", 2, "lag", 5]];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
const res = createMockRes();
|
|
await handlers.getSystemMetrics(createMockReq(), res, ctx());
|
|
|
|
const body = parse(res);
|
|
expect(body.streams.OUTBOUND_CRITICAL).toBe(0); // 0 lag + 0 pending
|
|
expect(body.streams.OUTBOUND_LOW).toBe(3); // fallback to xlen when xinfo returns empty
|
|
expect(body.streams.OUTBOUND_NORMAL).toBe(7); // 5 lag + 2 pending
|
|
});
|
|
});
|
|
|
|
describe("DLQ", () => {
|
|
it("maps raw stream entries into messages", async () => {
|
|
deps.redis.native.xrevrange.mockResolvedValue([
|
|
[
|
|
"1-0",
|
|
[
|
|
"eventType",
|
|
"notification.requested",
|
|
"payload",
|
|
JSON.stringify({ a: 1 }),
|
|
"error",
|
|
"boom",
|
|
"timestamp",
|
|
"2026-01-01T00:00:00.000Z",
|
|
],
|
|
],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.getDLQMessages(createMockReq(), res, ctx({ projectId: undefined }));
|
|
|
|
expect(parse(res).messages[0]).toEqual({
|
|
id: "1-0",
|
|
eventType: "notification.requested",
|
|
payload: { a: 1 },
|
|
error: "boom",
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
});
|
|
|
|
it("filters out DLQ messages belonging to other projects", async () => {
|
|
deps.redis.native.xrevrange.mockResolvedValue([
|
|
[
|
|
"1-0",
|
|
[
|
|
"eventType",
|
|
"notification.requested",
|
|
"payload",
|
|
JSON.stringify({ projectId: "proj_1", text: "mine" }),
|
|
"error",
|
|
"boom",
|
|
"timestamp",
|
|
"2026-01-01T00:00:00.000Z",
|
|
],
|
|
],
|
|
[
|
|
"2-0",
|
|
[
|
|
"eventType",
|
|
"notification.requested",
|
|
"payload",
|
|
JSON.stringify({ projectId: "proj_other", text: "other" }),
|
|
"error",
|
|
"boom",
|
|
"timestamp",
|
|
"2026-01-01T00:00:00.000Z",
|
|
],
|
|
],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.getDLQMessages(createMockReq(), res, ctx({ projectId: "proj_1" }));
|
|
|
|
const msgs = parse(res).messages;
|
|
expect(msgs).toHaveLength(1);
|
|
expect(msgs[0].id).toBe("1-0");
|
|
});
|
|
|
|
it("falls back across the alternate field names", async () => {
|
|
deps.redis.native.xrevrange.mockResolvedValue([
|
|
["2-0", ["event_type", "legacy.event", "reason", "legacy reason", "projectId", "proj_1"]],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.getDLQMessages(createMockReq(), res, ctx());
|
|
|
|
const msg = parse(res).messages[0];
|
|
expect(msg.eventType).toBe("legacy.event");
|
|
expect(msg.error).toBe("legacy reason");
|
|
// With no `payload` field the whole field map stands in for it.
|
|
expect(msg.payload).toMatchObject({ event_type: "legacy.event" });
|
|
});
|
|
|
|
it("returns an empty list when the stream read fails", async () => {
|
|
deps.redis.native.xrevrange.mockRejectedValue(new Error("no stream"));
|
|
const res = createMockRes();
|
|
await handlers.getDLQMessages(createMockReq(), res, ctx());
|
|
|
|
expect(parse(res).messages).toEqual([]);
|
|
});
|
|
|
|
it("replay rejects a request with no id", async () => {
|
|
const res = createMockRes();
|
|
await handlers.replayDLQMessage(createMockReq({}), res, ctx());
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(parse(res).error).toBe("missing_message_id");
|
|
});
|
|
|
|
it("replay 404s when the entry is gone", async () => {
|
|
deps.redis.native.xrange.mockResolvedValue([]);
|
|
const res = createMockRes();
|
|
await handlers.replayDLQMessage(createMockReq({ id: "1-0" }), res, ctx());
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
expect(parse(res).error).toBe("dlq_message_not_found");
|
|
});
|
|
|
|
it("replay 404s when the entry belongs to a different project", async () => {
|
|
deps.redis.native.xrange.mockResolvedValue([
|
|
["1-0", ["priority", "normal", "payload", JSON.stringify({ projectId: "proj_other" })]],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.replayDLQMessage(
|
|
createMockReq({ id: "1-0" }),
|
|
res,
|
|
ctx({ projectId: "proj_1" }),
|
|
);
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
expect(parse(res).error).toBe("dlq_message_not_found");
|
|
});
|
|
|
|
it("delete 404s when the entry belongs to a different project", async () => {
|
|
deps.redis.native.xrange.mockResolvedValue([
|
|
["1-0", ["priority", "normal", "payload", JSON.stringify({ projectId: "proj_other" })]],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.deleteDLQMessage(
|
|
createMockReq(),
|
|
res,
|
|
ctx({ projectId: "proj_1", params: { id: "1-0" } }),
|
|
);
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
expect(parse(res).error).toBe("dlq_message_not_found");
|
|
});
|
|
|
|
it("replay 404s when the entry has no projectId for project-scoped caller", async () => {
|
|
deps.redis.native.xrange.mockResolvedValue([
|
|
["1-0", ["priority", "normal", "payload", "{}"]],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.replayDLQMessage(
|
|
createMockReq({ id: "1-0" }),
|
|
res,
|
|
ctx({ projectId: "proj_1" }),
|
|
);
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
expect(parse(res).error).toBe("dlq_message_not_found");
|
|
});
|
|
|
|
it("delete 404s when the entry has no projectId for project-scoped caller", async () => {
|
|
deps.redis.native.xrange.mockResolvedValue([
|
|
["1-0", ["priority", "normal", "payload", "{}"]],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.deleteDLQMessage(
|
|
createMockReq(),
|
|
res,
|
|
ctx({ projectId: "proj_1", params: { id: "1-0" } }),
|
|
);
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
expect(parse(res).error).toBe("dlq_message_not_found");
|
|
});
|
|
|
|
it("replay re-publishes onto the priority stream and drops the DLQ entry", async () => {
|
|
deps.redis.native.xrange.mockResolvedValue([
|
|
["1-0", ["priority", "critical", "payload", JSON.stringify({ projectId: "proj_1" })]],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.replayDLQMessage(
|
|
createMockReq({ id: "1-0" }),
|
|
res,
|
|
ctx({ projectId: "proj_1" }),
|
|
);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(parse(res)).toEqual({
|
|
success: true,
|
|
replayedId: "1-0",
|
|
stream: STREAMS.INBOUND_CRITICAL,
|
|
});
|
|
expect(deps.redis.native.xadd).toHaveBeenCalledWith(
|
|
STREAMS.INBOUND_CRITICAL,
|
|
"*",
|
|
"priority",
|
|
"critical",
|
|
"payload",
|
|
JSON.stringify({ projectId: "proj_1" }),
|
|
);
|
|
expect(deps.redis.native.xdel).toHaveBeenCalledWith(STREAMS.DEAD_LETTER, "1-0");
|
|
});
|
|
|
|
it("replay defaults to the normal stream when no priority is recorded", async () => {
|
|
deps.redis.native.xrange.mockResolvedValue([
|
|
["1-0", ["payload", JSON.stringify({ projectId: "proj_1" })]],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.replayDLQMessage(createMockReq({ id: "1-0" }), res, ctx());
|
|
|
|
expect(deps.redis.native.xadd).toHaveBeenCalledWith(
|
|
STREAMS.INBOUND_NORMAL,
|
|
"*",
|
|
"payload",
|
|
JSON.stringify({ projectId: "proj_1" }),
|
|
);
|
|
});
|
|
|
|
it("replay surfaces a redis failure as a 500", async () => {
|
|
deps.redis.native.xrange.mockRejectedValue(new Error("redis gone"));
|
|
const res = createMockRes();
|
|
await handlers.replayDLQMessage(createMockReq({ id: "1-0" }), res, ctx());
|
|
|
|
expect(res.statusCode).toBe(500);
|
|
expect(parse(res)).toMatchObject({ error: "replay_failed", message: "redis gone" });
|
|
});
|
|
|
|
it("delete removes the entry", async () => {
|
|
deps.redis.native.xrange.mockResolvedValue([
|
|
["1-0", ["payload", JSON.stringify({ projectId: "proj_1" })]],
|
|
]);
|
|
const res = createMockRes();
|
|
await handlers.deleteDLQMessage(createMockReq(), res, ctx({ params: { id: "1-0" } }));
|
|
|
|
expect(parse(res)).toEqual({ success: true });
|
|
expect(deps.redis.native.xdel).toHaveBeenCalledWith(STREAMS.DEAD_LETTER, "1-0");
|
|
});
|
|
|
|
it("delete rejects a missing id", async () => {
|
|
const res = createMockRes();
|
|
await handlers.deleteDLQMessage(createMockReq(), res, ctx({ params: {} }));
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(parse(res).error).toBe("missing_id");
|
|
});
|
|
|
|
it("delete surfaces a redis failure as a 500", async () => {
|
|
deps.redis.native.xrange.mockResolvedValue([
|
|
["1-0", ["payload", JSON.stringify({ projectId: "proj_1" })]],
|
|
]);
|
|
deps.redis.native.xdel.mockRejectedValue(new Error("redis gone"));
|
|
const res = createMockRes();
|
|
await handlers.deleteDLQMessage(createMockReq(), res, ctx({ params: { id: "1-0" } }));
|
|
|
|
expect(res.statusCode).toBe(500);
|
|
expect(parse(res)).toMatchObject({ error: "delete_failed" });
|
|
});
|
|
});
|
|
|
|
describe("workflow definitions", () => {
|
|
it("createWorkflow upserts the definition and returns 201", async () => {
|
|
const res = createMockRes();
|
|
await handlers.createWorkflow(
|
|
createMockReq({ name: "onboarding", steps: [{ action: "wait", duration: "1h" }] }),
|
|
res,
|
|
ctx(),
|
|
);
|
|
|
|
expect(res.statusCode).toBe(201);
|
|
expect(parse(res)).toEqual({ name: "onboarding" });
|
|
});
|
|
|
|
it("createWorkflow rejects an invalid definition", async () => {
|
|
const res = createMockRes();
|
|
await handlers.createWorkflow(createMockReq({ steps: [] }), res, ctx());
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(parse(res).error).toBe("validation_error");
|
|
});
|
|
|
|
it("createWorkflow reports a write failure as a 500", async () => {
|
|
deps.db.insert = vi.fn(() => ({
|
|
values: () => ({ onConflictDoUpdate: () => Promise.reject(new Error("constraint")) }),
|
|
}));
|
|
const res = createMockRes();
|
|
await handlers.createWorkflow(
|
|
createMockReq({ name: "onboarding", steps: [{ action: "wait", duration: "1h" }] }),
|
|
res,
|
|
ctx(),
|
|
);
|
|
|
|
expect(res.statusCode).toBe(500);
|
|
expect(parse(res)).toMatchObject({ error: "internal_error", message: "constraint" });
|
|
expect(deps.logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it("cancelWorkflow returns 204 once the instance is canceled", async () => {
|
|
const res = createMockRes();
|
|
const id = "33333333-3333-4333-8333-333333333333";
|
|
await handlers.cancelWorkflow(createMockReq(), res, ctx({ params: { id } }));
|
|
|
|
expect(res.statusCode).toBe(204);
|
|
expect(deps.workflowRepo.cancelInstance).toHaveBeenCalledWith("proj_1", id);
|
|
});
|
|
|
|
it("cancelWorkflow 400s when the instance cannot be canceled", async () => {
|
|
deps.workflowRepo.cancelInstance.mockResolvedValue(false);
|
|
const res = createMockRes();
|
|
await handlers.cancelWorkflow(createMockReq(), res, ctx({ params: { id: "wf_1" } }));
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(parse(res)).toMatchObject({ error: "workflow_not_cancelable", id: "wf_1" });
|
|
});
|
|
});
|
|
|
|
describe("getScheduledMessages", () => {
|
|
it("returns the project's scheduled payloads", async () => {
|
|
selectRows = [[{ id: "sched_1" }]];
|
|
const res = createMockRes();
|
|
await handlers.getScheduledMessages(createMockReq(), res, ctx());
|
|
|
|
expect(parse(res)).toEqual({ scheduled: [{ id: "sched_1" }], nextCursor: null });
|
|
});
|
|
});
|
|
|
|
describe("getUserDetails", () => {
|
|
it("merges the profile with its contacts and recent logs", async () => {
|
|
deps.contactRepo.findByUserId.mockResolvedValue([
|
|
{ channel: "email", target: "alice@example.com" },
|
|
]);
|
|
selectRows = [[{ taskId: "t1" }], [{ taskId: "t1" }]];
|
|
const res = createMockRes();
|
|
await handlers.getUserDetails(createMockReq(), res, ctx({ params: { id: "usr_1" } }));
|
|
|
|
expect(parse(res)).toMatchObject({
|
|
id: "usr_1",
|
|
contacts: [{ channel: "email", target: "alice@example.com" }],
|
|
logs: [{ taskId: "t1" }],
|
|
});
|
|
});
|
|
|
|
it("returns empty logs when user has no contacts", async () => {
|
|
deps.contactRepo.findByUserId.mockResolvedValue([]);
|
|
const res = createMockRes();
|
|
await handlers.getUserDetails(createMockReq(), res, ctx({ params: { id: "usr_1" } }));
|
|
|
|
expect(parse(res)).toMatchObject({
|
|
id: "usr_1",
|
|
contacts: [],
|
|
logs: [],
|
|
});
|
|
});
|
|
|
|
it("404s for an unknown user", async () => {
|
|
deps.userRepo.findRecordById.mockResolvedValue(null);
|
|
const res = createMockRes();
|
|
await handlers.getUserDetails(createMockReq(), res, ctx({ params: { id: "nope" } }));
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
expect(parse(res)).toMatchObject({ error: "user_not_found", id: "nope" });
|
|
});
|
|
});
|
|
|
|
describe("getEventsStream", () => {
|
|
it("opens an SSE stream and forwards delivery events for the project", async () => {
|
|
const req = createMockReq();
|
|
const res = createMockRes();
|
|
await handlers.getEventsStream(req, res, ctx());
|
|
|
|
expect(res.headers["Content-Type"]).toBe("text/event-stream");
|
|
expect(res.written[0]).toBe("retry: 10000\n\n");
|
|
|
|
globalEmitter.emit("delivery:delivered", "task-1", "prov-1", "email", "proj_1");
|
|
globalEmitter.emit("delivery:failed", "task-2", "boom", "sms", "proj_1");
|
|
|
|
const stream = res.written.join("");
|
|
expect(stream).toContain("event: delivery:delivered");
|
|
expect(stream).toContain('"taskId":"task-1"');
|
|
expect(stream).toContain("event: delivery:failed");
|
|
expect(stream).toContain('"error":"boom"');
|
|
|
|
// Detach so the listeners do not leak into the next test.
|
|
const close = req.on.mock.calls.find((c: any[]) => c[0] === "close")![1];
|
|
close();
|
|
});
|
|
|
|
it("ignores events belonging to another project", async () => {
|
|
const req = createMockReq();
|
|
const res = createMockRes();
|
|
await handlers.getEventsStream(req, res, ctx());
|
|
const before = res.written.length;
|
|
|
|
globalEmitter.emit("delivery:delivered", "task-1", "prov-1", "email", "other_project");
|
|
globalEmitter.emit("delivery:failed", "task-2", "boom", "sms", "other_project");
|
|
|
|
expect(res.written.length).toBe(before);
|
|
|
|
const close = req.on.mock.calls.find((c: any[]) => c[0] === "close")![1];
|
|
close();
|
|
});
|
|
|
|
it("unsubscribes when the client disconnects", async () => {
|
|
const req = createMockReq();
|
|
const res = createMockRes();
|
|
await handlers.getEventsStream(req, res, ctx());
|
|
|
|
const close = req.on.mock.calls.find((c: any[]) => c[0] === "close")![1];
|
|
close();
|
|
|
|
const before = res.written.length;
|
|
globalEmitter.emit("delivery:delivered", "task-1", "prov-1", "email", "proj_1");
|
|
expect(res.written.length).toBe(before);
|
|
});
|
|
});
|
|
});
|