mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
460 lines
15 KiB
TypeScript
460 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { workflowRegistry } from "@/workflows/registry.js";
|
|
import { buildStepNotifyPayload } from "@/workflows/sdk.js";
|
|
import { NotificationRequestedPayloadSchema } from "@/contracts/events/notification-requested.js";
|
|
import type { StreamMessage } from "@/queue/index.js";
|
|
|
|
const { mockDb, mockRedisNative, mockNotificationProducer, mockWorkflowProducer } = vi.hoisted(
|
|
() => {
|
|
return {
|
|
mockDb: {
|
|
select: vi.fn().mockReturnThis(),
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockReturnThis(),
|
|
insert: vi.fn().mockReturnThis(),
|
|
values: vi.fn().mockReturnThis(),
|
|
returning: vi.fn().mockReturnThis(),
|
|
update: vi.fn().mockReturnThis(),
|
|
set: vi.fn().mockReturnThis(),
|
|
delete: vi.fn().mockReturnThis(),
|
|
orderBy: vi.fn().mockReturnThis(),
|
|
then: vi.fn(),
|
|
},
|
|
// The ioredis client the worker actually receives.
|
|
mockRedisNative: {
|
|
zadd: vi.fn(),
|
|
releaseLock: vi.fn().mockResolvedValue(1),
|
|
renewLock: vi.fn().mockResolvedValue(1),
|
|
set: vi.fn().mockResolvedValue("OK"),
|
|
del: vi.fn(),
|
|
duplicate: vi.fn(),
|
|
},
|
|
mockNotificationProducer: {
|
|
publish: vi.fn(),
|
|
publishBatch: vi.fn().mockResolvedValue({ messageIds: ["1", "2"], eventIds: ["a", "b"] }),
|
|
},
|
|
mockWorkflowProducer: {
|
|
publish: vi.fn(),
|
|
publishBatch: vi.fn().mockResolvedValue({ messageIds: ["1", "2"], eventIds: ["a", "b"] }),
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
vi.mock("../src/db/index.js", () => ({
|
|
createDatabase: () => ({ db: mockDb, sql: { end: vi.fn() } }),
|
|
}));
|
|
|
|
vi.mock("../src/redis/index.js", async (importOriginal) => {
|
|
const actual = (await importOriginal()) as any;
|
|
return {
|
|
...actual,
|
|
RedisClient: vi.fn().mockImplementation(() => ({
|
|
native: mockRedisNative,
|
|
healthCheck: vi.fn().mockResolvedValue(true),
|
|
disconnect: vi.fn(),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("../src/logger/index.js", () => ({
|
|
createLogger: () => ({
|
|
child: vi.fn().mockReturnThis(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../src/queue/index.js", () => ({
|
|
StreamConsumer: vi.fn(),
|
|
PendingMessageScanner: vi.fn(),
|
|
StreamProducer: vi.fn().mockImplementation((opts) => {
|
|
if (opts.stream.includes("workflow")) return mockWorkflowProducer;
|
|
return mockNotificationProducer;
|
|
}),
|
|
}));
|
|
|
|
// Import after mocks
|
|
import { WorkflowWorker, __injectForTests } from "@/services/workflow/main.js";
|
|
|
|
describe("Workflow Engine Edge Cases", () => {
|
|
let worker: WorkflowWorker;
|
|
const mockConsumer = { redis: mockRedisNative, ack: vi.fn(), nack: vi.fn() } as any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
worker = new WorkflowWorker({
|
|
consumer: mockConsumer,
|
|
pendingScanner: {} as any,
|
|
logger: {
|
|
child: vi.fn().mockReturnThis(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
} as any,
|
|
maxRetriesBeforeDlq: 5,
|
|
redis: mockRedisNative as any,
|
|
db: mockDb,
|
|
workflowProducer: mockWorkflowProducer,
|
|
notificationProducer: mockNotificationProducer,
|
|
});
|
|
__injectForTests(mockRedisNative, mockDb, mockWorkflowProducer, mockNotificationProducer);
|
|
|
|
mockDb.select.mockReturnThis();
|
|
mockDb.from.mockReturnThis();
|
|
mockDb.where.mockReturnThis();
|
|
mockDb.limit.mockReturnThis();
|
|
mockDb.insert.mockReturnThis();
|
|
mockDb.values.mockReturnThis();
|
|
mockDb.returning.mockReturnThis();
|
|
mockDb.update.mockReturnThis();
|
|
mockDb.set.mockReturnThis();
|
|
mockDb.delete.mockReturnThis();
|
|
mockDb.orderBy.mockReturnThis();
|
|
// Default then resolving to empty array
|
|
mockDb.then.mockImplementation((resolve) => {
|
|
resolve([]);
|
|
});
|
|
});
|
|
|
|
it("suspends execution on step.wait and schedules a timeout", async () => {
|
|
workflowRegistry.register("test-wait", async ({ step }) => {
|
|
await step.notify({ channels: ["email"], template: "t1" });
|
|
await step.wait("2h");
|
|
await step.notify({ channels: ["sms"], template: "t2" });
|
|
});
|
|
|
|
const msg: StreamMessage = {
|
|
id: "1-0",
|
|
event: {
|
|
id: "evt-1",
|
|
type: "workflow.triggered",
|
|
payload: {
|
|
name: "test-wait",
|
|
instanceId: "inst-1",
|
|
projectId: "00000000-0000-0000-0000-000000000000",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
metadata: { traceId: "t-1", source: "api", retryCount: 0 },
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
};
|
|
|
|
// 1st run: new instance, no steps
|
|
mockDb.then.mockImplementationOnce((res) => res([])); // instance query
|
|
mockDb.returning.mockResolvedValueOnce([
|
|
{ id: "inst-1", name: "test-wait", status: "pending", input: { user: { id: "user-1" } } },
|
|
]); // insert returning — the row stores the trigger input, so it carries the user
|
|
mockDb.then.mockImplementationOnce((res) => res([{ id: "claimed" }])); // update to running claims the instance
|
|
mockDb.then.mockImplementationOnce((res) => res([])); // steps query
|
|
|
|
await worker.process(msg);
|
|
|
|
// Should have published 1 notification
|
|
expect(mockNotificationProducer.publishBatch).toHaveBeenCalledTimes(1);
|
|
// Should have inserted 2 steps (notify, wait)
|
|
expect(mockDb.insert).toHaveBeenCalledTimes(3); // 1 instance + 2 steps
|
|
// Should have scheduled timeout in redis
|
|
expect(mockRedisNative.zadd).toHaveBeenCalledWith(
|
|
"notif:workflow:timers",
|
|
expect.any(Number),
|
|
expect.stringContaining("inst-1"),
|
|
);
|
|
});
|
|
|
|
it("replays deterministically and skips executed steps", async () => {
|
|
// 2nd run: waking up from wait
|
|
const msg: StreamMessage = {
|
|
id: "2-0",
|
|
event: {
|
|
id: "evt-2",
|
|
type: "workflow.resumed",
|
|
payload: {
|
|
name: "test-wait",
|
|
instanceId: "inst-1",
|
|
projectId: "00000000-0000-0000-0000-000000000000",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
metadata: { traceId: "t-2", source: "scheduler", retryCount: 0 },
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
};
|
|
|
|
// Existing instance
|
|
mockDb.then.mockImplementationOnce((res) =>
|
|
res([
|
|
{ id: "inst-1", name: "test-wait", status: "pending", input: { user: { id: "user-1" } } },
|
|
]),
|
|
);
|
|
mockDb.then.mockImplementationOnce((res) => res([{ id: "claimed" }])); // update to running claims the instance
|
|
|
|
// Existing steps: notify and wait
|
|
mockDb.then.mockImplementationOnce((res) =>
|
|
res([
|
|
{ stepIndex: "0", action: "notify", output: { success: true } },
|
|
{ stepIndex: "1", action: "wait", output: { scheduledAt: 12345 } },
|
|
]),
|
|
);
|
|
|
|
await worker.process(msg);
|
|
|
|
// Should NOT have published the first notification again, only the SECOND notification
|
|
expect(mockNotificationProducer.publishBatch).toHaveBeenCalledTimes(1);
|
|
// Should have inserted 1 step (the final notify)
|
|
expect(mockDb.insert).toHaveBeenCalledTimes(1);
|
|
// Should have marked workflow as completed
|
|
expect(mockDb.update).toHaveBeenCalled();
|
|
expect(mockDb.set).toHaveBeenLastCalledWith({ status: "completed" });
|
|
});
|
|
|
|
it("handles step.waitForEvent successfully", async () => {
|
|
workflowRegistry.register("test-wait-event", async ({ step }) => {
|
|
const evt = await step.waitForEvent("user.action", {
|
|
timeout: "1h",
|
|
match: { userId: "user-1" },
|
|
});
|
|
if (evt) {
|
|
await step.notify({ channels: ["push"], template: "success" });
|
|
}
|
|
});
|
|
|
|
const msg: StreamMessage = {
|
|
id: "3-0",
|
|
event: {
|
|
id: "evt-3",
|
|
type: "workflow.triggered",
|
|
payload: {
|
|
name: "test-wait-event",
|
|
instanceId: "inst-2",
|
|
projectId: "00000000-0000-0000-0000-000000000000",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
metadata: { traceId: "t-3", source: "api", retryCount: 0 },
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
};
|
|
|
|
mockDb.then.mockImplementationOnce((res) => res([])); // instance
|
|
mockDb.returning.mockResolvedValueOnce([
|
|
{
|
|
id: "inst-2",
|
|
name: "test-wait-event",
|
|
status: "pending",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
]);
|
|
mockDb.then.mockImplementationOnce((res) => res([{ id: "claimed" }])); // update to running claims the instance
|
|
mockDb.then.mockImplementationOnce((res) => res([])); // steps
|
|
|
|
await worker.process(msg);
|
|
|
|
// Should have registered waiter and pending step, and ZSET
|
|
expect(mockDb.insert).toHaveBeenCalledTimes(3); // instance, waiter, step
|
|
expect(mockRedisNative.zadd).toHaveBeenCalled();
|
|
|
|
// Now simulate resume with event
|
|
const resumeMsg: StreamMessage = {
|
|
id: "4-0",
|
|
event: {
|
|
id: "evt-4",
|
|
type: "workflow.resumed",
|
|
payload: {
|
|
name: "test-wait-event",
|
|
instanceId: "inst-2",
|
|
projectId: "00000000-0000-0000-0000-000000000000",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
metadata: { traceId: "t-4", source: "events", retryCount: 0 },
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
};
|
|
|
|
mockDb.then.mockImplementationOnce((res) =>
|
|
res([
|
|
{
|
|
id: "inst-2",
|
|
name: "test-wait-event",
|
|
status: "pending",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
]),
|
|
);
|
|
mockDb.then.mockImplementationOnce((res) => res([{ id: "claimed" }])); // update to running claims the instance
|
|
// Steps now contain the event output injected by EventWorker
|
|
mockDb.then.mockImplementationOnce((res) =>
|
|
res([{ stepIndex: "0", action: "waitForEvent", output: { action: "clicked" } }]),
|
|
);
|
|
|
|
await worker.process(resumeMsg);
|
|
|
|
// Should have published the success notification (since evt was truthy)
|
|
expect(mockNotificationProducer.publishBatch).toHaveBeenCalledTimes(1);
|
|
expect(mockDb.update).toHaveBeenCalled();
|
|
expect(mockDb.set).toHaveBeenLastCalledWith({ status: "completed" });
|
|
});
|
|
|
|
it("goes back to sleep when resumed before the awaited event or its timeout", async () => {
|
|
// A resume with the step still unfilled — no event recorded, no timeout —
|
|
// is spurious (a duplicate trigger, a replayed message). A real timeout
|
|
// writes { timedOut: true }; see the next test.
|
|
const msg: StreamMessage = {
|
|
id: "5-0",
|
|
event: {
|
|
id: "evt-5",
|
|
type: "workflow.resumed",
|
|
payload: {
|
|
name: "test-wait-event",
|
|
instanceId: "inst-2",
|
|
projectId: "00000000-0000-0000-0000-000000000000",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
metadata: { traceId: "t-5", source: "scheduler", retryCount: 0 },
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
};
|
|
|
|
mockDb.then.mockImplementationOnce((res) =>
|
|
res([
|
|
{
|
|
id: "inst-2",
|
|
name: "test-wait-event",
|
|
status: "pending",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
]),
|
|
);
|
|
mockDb.then.mockImplementationOnce((res) => res([{ id: "claimed" }])); // update to running claims the instance
|
|
// Steps contain NO output (null) because EventWorker didn't inject anything, the scheduler woke it up
|
|
mockDb.then.mockImplementationOnce((res) =>
|
|
res([{ stepIndex: "0", action: "waitForEvent", output: null }]),
|
|
);
|
|
|
|
await worker.process(msg);
|
|
|
|
expect(mockNotificationProducer.publish).not.toHaveBeenCalled();
|
|
// Suspended again rather than carrying on as if the wait had timed out.
|
|
expect(mockDb.set).toHaveBeenLastCalledWith({ status: "pending" });
|
|
});
|
|
|
|
it("handles step.waitForEvent when persisted step output is { timedOut: true }", async () => {
|
|
const msg: StreamMessage = {
|
|
id: "6-0",
|
|
event: {
|
|
id: "evt-6",
|
|
type: "workflow.resumed",
|
|
payload: {
|
|
name: "test-wait-event",
|
|
instanceId: "inst-2",
|
|
projectId: "00000000-0000-0000-0000-000000000000",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
metadata: { traceId: "t-6", source: "scheduler", retryCount: 0 },
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
};
|
|
|
|
mockDb.then.mockImplementationOnce((res) =>
|
|
res([
|
|
{
|
|
id: "inst-2",
|
|
name: "test-wait-event",
|
|
status: "pending",
|
|
input: { user: { id: "user-1" } },
|
|
},
|
|
]),
|
|
);
|
|
mockDb.then.mockImplementationOnce((res) => res([{ id: "claimed" }])); // update to running claims the instance
|
|
// Persisted step records timedOut: true
|
|
mockDb.then.mockImplementationOnce((res) =>
|
|
res([{ stepIndex: "0", action: "waitForEvent", output: { timedOut: true } }]),
|
|
);
|
|
|
|
await worker.process(msg);
|
|
|
|
// Should NOT have published the success notification
|
|
expect(mockNotificationProducer.publish).not.toHaveBeenCalled();
|
|
expect(mockDb.set).toHaveBeenLastCalledWith({ status: "completed" });
|
|
});
|
|
});
|
|
|
|
describe("step.notify payload", () => {
|
|
const projectId = "00000000-0000-0000-0000-000000000000";
|
|
const instanceInput = { user: { id: "user-1" }, plan: "pro" };
|
|
|
|
it("produces an event the enricher accepts", () => {
|
|
// The enricher validates notification.requested and drops what fails, so a
|
|
// payload that does not parse here is a notification nobody ever receives.
|
|
const built = buildStepNotifyPayload(
|
|
{
|
|
template: "welcome",
|
|
channels: ["email"],
|
|
data: { a: 1 },
|
|
sendAt: "2026-09-01T09:00:00.000Z",
|
|
},
|
|
instanceInput,
|
|
projectId,
|
|
);
|
|
|
|
expect(NotificationRequestedPayloadSchema.safeParse(built).success).toBe(true);
|
|
expect(built.templateId).toBe("welcome");
|
|
expect(built.scheduledAt).toBe("2026-09-01T09:00:00.000Z");
|
|
expect(built).not.toHaveProperty("template");
|
|
expect(built).not.toHaveProperty("sendAt");
|
|
});
|
|
|
|
it("inherits the instance user when the step names no target", () => {
|
|
const built = buildStepNotifyPayload({ template: "t" }, instanceInput, projectId);
|
|
expect(built.target).toEqual({ type: "user", userId: "user-1" });
|
|
});
|
|
|
|
it("lets the step payload override the instance user", () => {
|
|
const built = buildStepNotifyPayload(
|
|
{ template: "t", user: "user-9" },
|
|
instanceInput,
|
|
projectId,
|
|
);
|
|
expect(built.target).toEqual({ type: "user", userId: "user-9" });
|
|
});
|
|
|
|
it("accepts an inline user object", () => {
|
|
const built = buildStepNotifyPayload(
|
|
{ template: "t", user: { id: "user-9", email: "a@b.co" } },
|
|
instanceInput,
|
|
projectId,
|
|
);
|
|
expect(built.target).toEqual({ type: "user", userId: "user-9" });
|
|
});
|
|
|
|
it("targets a segment, so one instance can fan out", () => {
|
|
const built = buildStepNotifyPayload(
|
|
{ template: "t", segment: "beta" },
|
|
instanceInput,
|
|
projectId,
|
|
);
|
|
expect(built.target).toEqual({ type: "segment", segment: "beta" });
|
|
});
|
|
|
|
it("targets a topic", () => {
|
|
const built = buildStepNotifyPayload(
|
|
{ template: "t", topic: "outages" },
|
|
instanceInput,
|
|
projectId,
|
|
);
|
|
expect(built.target).toEqual({ type: "topic", topic: "outages" });
|
|
});
|
|
|
|
it("throws when no target can be resolved at all", () => {
|
|
expect(() => buildStepNotifyPayload({ template: "t" }, {}, projectId)).toThrow(/no target/);
|
|
});
|
|
|
|
it("refuses a list of users rather than silently sending to one", () => {
|
|
expect(() =>
|
|
buildStepNotifyPayload({ template: "t", user: ["a", "b"] }, instanceInput, projectId),
|
|
).toThrow(/single `user`/);
|
|
});
|
|
});
|