feat: add integration test harness, workflow cancellation, and provider send gating

This commit is contained in:
coo1estguy
2026-09-29 17:41:06 +05:30
parent 7b4d7f8efd
commit 3291dc64de
59 changed files with 8481 additions and 564 deletions
@@ -0,0 +1,3 @@
-- WorkflowRepository.cancelInstance writes 'canceled', which the enum never
-- had, so every cancel failed with invalid_text_representation.
ALTER TYPE "public"."workflow_status" ADD VALUE IF NOT EXISTS 'canceled';
+7
View File
@@ -50,6 +50,13 @@
"when": 1790600000000,
"tag": "0006_message_logs_redundant_indexes",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1790700000000,
"tag": "0007_workflow_status_canceled",
"breakpoints": true
}
]
}
+11 -18
View File
@@ -302,24 +302,17 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
if (seen.has(target)) continue;
seen.add(target);
let userRecord: Record<string, unknown>;
if (channel === "email") {
userRecord = { id: `email:${target}`, email: target };
} else if (channel === "sms") {
userRecord = { id: `phone:${target}`, phone: target };
} else if (channel === "push") {
userRecord = { id: `push:${target}`, pushToken: target };
} else if (channel === "telegram") {
userRecord = { id: `telegram:${target}` };
} else if (channel === "discord") {
userRecord = { id: `discord:${target}` };
} else if (channel === "whatsapp") {
userRecord = { id: `whatsapp:${target}`, phone: target };
} else if (channel === "slack") {
userRecord = { id: `slack:${target}` };
} else {
userRecord = { id: `webhook:${target}` };
}
// Every channel needs a contact on that channel to be reached. Chat
// channels used to get a bare user record, and WhatsApp a phone
// number filed as an SMS contact, so those campaigns sent nothing.
const userRecord: Record<string, unknown> =
channel === "email"
? { id: `email:${target}`, email: target }
: channel === "sms"
? { id: `phone:${target}`, phone: target }
: channel === "push"
? { id: `push:${target}`, pushToken: target }
: { id: `${channel}:${target}`, contacts: [{ channel, target }] };
recipients.push(userRecord);
}
+5 -1
View File
@@ -66,7 +66,11 @@ export class DiscordTransport implements Transport {
const subject = content.subject;
// Discord messages have no subject line of their own, so a template that
// renders one is bolded onto the first line rather than dropped.
const text = subject ? `**${subject}**\n${body}` : body;
// Either part may be missing: a subject-only template must not post
// "undefined" underneath it.
const text = [subject ? `**${subject}**` : undefined, body]
.filter((part) => typeof part === "string" && part)
.join("\n");
// A template may carry its own sender, so one webhook can post as
// different bots for different alert types.
+14 -1
View File
@@ -73,7 +73,10 @@ export class ResendTransport implements Transport {
from,
to,
subject: subject ?? "Notification",
html: htmlBody ?? `<p>${body}</p>`,
// A text-only template gets an HTML part built from its text. The text
// was rendered unescaped (it is plain text), so it is escaped here —
// otherwise a variable holding markup becomes live HTML in the email.
html: htmlBody ?? (typeof body === "string" ? textToHtml(body) : undefined),
text: body,
...(replyTo ? { replyTo } : {}),
...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
@@ -249,3 +252,13 @@ export class ResendTransport implements Transport {
return [];
}
}
function textToHtml(text: string): string {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return `<p>${escaped.replace(/\r?\n/g, "<br>")}</p>`;
}
+6 -1
View File
@@ -55,7 +55,12 @@ export class TelegramTransport implements Transport {
const subject = content.subject;
// Telegram messages have no subject line of their own, so a template
// that renders one gets folded into the first line rather than dropped.
const text = subject ? `${subject}\n\n${body}` : body;
// Either part may be missing: a subject-only template must not send the
// word "undefined" underneath it.
const text = [subject, body].filter((part) => typeof part === "string" && part).join("\n\n");
if (!text) {
return { success: false, error: "No message text on task" };
}
const parseMode = typeof content.parseMode === "string" ? content.parseMode : this.parseMode;
try {
+15 -5
View File
@@ -22,6 +22,7 @@ import type {
SuppressionRecord,
ProjectRecord,
ProjectKeyRecord,
CreatedProjectKeyRecord,
SystemHealthRecord,
SystemMetricsRecord,
DLQMessageRecord,
@@ -61,10 +62,17 @@ export class NotifkitClient {
return undefined as T;
}
const data = await res.json();
const text = await res.text();
let data: unknown;
try {
data = text ? JSON.parse(text) : undefined;
} catch {
// Not JSON: an HTML error page, or a proxy in front of the API.
data = undefined;
}
if (!res.ok) {
const errorMsg =
(data as any).message || (data as any).error || `Request failed with status ${res.status}`;
const body = data as { message?: string; error?: string } | undefined;
const errorMsg = body?.message || body?.error || `Request failed with status ${res.status}`;
throw new Error(errorMsg);
}
return data as T;
@@ -285,7 +293,7 @@ export class NotifkitClient {
async createProjectKey(
id: string,
input?: { role?: "admin" | "read_only" },
): Promise<ProjectKeyRecord> {
): Promise<CreatedProjectKeyRecord> {
return this.request(`/v1/projects/${encodeURIComponent(id)}/keys`, "POST", input || {});
}
@@ -472,7 +480,9 @@ export class NotifkitClient {
}
/** Replay a dead-letter queue message. */
async replayDLQMessage(id: string): Promise<{ success: boolean; replayedId: string }> {
async replayDLQMessage(
id: string,
): Promise<{ success: boolean; replayedId: string; stream: string }> {
return this.request("/v1/dlq/replay", "POST", { id });
}
@@ -14,5 +14,17 @@ export const NotificationAiPendingPayloadSchema = z.object({
aiPrompts: z.record(z.string(), z.string()),
scheduledAt: z.string().datetime().optional(),
fallbackChain: z.array(NotificationChannelSchema).optional(),
/**
* The task id the engine assigned. Stable across replays, so a re-run of
* this event dispatches the same task — which delivery's lease then sends
* once — rather than a fresh one.
*/
taskId: z.string().min(1).optional(),
/** The contact address the engine resolved and cleared against suppressions. */
destination: z.string().min(1).optional(),
/** Headers for the provider, e.g. one-click unsubscribe. */
deliveryHeaders: z.record(z.string(), z.string()).optional(),
campaignId: z.string().min(1).max(128).optional(),
workflowInstanceId: z.string().uuid().optional(),
});
export type NotificationAiPendingPayload = z.infer<typeof NotificationAiPendingPayloadSchema>;
@@ -45,8 +45,15 @@ export const NotificationDispatchedPayloadSchema = z.object({
deliveryOptions: DeliveryOptionsSchema,
fallbackChain: z.array(NotificationChannelSchema).optional(),
throttleAttemptCount: z.number().int().nonnegative().optional(),
/**
* Provider attempts that already failed. Delivery re-queues a failed send
* through the scheduler until this reaches `deliveryOptions.maxAttempts`.
*/
deliveryAttemptCount: z.number().int().nonnegative().optional(),
/** Campaign this message belongs to, carried from the originating request. */
campaignId: z.string().min(1).max(128).optional(),
/** Workflow instance whose step sent this, recorded on the delivery log. */
workflowInstanceId: z.string().uuid().optional(),
});
/** A `notification.dispatched` task as it travels on the outbound stream. */
export type DispatchedTaskPayload = z.infer<typeof NotificationDispatchedPayloadSchema>;
@@ -50,5 +50,13 @@ export const NotificationEnrichedPayloadSchema = z.object({
destination: z.string().min(1).optional(),
/** Campaign this message belongs to, carried from the originating request. */
campaignId: z.string().min(1).max(128).optional(),
/** Workflow instance whose step sent this, carried to the delivery log. */
workflowInstanceId: z.string().uuid().optional(),
/**
* Set on delivery's channel fallback. The notification was counted against
* the user's throttle when it was first sent; counting it again per channel
* it falls back through would drop the very send meant to reach them.
*/
throttleCounted: z.boolean().optional(),
});
export type NotificationEnrichedPayload = z.infer<typeof NotificationEnrichedPayloadSchema>;
@@ -32,5 +32,7 @@ export const NotificationRequestedPayloadSchema = z.object({
* reported on afterwards. Carried unchanged to the delivery log.
*/
campaignId: z.string().min(1).max(128).optional(),
/** Set by the workflow worker on a step's notify, carried to the delivery log. */
workflowInstanceId: z.string().uuid().optional(),
});
export type NotificationRequestedPayload = z.infer<typeof NotificationRequestedPayloadSchema>;
+80 -32
View File
@@ -110,12 +110,12 @@ export const TemplateSchema = z.object({
content: z.record(z.string(), z.unknown()),
aiPrompts: z.record(z.string(), z.string()).optional(),
});
export type TemplateInput = z.infer<typeof TemplateSchema>;
export type TemplateInput = z.input<typeof TemplateSchema>;
export const SyncTemplatesSchema = z.object({
templates: z.array(TemplateSchema).min(1),
});
export type SyncTemplatesInput = z.infer<typeof SyncTemplatesSchema>;
export type SyncTemplatesInput = z.input<typeof SyncTemplatesSchema>;
// ─── notify() ─────────────────────────────────────────────────────────────────
@@ -216,7 +216,7 @@ export const TriggerWorkflowSchema = z.object({
input: z.record(z.string(), z.unknown()).optional(),
user: z.union([z.string().min(1), InlineUserSchema]).optional(),
});
export type TriggerWorkflowInput = z.infer<typeof TriggerWorkflowSchema>;
export type TriggerWorkflowInput = z.input<typeof TriggerWorkflowSchema>;
// ─── Events ───────────────────────────────────────────────────────────────────
@@ -241,6 +241,12 @@ export const WorkflowStepSchema = z.discriminatedUnion("action", [
options: z
.object({
timeout: z.string().optional(), // e.g. "24h"
/**
* Fields the event must carry to wake this instance, e.g.
* `{ userId: "u1" }`. Without it any event of that name in the project
* wakes every instance waiting on it.
*/
match: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
}),
@@ -251,7 +257,7 @@ export const CreateWorkflowSchema = z.object({
name: z.string().min(1),
steps: z.array(WorkflowStepSchema).min(1),
});
export type CreateWorkflowInput = z.infer<typeof CreateWorkflowSchema>;
export type CreateWorkflowInput = z.input<typeof CreateWorkflowSchema>;
// ─── Projects ─────────────────────────────────────────────────────────────────
@@ -291,7 +297,8 @@ export interface UserResponse extends UserProfileResponse {
export interface UserDetailResponse extends UserResponse {
contacts: UserContactResponse[];
recentLogs?: unknown[];
/** The 50 most recent delivery-log rows for this user's addresses. */
logs: NotificationLogRecord[];
}
export interface WorkflowDefinitionRecord {
@@ -302,30 +309,53 @@ export interface WorkflowDefinitionRecord {
updatedAt?: string;
}
export interface WorkflowInstanceRecord {
export interface WorkflowStepRecord {
id: string;
name: string;
status: string;
currentStepIndex: number;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
instanceId: string;
stepIndex: string;
action: "notify" | "wait" | "waitForEvent" | "run" | string;
output: unknown;
error: string | null;
createdAt: string;
updatedAt: string;
}
export interface WorkflowWaiterRecord {
id: string;
instanceId: string;
eventName: string;
matchCriteria: Record<string, unknown>;
expiresAt: string;
createdAt: string;
}
export interface WorkflowInstanceRecord {
id: string;
projectId: string;
name: string;
status: "pending" | "running" | "completed" | "failed" | "canceled";
input?: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
steps: WorkflowStepRecord[];
waiters: WorkflowWaiterRecord[];
}
/** One row of the delivery log, as the API returns it. */
export interface NotificationLogRecord {
id: string;
notificationId: string;
taskId?: string;
projectId: string;
taskId: string;
providerMessageId: string | null;
templateId: string | null;
workflowInstanceId: string | null;
channel: string;
target?: string;
attempt: number;
/** `dispatched`, `attempt`, or an engagement event such as `opened`. */
kind: string;
status: string;
templateId?: string;
campaign?: string;
createdAt: string;
dispatchedAt?: string;
error?: string;
metadata?: Record<string, unknown>;
campaignId: string | null;
metadata: Record<string, unknown> | null;
timestamp: string;
}
export interface TemplateRecordResponse {
@@ -338,9 +368,12 @@ export interface TemplateRecordResponse {
export interface SuppressionRecord {
id: string;
projectId: string;
channel: string;
target: string;
reason: string;
source: string | null;
taskId: string | null;
createdAt: string;
}
@@ -353,30 +386,45 @@ export interface ProjectRecord {
createdAt: string;
}
/** A key as listed: the secret itself is only ever returned once, on creation. */
export interface ProjectKeyRecord {
id: string;
apiKey: string;
role: string;
role: "admin" | "read_only";
createdAt: string;
}
export interface CreatedProjectKeyRecord {
id: string;
apiKey: string;
role: "admin" | "read_only";
}
export interface SystemHealthRecord {
status: string;
timestamp: string;
redis?: { status: string };
db?: { status: string };
workers?: Record<string, unknown>;
status: "healthy" | "degraded";
redis: { ok: boolean; latencyMs: number };
db: { ok: boolean; latencyMs: number };
/** Each worker's last heartbeat, or `{ status: "unknown" }` when none arrived. */
workers: Record<string, Record<string, unknown>>;
}
export interface SystemMetricsRecord {
queues: Record<string, number>;
throughput?: Record<string, unknown>;
/** Unconsumed backlog per stream. Empty for project-scoped callers. */
streams: Record<string, number>;
deliveryStats: {
total: number;
delivered: number;
failed: number;
successRate: number;
};
}
export interface DLQMessageRecord {
id: string;
stream: string;
eventType: string;
payload: Record<string, unknown>;
error?: string;
/** Why the message was dead-lettered. */
error: string;
timestamp: string;
/** The stream it failed on, where a replay puts it back. */
originalStream?: string;
}
+1
View File
@@ -270,6 +270,7 @@ export const workflowStatusEnum = pgEnum("workflow_status", [
"running",
"completed",
"failed",
"canceled",
]);
export const workflowDefinitions = pgTable(
+16 -7
View File
@@ -96,12 +96,14 @@ export class StreamProducer {
}
async publish(partial: Omit<StreamEvent, "id" | "timestamp">): Promise<string> {
const event: StreamEvent = {
return this.enqueue({
...partial,
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
};
});
}
private enqueue(event: StreamEvent): Promise<string> {
return new Promise<string>((resolve, reject) => {
this.pendingQueue.push({ event, resolve, reject });
if (this.pendingQueue.length >= 100) {
@@ -209,13 +211,15 @@ export class StreamProducer {
): Promise<{ messageIds: string[]; eventIds: string[] }> {
if (partials.length === 0) return { messageIds: [], eventIds: [] };
// The ids returned must be the ids the events are written with: callers
// record them as notification ids.
const eventIds: string[] = [];
const promises: Promise<string>[] = [];
for (const partial of partials) {
const id = crypto.randomUUID();
eventIds.push(id);
promises.push(this.publish(partial));
promises.push(this.enqueue({ ...partial, id, timestamp: new Date().toISOString() }));
}
const messageIds = await Promise.all(promises);
@@ -472,7 +476,7 @@ export class StreamConsumer {
* against this consumer's stream so a replay lands where it would have been
* read from.
*/
async deadLetter(event: StreamEvent): Promise<void> {
async deadLetter(event: StreamEvent, reason?: string): Promise<void> {
if (!this.dlqStream) {
this.logger?.error(
{ eventId: event.id },
@@ -486,12 +490,17 @@ export class StreamConsumer {
"data",
JSON.stringify({
...event,
dlq: { originalStream: this.streams[0]!, ackedAt: new Date().toISOString() },
dlq: { originalStream: this.streams[0]!, ackedAt: new Date().toISOString(), reason },
}),
);
}
async nack(messageId: string, event: StreamEvent, stream?: string): Promise<void> {
async nack(
messageId: string,
event: StreamEvent,
stream?: string,
reason?: string,
): Promise<void> {
const s = stream ?? this.streams[0]!;
if (this.dlqStream) {
// Sequential rather than pipelined, and in this order: the ack is what
@@ -506,7 +515,7 @@ export class StreamConsumer {
"data",
JSON.stringify({
...event,
dlq: { originalStream: s, ackedAt: new Date().toISOString() },
dlq: { originalStream: s, ackedAt: new Date().toISOString(), reason },
}),
);
+17 -16
View File
@@ -308,24 +308,25 @@ export class ApiRateLimiter {
}
// 2. Slow Path: tokens are depleted, block on the in-flight lease or start one.
// Multiple concurrent requests racing here all join the *same* promise so
// only one Redis round trip happens. After it resolves each waiter tries to
// claim exactly one token — if they lose the race they're rate-limited.
if (!state.inFlightLease) {
this.refill(projectId, state, limitRpm, nowMs, currentBucket).catch(() => {});
}
// Concurrent requests racing here all join the *same* promise so one Redis
// round trip serves them. A lease only grants `batchSize` tokens, so a
// burst larger than that leases again rather than rejecting requests the
// window still has room for: a request is refused only once Redis itself
// reports the window spent.
for (;;) {
if (!state.inFlightLease) {
this.refill(projectId, state, limitRpm, nowMs, currentBucket).catch(() => {});
}
const granted = state.inFlightLease ? await state.inFlightLease : 0;
if (state.inFlightLease) {
await state.inFlightLease;
// Only claim a token if the lease came back for the same bucket we waited on.
if (state.bucketIndex !== currentBucket) return false;
if (state.tokens > 0) {
state.tokens--;
return true;
}
if (granted <= 0) return false;
}
// Only claim a token if the lease came back for the same bucket we waited on.
if (state.bucketIndex === currentBucket && state.tokens > 0) {
state.tokens--;
return true;
}
return false;
}
private async refill(
+74 -15
View File
@@ -22,6 +22,8 @@ import {
adminUsers,
} from "@/db/schema.js";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// ─── Domain types ───────────────────────────────────────────────────────────
export interface UserProfile {
@@ -310,6 +312,12 @@ export class UserRepository {
): Promise<void> {
if (usersList.length === 0) return;
// ON CONFLICT DO UPDATE rejects the whole statement when two proposed rows
// hit the same key, so an import naming one user twice failed outright.
// The last occurrence wins, as it would had they been sent one by one.
const uniqueUsers = [...new Map(usersList.map((u) => [u.userId, u])).values()];
const winners = new Set(uniqueUsers);
let attempts = 0;
while (attempts < 3) {
try {
@@ -317,7 +325,7 @@ export class UserRepository {
await tx
.insert(users)
.values(
usersList.map((u) => ({
uniqueUsers.map((u) => ({
projectId,
externalId: u.userId,
attributes: {
@@ -381,7 +389,12 @@ export class UserRepository {
}
}
if (u.preferences?.quietHours && u.preferences.quietHours.length > 0) {
// Quiet hours are replaced, not merged, so only the winning row counts.
if (
winners.has(u) &&
u.preferences?.quietHours &&
u.preferences.quietHours.length > 0
) {
for (const window of u.preferences.quietHours) {
quietHoursInserts.push({
userId: internalId,
@@ -438,7 +451,7 @@ export class UserRepository {
});
}
const usersWithQuietHours = usersList.filter(
const usersWithQuietHours = uniqueUsers.filter(
(u) => u.preferences?.quietHours !== undefined,
);
const internalIdsToClearQuietHours = usersWithQuietHours
@@ -485,7 +498,8 @@ export class UserRepository {
const attrs = {
language: patch.language ?? existing.language,
timezone: patch.timezone ?? existing.timezone,
email: patch.email ?? existing.email,
// `null` clears the email; only an absent field keeps the old one.
email: patch.email !== undefined ? patch.email : existing.email,
};
await this.db.transaction(async (tx) => {
@@ -588,11 +602,14 @@ export class UserRepository {
): Promise<{ users: UserProfile[]; nextCursor: string | null }> {
const conditions = [eq(users.projectId, projectId)];
if (cursor) {
const cursorDate = new Date(parseInt(cursor, 10));
if (!isNaN(cursorDate.getTime())) {
conditions.push(drizzleSql`${users.createdAt} < ${cursorDate.toISOString()}`);
}
// The cursor is the id of the last row returned. Paging on created_at
// alone skipped rows: a bulk import writes every user in one transaction,
// so they share one created_at, and the millisecond cursor could not tell
// them apart. (created_at, id) is unique and ordered.
if (cursor && UUID_RE.test(cursor)) {
conditions.push(
drizzleSql`(${users.createdAt}, ${users.id}) < (SELECT c.created_at, c.id FROM ${users} c WHERE c.id = ${cursor})`,
);
}
if (filters?.language) {
@@ -626,7 +643,7 @@ export class UserRepository {
.select()
.from(users)
.where(and(...conditions))
.orderBy(desc(users.createdAt))
.orderBy(desc(users.createdAt), desc(users.id))
.limit(limit);
const items = rows.map((r) => {
@@ -640,8 +657,7 @@ export class UserRepository {
};
});
const nextCursor =
items.length === limit ? items[items.length - 1]!.createdAt.toString() : null;
const nextCursor = rows.length === limit ? rows[rows.length - 1]!.id : null;
return { users: items, nextCursor };
}
@@ -794,6 +810,25 @@ export class ContactRepository {
),
);
// Per-address topic opt-outs (a user who wants marketing at home but not at
// work). Without them the pipeline cannot honour an opt-out it accepted.
const topicsByContact = new Map<string, Record<string, boolean>>();
if (rows.length > 0) {
const topicRows = await this.db
.select()
.from(contactTopicPreferences)
.where(
inArray(
contactTopicPreferences.contactId,
rows.map((r) => r.id),
),
);
for (const t of topicRows) {
if (!topicsByContact.has(t.contactId)) topicsByContact.set(t.contactId, {});
topicsByContact.get(t.contactId)![t.topic] = t.enabled;
}
}
const fetchedByUser = new Map<string, UserContact[]>();
for (const id of missingIds) {
fetchedByUser.set(id, []);
@@ -801,12 +836,13 @@ export class ContactRepository {
for (const row of rows) {
const contacts = fetchedByUser.get(row.userId) ?? [];
const topics = topicsByContact.get(row.id);
contacts.push({
id: row.id,
userId: row.userId,
channel: row.channel as ContactChannel,
target: row.target,
preferences: {},
preferences: topics ? { topics } : {},
active: row.enabled,
});
fetchedByUser.set(row.userId, contacts);
@@ -897,7 +933,29 @@ export class ContactRepository {
const idMap = new Map(internalUserIdRows.map((r) => [r.externalId, r.id]));
const validContacts = contactsList.filter((c) => idMap.has(c.userId));
// The same address can arrive twice — as `email` and again in `contacts`
// — and ON CONFLICT DO UPDATE rejects a statement that touches one row
// twice. Keep one entry per address, merging their topic preferences.
const byAddress = new Map<string, (typeof contactsList)[number]>();
for (const c of contactsList) {
if (!idMap.has(c.userId)) continue;
const key = `${c.userId}\u0000${c.channel}\u0000${c.target}`;
const seen = byAddress.get(key);
byAddress.set(
key,
seen
? {
...c,
preferences: {
...seen.preferences,
...c.preferences,
topics: { ...seen.preferences?.topics, ...c.preferences?.topics },
},
}
: c,
);
}
const validContacts = [...byAddress.values()];
if (validContacts.length === 0) return;
const inserted = await this.db
@@ -1118,6 +1176,7 @@ export class ProjectRepository {
await tx.delete(suppressions).where(eq(suppressions.projectId, id));
await tx.delete(messageLogs).where(eq(messageLogs.projectId, id));
await tx.delete(workflowInstances).where(eq(workflowInstances.projectId, id));
await tx.delete(workflowDefinitions).where(eq(workflowDefinitions.projectId, id));
const result = await tx.delete(projects).where(eq(projects.id, id)).returning();
return result.length > 0;
@@ -1234,7 +1293,7 @@ export class WorkflowRepository {
return await this.db.transaction(async (tx) => {
const result = await tx
.update(workflowInstances)
.set({ status: "canceled" as any })
.set({ status: "canceled" })
.where(
and(
eq(workflowInstances.id, instanceId),
+32 -17
View File
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { loadEnv, readBaseConfig } from "@/index.js";
import type { Logger } from "@/index.js";
import type { RedisClient, Redis } from "@/index.js";
@@ -31,6 +30,7 @@ import {
createStreamConsumer,
createWorkerRuntime,
shutdownWorker,
uniqueConsumerId,
} from "@/workers/bootstrap.js";
// ─── Bootstrap ─────────────────────────────────────────────────────────────
@@ -128,11 +128,22 @@ export class AiWorker extends BaseWorker {
const pending = payloadResult.data as NotificationAiPendingPayload;
// Idempotency
const idempotencyKey = `${pending.enrichedEventId}:${pending.recipientId}:${pending.channel}:ai`;
// Stable across replays: the engine's task id when it sent one. A fresh id
// per attempt made a replay after a crash a second, unrelated task that
// delivery's lease could not recognise — the recipient got it twice.
const taskId =
pending.taskId ?? `${pending.enrichedEventId}:${pending.recipientId}:${pending.channel}`;
// Idempotency. The lease has to outlast every generation this message may
// run, or a second worker takes it over mid-call and bills the prompts again.
const idempotencyKey = `${taskId}:ai`;
const aiConfig = getAiConfig();
const maxPrompts = aiConfig.maxPromptsPerNotification ?? AI_DEFAULTS.maxPromptsPerNotification;
const leaseSeconds =
Math.ceil(((aiConfig.timeoutMs ?? AI_DEFAULTS.timeoutMs) * maxPrompts) / 1000) + 30;
const lease = this.idempotency.acquireLease
? await this.idempotency.acquireLease(idempotencyKey, 30)
: (await this.idempotency.checkAndMark(idempotencyKey, 30))
? await this.idempotency.acquireLease(idempotencyKey, leaseSeconds)
: (await this.idempotency.checkAndMark(idempotencyKey, leaseSeconds))
? "acquired"
: "locked";
@@ -155,8 +166,6 @@ export class AiWorker extends BaseWorker {
// Execute AI prompts. Each key is a separate billed model call, so the
// count is capped rather than being driven by whatever the caller sent.
const promptEntries = Object.entries(pending.aiPrompts);
const maxPrompts =
getAiConfig().maxPromptsPerNotification ?? AI_DEFAULTS.maxPromptsPerNotification;
if (promptEntries.length > maxPrompts) {
this.logger.warn(
{ messageId: message.id, requested: promptEntries.length, maxPrompts },
@@ -178,9 +187,9 @@ export class AiWorker extends BaseWorker {
const rendered = renderWithTemplate(dbTemplate, finalVars);
const taskId = randomUUID();
const destination =
pending.channel === "email"
pending.destination ??
(pending.channel === "email"
? pending.recipient.email
: pending.channel === "sms" || pending.channel === "whatsapp"
? pending.recipient.phone
@@ -194,7 +203,7 @@ export class AiWorker extends BaseWorker {
? pending.recipient.slack
: pending.channel === "push"
? (pending.recipient.pushTokens?.[0] ?? pending.recipient.pushToken)
: undefined;
: undefined);
const resolvedDestination =
destination ?? (pending.channel === "push" ? undefined : pending.recipientId);
@@ -214,8 +223,11 @@ export class AiWorker extends BaseWorker {
deliveryOptions: {
maxAttempts: 3,
timeoutMs: 10_000,
...(pending.deliveryHeaders ? { headers: pending.deliveryHeaders } : {}),
},
fallbackChain: pending.fallbackChain,
campaignId: pending.campaignId,
workflowInstanceId: pending.workflowInstanceId,
};
const envelope = buildStreamEvent(
@@ -230,12 +242,15 @@ export class AiWorker extends BaseWorker {
const scheduledAt = pending.scheduledAt ? new Date(pending.scheduledAt).getTime() : now;
if (scheduledAt > now) {
await this.db.insert(scheduledPayloads).values({
taskId,
// The send time rides in the payload so the scheduler can re-queue
// this from Postgres if Redis loses its data.
payload: { ...taskPayload, scheduledAt: pending.scheduledAt },
});
await this.db
.insert(scheduledPayloads)
.values({
taskId,
// The send time rides in the payload so the scheduler can re-queue
// this from Postgres if Redis loses its data.
payload: { ...taskPayload, scheduledAt: pending.scheduledAt },
})
.onConflictDoNothing();
const scheduledEnvelope = buildStreamEvent(
"notification.scheduled",
@@ -305,7 +320,7 @@ export async function startAiWorker() {
{
stream: STREAMS.AI_PENDING,
group: CONSUMER_GROUPS.AI,
consumer: `ai-${process.pid}`,
consumer: uniqueConsumerId("ai"),
batchSize: config.WORKER_CONCURRENCY,
},
));
+239 -115
View File
@@ -43,7 +43,7 @@ import { readJsonBody, sendJson, sendNoContent, sendValidationError } from "./ht
import type { RouteContext } from "./router.js";
import { globalEmitter, getPriorityBucket, normaliseTarget } from "@/shared/index.js";
import { metrics } from "@/metrics/index.js";
import { eq, desc, and, lt, lte, gte, sql, like, or, isNotNull, inArray } from "drizzle-orm";
import { eq, desc, and, lte, gte, sql, like, or, isNotNull, inArray } from "drizzle-orm";
import {
messageLogs,
workflowDefinitions,
@@ -177,6 +177,106 @@ async function persistUsers(deps: Deps, users: InlineUserLike[], projectId: stri
});
}
/**
* A dead-letter entry, read the way StreamConsumer writes it: the original
* event JSON under `data`, carrying a `dlq` block that records which stream it
* failed on. Flat `payload`/`eventType` fields are accepted for entries written
* by hand or by older versions.
*/
interface DlqEntry {
id: string;
eventType: string;
payload: any;
projectId?: string;
error: string;
timestamp: string;
/** The event as it was published, without its `dlq` block. */
event: Record<string, any> | null;
originalStream?: string;
fields: Record<string, string>;
}
function parseDlqEntry(id: string, rawFields: string[]): DlqEntry {
const fields: Record<string, string> = {};
for (let i = 0; i < rawFields.length; i += 2) {
fields[rawFields[i]!] = rawFields[i + 1]!;
}
let parsed: Record<string, any> | null = null;
if (fields.data) {
try {
parsed = JSON.parse(fields.data);
} catch {
parsed = null;
}
}
const { dlq, ...event } = parsed ?? {};
let payload: any = fields;
if (parsed) {
payload = event.payload;
} else if (fields.payload) {
try {
payload = JSON.parse(fields.payload);
} catch {
payload = fields.payload;
}
}
const ms = Number(id.split("-")[0]);
return {
id,
eventType: event.type ?? fields.eventType ?? fields.event_type ?? "unknown",
payload,
projectId: payload?.projectId ?? payload?.project_id ?? fields.projectId,
error: dlq?.reason ?? fields.error ?? fields.reason ?? "Dead letter payload",
timestamp:
dlq?.ackedAt ??
fields.timestamp ??
(Number.isFinite(ms) ? new Date(ms).toISOString() : new Date().toISOString()),
event: parsed ? event : null,
originalStream: dlq?.originalStream,
fields,
};
}
/**
* Ids that live in uuid columns. Anything else can match no row, and passing it
* to Postgres fails the query outright — a 500 for what is simply not found.
*/
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUuid = (v: string | undefined): v is string => typeof v === "string" && UUID_RE.test(v);
/** Opaque position in the delivery log: a row's full-precision timestamp and id. */
function encodeLogCursor(ts: string, id: string): string {
return Buffer.from(JSON.stringify([ts, id])).toString("base64url");
}
function decodeLogCursor(cursor: string | null | undefined): { ts: string; id: string } | null {
if (!cursor) return null;
// Cursors handed out before this format were a millisecond timestamp.
if (/^d+$/.test(cursor)) {
return {
ts: new Date(Number(cursor)).toISOString(),
id: "ffffffff-ffff-ffff-ffff-ffffffffffff",
};
}
try {
const [ts, id] = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
if (
typeof ts === "string" &&
typeof id === "string" &&
!Number.isNaN(Date.parse(ts)) &&
/^[0-9a-f-]{36}$/i.test(id)
) {
return { ts, id };
}
} catch {
// fall through
}
return null;
}
function getQueryParam(ctx: RouteContext, key: string): string | undefined {
if (!ctx.query) return undefined;
if (typeof ctx.query.get === "function") {
@@ -662,34 +762,82 @@ export function createHandlers(deps: Deps) {
);
}
if (cursor) {
const cursorDate = new Date(parseInt(cursor, 10));
conditions.push(lt(messageLogs.timestamp, cursorDate));
}
// One entry per task, newest activity first. Rows are scanned in
// (timestamp, id) order — a batch of deliveries shares one timestamp, so
// the timestamp alone cannot mark a place — and a task is listed at its
// newest row. The cursor is that row for the first task of the next page,
// and any task with a row newer than the cursor was listed on an earlier
// page. Paging on a millisecond timestamp over `limit * 2` rows skipped
// tasks that shared a timestamp and dropped any that did not fit the slice.
const start = decodeLogCursor(cursor);
const tsText = sql<string>`${messageLogs.timestamp}::text`;
const position = (ts: string, id: string) => sql`(${ts}::timestamptz, ${id}::uuid)`;
const rowKey = sql`(${messageLogs.timestamp}, ${messageLogs.id})`;
const logs = await deps.db
.select()
.from(messageLogs)
.where(and(...conditions))
.orderBy(desc(messageLogs.timestamp))
.limit(limit * 2);
const order: string[] = [];
const rowsByTask = new Map<string, (typeof messageLogs.$inferSelect)[]>();
let nextCursor: string | null = null;
let scanFrom = start;
let inclusive = true;
const batchSize = Math.max(limit * 4, 50);
// Deduplicate by taskId preserving the latest status per taskId
const deduplicatedMap = new Map<string, (typeof logs)[0]>();
for (const log of logs) {
if (!deduplicatedMap.has(log.taskId)) {
deduplicatedMap.set(log.taskId, log);
} else {
const existing = deduplicatedMap.get(log.taskId)!;
if (existing.status === "dispatched" && log.status !== "dispatched") {
deduplicatedMap.set(log.taskId, log);
}
for (let round = 0; round < 50 && nextCursor === null; round++) {
const batch = await deps.db
.select({ row: messageLogs, ts: tsText })
.from(messageLogs)
.where(
and(
...conditions,
scanFrom
? sql`${rowKey} ${inclusive ? sql`<=` : sql`<`} ${position(scanFrom.ts, scanFrom.id)}`
: undefined,
),
)
.orderBy(desc(messageLogs.timestamp), desc(messageLogs.id))
.limit(batchSize);
const fresh = [...new Set(batch.map((b) => b.row.taskId))].filter((t) => !rowsByTask.has(t));
const listedEarlier = new Set<string>();
if (start && fresh.length > 0) {
const newer = await deps.db
.selectDistinct({ taskId: messageLogs.taskId })
.from(messageLogs)
.where(
and(
...conditions,
inArray(messageLogs.taskId, fresh),
sql`${rowKey} > ${position(start.ts, start.id)}`,
),
);
for (const r of newer) listedEarlier.add(r.taskId);
}
}
const deduplicatedLogs = Array.from(deduplicatedMap.values()).slice(0, limit);
const nextCursor =
logs.length === limit * 2 ? logs[logs.length - 1]!.timestamp.getTime().toString() : null;
for (const { row, ts } of batch) {
if (listedEarlier.has(row.taskId)) continue;
let rows = rowsByTask.get(row.taskId);
if (!rows) {
if (order.length === limit) {
nextCursor = encodeLogCursor(ts, row.id);
break;
}
rows = [];
order.push(row.taskId);
rowsByTask.set(row.taskId, rows);
}
rows.push(row);
}
if (batch.length < batchSize) break;
const last = batch[batch.length - 1]!;
scanFrom = { ts: last.ts, id: last.row.id };
inclusive = false;
}
// The latest outcome, rather than the dispatch that preceded it.
const deduplicatedLogs = order.map((taskId) => {
const rows = rowsByTask.get(taskId)!;
return rows.find((r) => r.status !== "dispatched") ?? rows[0]!;
});
sendJson(res, 200, { logs: deduplicatedLogs, nextCursor });
}
@@ -875,7 +1023,9 @@ export function createHandlers(deps: Deps) {
ctx: RouteContext,
): Promise<void> {
const instanceId = ctx.params.id!;
const workflow = await deps.workflowRepo.getInstance(ctx.projectId!, instanceId);
const workflow = isUuid(instanceId)
? await deps.workflowRepo.getInstance(ctx.projectId!, instanceId)
: null;
if (!workflow) return sendJson(res, 404, { error: "workflow_not_found", id: instanceId });
sendJson(res, 200, workflow);
@@ -888,7 +1038,8 @@ export function createHandlers(deps: Deps) {
ctx: RouteContext,
): Promise<void> {
const instanceId = ctx.params.id!;
const canceled = await deps.workflowRepo.cancelInstance(ctx.projectId!, instanceId);
const canceled =
isUuid(instanceId) && (await deps.workflowRepo.cancelInstance(ctx.projectId!, instanceId));
if (!canceled) return sendJson(res, 400, { error: "workflow_not_cancelable", id: instanceId });
logger.info({ instanceId }, "workflow canceled");
@@ -956,7 +1107,7 @@ export function createHandlers(deps: Deps) {
ctx: RouteContext,
): Promise<void> {
const id = ctx.params.id!;
const deleted = await deps.projectRepo.delete(id);
const deleted = isUuid(id) && (await deps.projectRepo.delete(id));
if (!deleted) return sendJson(res, 404, { error: "project_not_found" });
sendNoContent(res);
@@ -979,7 +1130,7 @@ export function createHandlers(deps: Deps) {
if (!parsed.success) return sendValidationError(res, parsed.error);
const updated = await deps.projectRepo.updateSettings(id, parsed.data);
const updated = isUuid(id) && (await deps.projectRepo.updateSettings(id, parsed.data));
if (!updated) return sendJson(res, 404, { error: "project_not_found" });
// Drop the engine's cached throttle overrides so the change applies to the
@@ -1009,6 +1160,11 @@ export function createHandlers(deps: Deps) {
.safeParse(await readJsonBody(req));
const role = parsed.success ? parsed.data.role : "admin";
// A key for a project that does not exist would fail the foreign key.
if (!isUuid(id) || !(await deps.projectRepo.findThrottleSettings(id))) {
return sendJson(res, 404, { error: "project_not_found" });
}
const apiKey = `nk_live_${randomBytes(32).toString("hex")}`;
const apiKeyHash = createHash("sha256").update(apiKey).digest("hex");
@@ -1023,6 +1179,7 @@ export function createHandlers(deps: Deps) {
ctx: RouteContext,
): Promise<void> {
const id = ctx.params.id!;
if (!isUuid(id)) return sendJson(res, 404, { error: "project_not_found" });
const keys = await deps.projectRepo.listApiKeys(id);
sendJson(res, 200, { keys });
}
@@ -1034,7 +1191,7 @@ export function createHandlers(deps: Deps) {
ctx: RouteContext,
): Promise<void> {
const { id, keyId } = ctx.params;
const deleted = await deps.projectRepo.deleteApiKey(id!, keyId!);
const deleted = isUuid(id) && isUuid(keyId) && (await deps.projectRepo.deleteApiKey(id, keyId));
if (!deleted) return sendJson(res, 404, { error: "key_not_found" });
await deps.redis.native.publish("apikey.invalidated", "*");
@@ -1234,41 +1391,40 @@ export function createHandlers(deps: Deps) {
"COUNT",
"100",
);
const allMessages = (rawEntries || []).map(([id, fields]: [string, string[]]) => {
const fieldMap: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) {
fieldMap[fields[i]!] = fields[i + 1]!;
}
let payload: any = fieldMap;
if (fieldMap.payload) {
try {
payload = JSON.parse(fieldMap.payload);
} catch {
payload = fieldMap.payload;
}
}
return {
id,
eventType: fieldMap.eventType || fieldMap.event_type || "unknown",
payload,
error: fieldMap.error || fieldMap.reason || "Dead letter payload",
timestamp: fieldMap.timestamp || new Date().toISOString(),
};
});
const allMessages = (rawEntries || []).map(([id, fields]: [string, string[]]) =>
parseDlqEntry(id, fields),
);
const messages = ctx.projectId
? allMessages.filter((m) => {
const pId = m.payload?.projectId ?? m.payload?.project_id ?? (m as any).projectId;
return pId === ctx.projectId;
})
? allMessages.filter((m) => m.projectId === ctx.projectId)
: allMessages;
sendJson(res, 200, { messages: messages.slice(0, 50) });
sendJson(res, 200, {
messages: messages.slice(0, 50).map((m) => ({
id: m.id,
eventType: m.eventType,
payload: m.payload,
error: m.error,
timestamp: m.timestamp,
originalStream: m.originalStream,
})),
});
} catch {
sendJson(res, 200, { messages: [] });
}
}
/** The entry, or null when it does not exist or belongs to another project. */
async function findDlqEntry(ctx: RouteContext, messageId: string): Promise<DlqEntry | null> {
// Redis rejects a malformed stream id rather than matching nothing.
if (!/^\d+-\d+$/.test(messageId)) return null;
const rawEntries = await deps.redis.native.xrange(STREAMS.DEAD_LETTER, messageId, messageId);
if (!rawEntries || rawEntries.length === 0 || !rawEntries[0]) return null;
const entry = parseDlqEntry(rawEntries[0][0], rawEntries[0][1]);
if (ctx.projectId && entry.projectId !== ctx.projectId) return null;
return entry;
}
// ── POST /v1/dlq/replay — replayDLQMessage ────────────────────────────────
async function replayDLQMessage(
req: IncomingMessage,
@@ -1284,46 +1440,35 @@ export function createHandlers(deps: Deps) {
}
try {
const rawEntries = await deps.redis.native.xrange(STREAMS.DEAD_LETTER, messageId, messageId);
if (!rawEntries || rawEntries.length === 0 || !rawEntries[0]) {
const entry = await findDlqEntry(ctx, messageId);
if (!entry) {
sendJson(res, 404, { error: "dlq_message_not_found" });
return;
}
const fields = rawEntries[0][1];
const fieldMap: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) {
fieldMap[fields[i]!] = fields[i + 1]!;
}
let payload: any = null;
if (fieldMap.payload) {
try {
payload = JSON.parse(fieldMap.payload);
} catch {}
}
if (ctx.projectId) {
const pId = payload?.projectId ?? payload?.project_id ?? (fieldMap as any).projectId;
if (pId !== ctx.projectId) {
sendJson(res, 404, { error: "dlq_message_not_found" });
return;
}
}
const priority = fieldMap.priority || "normal";
const p = getPriorityBucket(priority);
// Back onto the stream it failed on: a failed send belongs with delivery,
// not at the start of the pipeline, where its payload does not parse.
const replayable = new Set<string>(Object.values(STREAMS));
replayable.delete(STREAMS.DEAD_LETTER);
const inbound =
STREAMS[
`INBOUND_${getPriorityBucket(entry.payload?.priority ?? entry.fields.priority).toUpperCase()}` as keyof typeof STREAMS
] ?? STREAMS.INBOUND_NORMAL;
const targetStream =
STREAMS[`INBOUND_${p.toUpperCase()}` as keyof typeof STREAMS] || STREAMS.INBOUND_NORMAL;
entry.originalStream && replayable.has(entry.originalStream)
? entry.originalStream
: inbound;
const xaddArgs: string[] = [];
for (const [k, v] of Object.entries(fieldMap)) {
xaddArgs.push(k, v);
if (entry.event) {
xaddArgs.push("data", JSON.stringify(entry.event));
} else {
for (const [k, v] of Object.entries(entry.fields)) xaddArgs.push(k, v);
}
await deps.redis.native.xadd(targetStream, "*", ...xaddArgs);
await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
sendJson(res, 200, { success: true, replayedId: messageId });
sendJson(res, 200, { success: true, replayedId: messageId, stream: targetStream });
} catch (err: any) {
sendJson(res, 500, { error: "replay_failed", message: err.message });
}
@@ -1339,34 +1484,10 @@ export function createHandlers(deps: Deps) {
if (!messageId) return sendJson(res, 400, { error: "missing_id" });
try {
if (ctx.projectId) {
const rawEntries = await deps.redis.native.xrange(
STREAMS.DEAD_LETTER,
messageId,
messageId,
);
if (!rawEntries || rawEntries.length === 0 || !rawEntries[0]) {
sendJson(res, 404, { error: "dlq_message_not_found" });
return;
}
const fields = rawEntries[0][1];
const fieldMap: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) {
fieldMap[fields[i]!] = fields[i + 1]!;
}
let payload: any = null;
if (fieldMap.payload) {
try {
payload = JSON.parse(fieldMap.payload);
} catch {}
}
const pId = payload?.projectId ?? payload?.project_id ?? (fieldMap as any).projectId;
if (pId !== ctx.projectId) {
sendJson(res, 404, { error: "dlq_message_not_found" });
return;
}
if (!(await findDlqEntry(ctx, messageId))) {
sendJson(res, 404, { error: "dlq_message_not_found" });
return;
}
await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
sendJson(res, 200, { success: true });
} catch (err: any) {
@@ -1766,10 +1887,13 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
if (ENGAGEMENT.includes(row.kind as Engagement)) {
bucket[row.kind as Engagement] += n;
} else {
// A delivery row. `sent` is every task that reached a provider at all,
// which is the denominator a bounce rate is measured against.
} else if (row.kind === "dispatched") {
// `sent` is every task that reached a provider at all, which is the
// denominator a bounce rate is measured against. Each attempt logs a
// dispatched row besides its outcome row, and the two are grouped
// separately, so counting both counted every message twice.
bucket.sent += n;
} else {
if (row.status === "delivered") bucket.delivered += n;
if (row.status === "failed") bucket.failed += n;
}
+220 -112
View File
@@ -15,6 +15,7 @@ import {
registry,
buildStreamEvent,
type NotificationDispatchedPayload,
type DispatchedTaskPayload,
type RenderedContent,
PUBSUB_CHANNELS,
} from "@/index.js";
@@ -73,6 +74,37 @@ let subscriber: Redis | null = null;
/** How long a send may run before its dispatched log entry is published alone. */
const DISPATCH_LOG_DELAY_MS = 1_000;
/**
* Wait before re-trying a send that failed at the provider, by prior failures.
* The first step matches the circuit breaker's reset timeout, so a retry of a
* task the open breaker turned away reaches the provider's recovery probe
* rather than the same closed door.
*/
const RETRY_BACKOFF_MS = [30_000, 120_000, 600_000];
/**
* Names the provider at `transports[i]` for its rate limit and circuit breaker.
* The rate limit's budget lives in Redis and is shared by every delivery
* process, so the name must come out the same everywhere: the class name,
* numbered when a channel has two providers of one class.
*/
function providerKey(channel: string, transports: any[], i: number): string {
const name = transports[i].constructor?.name ?? "provider";
const nth = transports.slice(0, i).filter((t) => t.constructor?.name === name).length;
return nth === 0 ? `${channel}:${name}` : `${channel}:${name}#${nth + 1}`;
}
/** A provider's refusal, keeping whether it said another attempt could work. */
function providerFailure(res: { error?: string; retryable?: boolean }): Error {
const err = new Error(res.error ?? "Transport failed");
(err as any).retryable = res.retryable;
return err;
}
export function retryDelayMs(priorFailures: number): number {
return RETRY_BACKOFF_MS[Math.min(priorFailures, RETRY_BACKOFF_MS.length - 1)]!;
}
export interface DeliveryWorkerOptions extends WorkerOptions {
transportRegistry: any;
idempotency: any;
@@ -196,6 +228,51 @@ export class DeliveryWorker extends BaseWorker {
return renderWithTemplate(template, task.templateVariables);
}
/**
* Parks a task in Postgres and the scheduler until `sendAt`. The payload
* carries the send time, so the scheduler can rebuild it if Redis loses its
* data.
*/
private async reschedule(
task: DispatchedTaskPayload,
sendAt: Date,
reason: string,
): Promise<void> {
const scheduledAt = sendAt.toISOString();
await this.db
.insert(scheduledPayloads)
.values({ taskId: task.taskId, payload: { ...task, scheduledAt } })
.onConflictDoUpdate({
target: scheduledPayloads.taskId,
set: { payload: drizzleSql`EXCLUDED.payload` },
});
await this.scheduledProducer.publish(
buildStreamEvent(
"notification.scheduled",
{
projectId: task.projectId,
enrichedEventId: task.enrichedEventId,
taskId: task.taskId,
scheduledAt,
},
"delivery",
`${task.taskId}:${reason}:${Date.now()}`,
),
);
}
/** Takes a slot from the provider's rate limit, if it has one. */
private async providerGate(
channel: string,
transports: any[],
i: number,
): Promise<{ allowed: boolean; retryAfterMs: number }> {
const limits = transports[i].limits;
if (!limits) return { allowed: true, retryAfterMs: 0 };
return this.providerThrottle.check(providerKey(channel, transports, i), limits);
}
private getBreaker(name: string): CircuitBreaker {
let breaker = this.breakers.get(name);
if (!breaker) {
@@ -219,6 +296,11 @@ export class DeliveryWorker extends BaseWorker {
}
const task = payloadResult.data as NotificationDispatchedPayload;
// Attempts made on earlier trips through the scheduler, plus this one.
const attemptNo = (task.deliveryAttemptCount ?? 0) + attempt;
// What gets parked for a retry: the task as it arrived, before rendering
// and the transport's abort signal were attached to it.
const original: DispatchedTaskPayload = { ...task };
const fallbackToNextChannel = async (reason: string) => {
if (task.fallbackChain && task.fallbackChain.length > 0 && task.recipient) {
@@ -238,6 +320,9 @@ export class DeliveryWorker extends BaseWorker {
recipient: task.recipient,
scheduledAt: undefined,
fallbackChain: remainingChain.length > 0 ? remainingChain : undefined,
campaignId: task.campaignId,
workflowInstanceId: task.workflowInstanceId,
throttleCounted: true,
};
const p = getPriorityBucket(task.priority);
@@ -288,10 +373,9 @@ export class DeliveryWorker extends BaseWorker {
failureReason: "no transport registered for channel",
failureCode: "no_transport",
retryable: false,
attempt,
attempt: attemptNo,
templateId: task.templateId,
workflowInstanceId:
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
workflowInstanceId: task.workflowInstanceId,
campaignId: task.campaignId,
},
"delivery",
@@ -302,87 +386,67 @@ export class DeliveryWorker extends BaseWorker {
return;
}
const limitConfig = transports[0].limits;
if (limitConfig) {
const { allowed, retryAfterMs } = await this.providerThrottle.check(
task.channel,
limitConfig,
);
if (!allowed) {
task.throttleAttemptCount = (task.throttleAttemptCount ?? 0) + 1;
const maxAttempts = task.deliveryOptions?.maxAttempts ?? 3;
// Each provider has its own budget: start at the first with room left. The
// channel is out of capacity only once every provider for it is.
let startAt = -1;
let retryAfterMs = Infinity;
for (let i = 0; i < transports.length; i++) {
const gate = await this.providerGate(task.channel, transports, i);
if (gate.allowed) {
startAt = i;
break;
}
retryAfterMs = Math.min(retryAfterMs, gate.retryAfterMs);
}
if (startAt === -1) {
task.throttleAttemptCount = (task.throttleAttemptCount ?? 0) + 1;
const maxAttempts = task.deliveryOptions?.maxAttempts ?? 3;
if (task.throttleAttemptCount > maxAttempts) {
this.logger.warn(
{ messageId: message.id, taskId: task.taskId, attempts: task.throttleAttemptCount },
"provider rate limit max attempts exceeded",
);
const fallbackTriggered = await fallbackToNextChannel("provider_throttle_exceeded");
if (!fallbackTriggered) {
this.globalEmitter.emit(
"delivery:failed",
task.taskId,
"provider throttle exceeded",
task.channel,
task.projectId,
);
await this.eventProcessor.add(
buildStreamEvent(
"notification.failed",
{
projectId: task.projectId,
taskId: task.taskId,
enrichedEventId: task.enrichedEventId,
recipientId: task.recipientId,
channel: task.channel,
failureReason: "provider rate limit max attempts exceeded",
failureCode: "provider_throttle_exceeded",
retryable: false,
attempt,
templateId: task.templateId,
workflowInstanceId:
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
campaignId: task.campaignId,
},
"delivery",
event.metadata.traceId,
),
);
}
return;
}
// The retry time rides in the payload so the scheduler can re-queue
// this from Postgres if Redis loses its data.
const retryAt = new Date(Date.now() + retryAfterMs).toISOString();
await this.db
.insert(scheduledPayloads)
.values({
taskId: task.taskId,
payload: { ...task, scheduledAt: retryAt },
})
.onConflictDoUpdate({
target: scheduledPayloads.taskId,
set: { payload: drizzleSql`EXCLUDED.payload` },
});
await this.scheduledProducer.publish(
buildStreamEvent(
"notification.scheduled",
{
projectId: task.projectId,
enrichedEventId: task.enrichedEventId,
taskId: task.taskId,
scheduledAt: retryAt,
throttleAttemptCount: task.throttleAttemptCount,
},
"delivery",
`${task.taskId}:throttle:${Date.now()}`,
),
if (task.throttleAttemptCount > maxAttempts) {
this.logger.warn(
{ messageId: message.id, taskId: task.taskId, attempts: task.throttleAttemptCount },
"provider rate limit max attempts exceeded",
);
const fallbackTriggered = await fallbackToNextChannel("provider_throttle_exceeded");
if (!fallbackTriggered) {
this.globalEmitter.emit(
"delivery:failed",
task.taskId,
"provider throttle exceeded",
task.channel,
task.projectId,
);
await this.eventProcessor.add(
buildStreamEvent(
"notification.failed",
{
projectId: task.projectId,
taskId: task.taskId,
enrichedEventId: task.enrichedEventId,
recipientId: task.recipientId,
channel: task.channel,
failureReason: "provider rate limit max attempts exceeded",
failureCode: "provider_throttle_exceeded",
retryable: false,
attempt: attemptNo,
templateId: task.templateId,
workflowInstanceId: task.workflowInstanceId,
campaignId: task.campaignId,
},
"delivery",
event.metadata.traceId,
),
);
}
return;
}
await this.reschedule(
{ ...original, throttleAttemptCount: task.throttleAttemptCount },
new Date(Date.now() + retryAfterMs),
"throttle",
);
return;
}
const idempotencyKey = task.taskId;
@@ -433,9 +497,8 @@ export class DeliveryWorker extends BaseWorker {
recipientId: task.recipientId,
channel: task.channel,
templateId: task.templateId,
attempt,
workflowInstanceId:
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
attempt: attemptNo,
workflowInstanceId: task.workflowInstanceId,
campaignId: task.campaignId,
},
"delivery",
@@ -450,7 +513,40 @@ export class DeliveryWorker extends BaseWorker {
clearTimeout(dispatchLogTimer);
if (dispatchLogged) return payload;
dispatchLogged = true;
return { ...payload, attempt, dispatchedAt };
return { ...payload, attempt: attemptNo, dispatchedAt };
};
let retryScheduled = false;
/**
* Re-queues a send the provider turned away, unless it said the failure is
* permanent or the task is out of attempts. An outage, a timeout or an open
* circuit is exactly what a later attempt can get past; failing the task
* outright turned every blip into lost mail. The attempt stays in the log as
* dispatched, and the task's outcome is whatever its last attempt comes to.
*/
const retryLater = async (failure: { retryable?: boolean; error?: string }) => {
const maxAttempts = task.deliveryOptions?.maxAttempts ?? 3;
if (failure.retryable === false || attemptNo >= maxAttempts) return false;
const delayMs = retryDelayMs(task.deliveryAttemptCount ?? 0);
await this.reschedule(
{ ...original, deliveryAttemptCount: attemptNo },
new Date(Date.now() + delayMs),
"retry",
);
retryScheduled = true;
metrics.deliveryFailed.inc({ channel: task.channel, reason: "retry_scheduled" });
this.logger.warn(
{
taskId: task.taskId,
channel: task.channel,
attempt: attemptNo,
maxAttempts,
delayMs,
error: failure.error,
},
"provider send failed — retrying later",
);
return true;
};
try {
@@ -469,7 +565,13 @@ export class DeliveryWorker extends BaseWorker {
if (task.channel === "push") {
let lastResult: any = { success: false, error: "No transports" };
for (const transport of transports) {
for (let i = startAt; i < transports.length; i++) {
const transport = transports[i];
// The starting provider's slot was taken above. Failing over to a
// later one spends that provider's own budget, or skips it if spent.
if (i > startAt && !(await this.providerGate(task.channel, transports, i)).allowed) {
continue;
}
const elapsedMs = Date.now() - tDispatchStart;
const remainingBudgetMs = Math.max(0, MAX_DISPATCH_BUDGET_MS - elapsedMs);
if (remainingBudgetMs <= 0) {
@@ -484,7 +586,7 @@ export class DeliveryWorker extends BaseWorker {
try {
const tProv = Date.now();
const breaker = this.getBreaker(`${task.channel}:${transport.constructor.name}`);
const breaker = this.getBreaker(providerKey(task.channel, transports, i));
lastResult = await breaker.execute(async () => {
const controller = new AbortController();
@@ -504,7 +606,7 @@ export class DeliveryWorker extends BaseWorker {
}),
]);
if (!res.success && !res.invalidToken) {
throw new Error(res.error ?? "Transport failed");
throw providerFailure(res);
}
return res;
} finally {
@@ -515,7 +617,7 @@ export class DeliveryWorker extends BaseWorker {
(global as any)._telemetry.provider += Date.now() - tProv;
if (lastResult.success || lastResult.invalidToken) break;
} catch (err: any) {
lastResult = { success: false, error: err.message };
lastResult = { success: false, error: err.message, retryable: err.retryable };
}
}
@@ -560,10 +662,9 @@ export class DeliveryWorker extends BaseWorker {
failureReason: "invalid_token",
failureCode: "push_failure",
retryable: false,
attempt,
attempt: attemptNo,
templateId: task.templateId,
workflowInstanceId:
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
workflowInstanceId: task.workflowInstanceId,
campaignId: task.campaignId,
}),
"delivery",
@@ -614,8 +715,7 @@ export class DeliveryWorker extends BaseWorker {
deliveredAt: new Date().toISOString(),
providerMessageId,
templateId: task.templateId,
workflowInstanceId:
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
workflowInstanceId: task.workflowInstanceId,
campaignId: task.campaignId,
}),
"delivery",
@@ -623,7 +723,7 @@ export class DeliveryWorker extends BaseWorker {
),
),
);
} else {
} else if (!(await retryLater(lastResult))) {
this.logger.warn(
{ taskId: task.taskId, error: lastResult.error },
"push delivery failed",
@@ -659,10 +759,9 @@ export class DeliveryWorker extends BaseWorker {
failureReason: lastResult.error ?? "push delivery failed",
failureCode: "push_failure",
retryable: false,
attempt,
attempt: attemptNo,
templateId: task.templateId,
workflowInstanceId:
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
workflowInstanceId: task.workflowInstanceId,
campaignId: task.campaignId,
}),
"delivery",
@@ -679,7 +778,13 @@ export class DeliveryWorker extends BaseWorker {
} else {
// Other channels (email, sms, webhook, whatsapp) use the pre-resolved destination
let result: any = { success: false, error: "No transports" };
for (const transport of transports) {
for (let i = startAt; i < transports.length; i++) {
const transport = transports[i];
// The starting provider's slot was taken above. Failing over to a
// later one spends that provider's own budget, or skips it if spent.
if (i > startAt && !(await this.providerGate(task.channel, transports, i)).allowed) {
continue;
}
const elapsedMs = Date.now() - tDispatchStart;
const remainingBudgetMs = Math.max(0, MAX_DISPATCH_BUDGET_MS - elapsedMs);
if (remainingBudgetMs <= 0) {
@@ -694,7 +799,7 @@ export class DeliveryWorker extends BaseWorker {
try {
const tProv = Date.now();
const breaker = this.getBreaker(`${task.channel}:${transport.constructor.name}`);
const breaker = this.getBreaker(providerKey(task.channel, transports, i));
result = await breaker.execute(async () => {
const controller = new AbortController();
@@ -714,7 +819,7 @@ export class DeliveryWorker extends BaseWorker {
}),
]);
if (!res.success) {
throw new Error(res.error ?? "Transport failed");
throw providerFailure(res);
}
return res;
} finally {
@@ -725,7 +830,7 @@ export class DeliveryWorker extends BaseWorker {
(global as any)._telemetry.provider += Date.now() - tProv;
if (result.success) break;
} catch (err: any) {
result = { success: false, error: err.message };
result = { success: false, error: err.message, retryable: err.retryable };
}
}
@@ -763,8 +868,7 @@ export class DeliveryWorker extends BaseWorker {
deliveredAt: new Date().toISOString(),
providerMessageId,
templateId: task.templateId,
workflowInstanceId:
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
workflowInstanceId: task.workflowInstanceId,
campaignId: task.campaignId,
}),
"delivery",
@@ -772,7 +876,7 @@ export class DeliveryWorker extends BaseWorker {
),
),
);
} else {
} else if (!(await retryLater(result))) {
publishPromises.push(
this.outboxUpdateProcessor.add({
taskId: task.taskId,
@@ -794,10 +898,9 @@ export class DeliveryWorker extends BaseWorker {
failureReason: result.error ?? "delivery failed completely",
failureCode: "provider_error",
retryable: false,
attempt,
attempt: attemptNo,
templateId: task.templateId,
workflowInstanceId:
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
workflowInstanceId: task.workflowInstanceId,
campaignId: task.campaignId,
}),
"delivery",
@@ -807,7 +910,7 @@ export class DeliveryWorker extends BaseWorker {
);
}
if (!result.success) {
if (!result.success && !retryScheduled) {
this.logger.warn(
{ taskId: task.taskId, channel: task.channel, error: result.error },
"delivery failed completely across providers",
@@ -825,7 +928,7 @@ export class DeliveryWorker extends BaseWorker {
if (!fallbackTriggered) {
throw new NonRetryableError(result.error ?? "delivery failed");
}
} else {
} else if (result.success) {
this.logger.debug(
{ taskId: task.taskId, channel: task.channel, messageId: result.providerMessageId },
"notification delivered",
@@ -845,9 +948,14 @@ export class DeliveryWorker extends BaseWorker {
});
const flushTime = Date.now() - tFlushStart;
void this.idempotency.markProcessed(idempotencyKey).catch((err: any) => {
this.logger.warn({ err, taskId: task.taskId }, "failed to upgrade idempotency TTL");
});
if (retryScheduled) {
// Released so the re-queued attempt can take the lease.
await this.idempotency.unmark(idempotencyKey).catch(() => {});
} else {
void this.idempotency.markProcessed(idempotencyKey).catch((err: any) => {
this.logger.warn({ err, taskId: task.taskId }, "failed to upgrade idempotency TTL");
});
}
const t = ((global as any)._telemetry = (global as any)._telemetry || {
count: 0,
+58 -51
View File
@@ -414,7 +414,13 @@ export class EngineWorker extends BaseWorker {
// Quiet hours check
const qh = enriched.recipient.preferences.quietHours;
if (qh && qh.length > 0 && enriched.priority !== "critical") {
const qhResult = isInQuietHours(enriched.recipient.timezone, qh);
// Judged at the moment the message would go out, not the moment the
// engine sees it: a send booked for next week must not be pulled
// forward to tonight's quiet-hours end, and one booked for 3am must
// wait until the quiet hours are over.
const requestedAt = enriched.scheduledAt ? new Date(enriched.scheduledAt) : new Date();
const sendAt = requestedAt.getTime() > Date.now() ? requestedAt : new Date();
const qhResult = isInQuietHours(enriched.recipient.timezone, qh, sendAt);
if (qhResult.inQuietHours && qhResult.nextActiveTime) {
this.logger.info(
{
@@ -444,18 +450,16 @@ export class EngineWorker extends BaseWorker {
);
}
const throttleResult = await this.throttle.check(
enriched.projectId,
enriched.recipientId,
enriched.priority,
{
limit: projectThrottle.throttleLimit,
windowHours: projectThrottle.throttleWindowHours,
scheduledAt: enriched.scheduledAt,
// A replayed message must not be counted against the user twice.
messageId: idempotencyKey,
},
);
// A channel fallback was counted when the notification was first sent.
const throttleResult = enriched.throttleCounted
? { allowed: true, count: 0, limit: 0 }
: await this.throttle.check(enriched.projectId, enriched.recipientId, enriched.priority, {
limit: projectThrottle.throttleLimit,
windowHours: projectThrottle.throttleWindowHours,
scheduledAt: enriched.scheduledAt,
// A replayed message must not be counted against the user twice.
messageId: idempotencyKey,
});
if (!throttleResult.allowed) {
this.logger.info(
{
@@ -476,8 +480,7 @@ export class EngineWorker extends BaseWorker {
return;
}
// Gather AI Prompts
// Gather AI Prompts
// Template-level AI prompts, overridden by any on the request.
let dbTemplate = null;
if (enriched.templateId) {
dbTemplate = await this.templateCache.getCachedTemplate(
@@ -508,41 +511,10 @@ export class EngineWorker extends BaseWorker {
// resulting opt-out applies to.
const templateTopics: string[] = dbTemplate?.topics ?? [];
if (Object.keys(aiPrompts).length > 0) {
const aiPendingPayload = {
projectId: enriched.projectId,
enrichedEventId: event.id,
recipientId: enriched.recipientId,
channel: enriched.channel,
priority: enriched.priority,
templateId: enriched.templateId,
templateVariables: enriched.templateVariables,
recipient: enriched.recipient,
aiPrompts,
scheduledAt: enriched.scheduledAt,
fallbackChain: enriched.fallbackChain,
};
const aiPendingEnvelope = buildStreamEvent(
"notification.ai_pending",
aiPendingPayload as Record<string, unknown>,
"engine",
event.metadata.traceId,
);
await this.aiPendingProducer.publish(aiPendingEnvelope);
this.logger.info(
{
messageId: message.id,
eventId: event.id,
recipientId: enriched.recipientId,
traceId: event.metadata.traceId,
},
"task routed to AI worker",
);
await idempotency.markProcessed(idempotencyKey, customTtl);
return;
}
// AI-generated messages take the same gates as any other — contacts,
// topic opt-outs and suppressions below — and are handed to the AI worker
// per contact once they have passed them.
const useAi = Object.keys(aiPrompts).length > 0;
// Rendering happens in delivery, just before the send, so the rendered
// body does not travel through the outbound stream.
@@ -588,7 +560,8 @@ export class EngineWorker extends BaseWorker {
});
for (const contact of activeContacts) {
if (contact.preferences?.optedOut) {
// This address opted out of one of the template's topics.
if (templateTopics.some((t) => contact.preferences?.topics?.[t] === false)) {
continue;
}
@@ -670,8 +643,42 @@ export class EngineWorker extends BaseWorker {
},
fallbackChain: enriched.fallbackChain,
campaignId: enriched.campaignId,
workflowInstanceId: enriched.workflowInstanceId,
};
if (useAi) {
await this.aiPendingProducer.publish(
buildStreamEvent(
"notification.ai_pending",
{
projectId: enriched.projectId,
enrichedEventId: event.id,
recipientId: enriched.recipientId,
channel: enriched.channel,
priority: enriched.priority,
templateId: enriched.templateId,
templateVariables: enriched.templateVariables,
recipient: enriched.recipient,
aiPrompts,
scheduledAt: enriched.scheduledAt,
fallbackChain: enriched.fallbackChain,
taskId,
destination: resolvedDestination,
deliveryHeaders: unsubscribeHeaders,
campaignId: enriched.campaignId,
workflowInstanceId: enriched.workflowInstanceId,
},
"engine",
event.metadata.traceId,
),
);
this.logger.debug(
{ messageId: message.id, taskId, traceId: event.metadata.traceId },
"task routed to AI worker",
);
continue;
}
const envelope = buildStreamEvent(
"notification.dispatched",
taskPayload as Record<string, unknown>,
+54 -32
View File
@@ -11,6 +11,7 @@ import {
buildStreamEvent,
type NotificationCreatedPayload,
type NotificationEnrichedPayload,
type NotificationChannel,
type StreamEvent,
} from "@/index.js";
import { IdempotencyGuard } from "@/index.js";
@@ -21,7 +22,7 @@ import {
ContactRepository,
} from "@/index.js";
import { TemplateCache } from "@/templates/index.js";
import { getPriorityBucket, type WorkerOptions } from "@/shared/index.js";
import { getPriorityBucket, globalEmitter, type WorkerOptions } from "@/shared/index.js";
import { startHealthReporter } from "@/workers/index.js";
import {
createPriorityProducers,
@@ -256,10 +257,13 @@ export class EnricherWorker extends BaseWorker {
const profile = await this.userRepo.findRecordById(raw.projectId, raw.recipientId);
if (!profile) return;
const prefs = await this.prefRepo.findByUserId(raw.projectId, raw.recipientId);
const optedOutTypes = new Set(
prefs.filter((p: any) => !p.optedIn).map((p: any) => p.eventType),
);
// Opt-outs are keyed on the template's topics, as on the requested
// path. `event.type` is always "notification.created", which no user
// ever opts out of, so checking it here let every opt-out through.
const template = raw.templateId
? await this.templateCache.getCachedTemplate(raw.projectId, raw.templateId)
: null;
const topics: string[] = template?.topics ?? [];
const enrichedPayload: NotificationEnrichedPayload = {
projectId: raw.projectId,
@@ -275,8 +279,7 @@ export class EnricherWorker extends BaseWorker {
locale: profile.language ?? "en",
timezone: profile.timezone ?? "UTC",
preferences: {
optedOut:
optedOutTypes.has(event.type) || profile.preferences.topics?.[event.type] === false,
optedOut: topics.some((t) => profile.preferences.topics?.[t] === false),
channels: Object.entries(profile.preferences.channels ?? {})
.filter(([_, enabled]) => !enabled)
.map(([channel]) => channel as any),
@@ -311,6 +314,7 @@ export class EnricherWorker extends BaseWorker {
"event enriched",
);
} catch (err) {
await this.idempotency.unmark(dedupeId).catch(() => {});
throw err;
}
await Promise.all(publishPromises).catch(async (err) => {
@@ -345,24 +349,16 @@ export class EnricherWorker extends BaseWorker {
const maxUsers = readBaseConfig().SEGMENT_MAX_USERS;
if (userIds.length > maxUsers) {
const reason = `Segment fan-out of ${userIds.length} exceeds limit of ${maxUsers}`;
this.logger.error(
{ count: userIds.length, max: maxUsers, projectId: raw.projectId, eventId: event.id },
"Segment fan-out exceeds maximum allowed limit",
);
const p = getPriorityBucket(raw.priority ?? "normal");
const producer = this.producers[p] ?? this.producers.normal;
await producer.publish(
buildStreamEvent(
"notification.failed",
{
projectId: raw.projectId,
rawEventId: event.id,
error: `Segment fan-out of ${userIds.length} exceeds limit of ${maxUsers}`,
},
"enricher",
event.metadata.traceId,
),
);
// Reported to the caller, not published: a `notification.failed` on
// the enriched stream is not a payload the engine can read, so the
// refusal used to vanish there without a trace.
globalEmitter.emit("notification:failed", event.id, reason, event.type);
await this.idempotency.markProcessed(dedupeId);
return;
}
@@ -374,15 +370,10 @@ export class EnricherWorker extends BaseWorker {
: null;
const topics: string[] = template?.topics ?? [];
const channels =
raw.channels && raw.channels.length > 0 ? raw.channels : (["email"] as any[]);
const channels: NotificationChannel[] =
raw.channels && raw.channels.length > 0 ? raw.channels : ["email"];
const isFallback = (raw as any).fallback === true;
// If fallback is true, we only emit the first channel, and pass the rest in fallbackChain.
// If fallback is false, we emit all channels concurrently.
const channelsToProcess = isFallback ? [channels[0]] : channels;
const fallbackChain = isFallback ? channels.slice(1) : undefined;
const chunkArray = <T>(arr: T[], size: number) =>
Array.from({ length: Math.ceil(arr.length / size) }, (v, i) =>
arr.slice(i * size, i * size + size),
@@ -410,9 +401,36 @@ export class EnricherWorker extends BaseWorker {
};
for (const profile of profiles) {
for (const channel of channelsToProcess) {
const contacts = contactsByUser.get(profile.userId) ?? [];
const channelContacts = contacts.filter((contact: any) => contact.channel === channel);
const contacts = contactsByUser.get(profile.userId) ?? [];
// This user's addresses on a channel, less any that opted out of one
// of this template's topics.
const usable = (channel: string) =>
contacts.filter(
(contact: any) =>
contact.channel === channel &&
!topics.some((t) => contact.preferences?.topics?.[t] === false),
);
// Without fallback every requested channel is sent at once. With it,
// start at the first channel this user can be reached on and chain the
// rest behind it — a user with no email address still gets the SMS.
let userChannels: NotificationChannel[] = channels;
let fallbackChain: NotificationChannel[] | undefined;
if (isFallback) {
const first = channels.findIndex((c: NotificationChannel) => usable(c).length > 0);
if (first === -1) {
this.logger.info(
{ recipientId: profile.userId, channels },
"no active contact on any fallback channel",
);
continue;
}
userChannels = [channels[first]!];
fallbackChain = channels.slice(first + 1);
}
for (const channel of userChannels) {
const channelContacts = usable(channel);
// Push resolves its active tokens at send time so token invalidation
// remains current. Other channels need one task per address.
const resolved: ({ id: string; target: string } | undefined)[] =
@@ -430,7 +448,7 @@ export class EnricherWorker extends BaseWorker {
projectId: raw.projectId,
rawEventId: event.id,
recipientId: profile.userId,
channel: channel,
channel,
priority: "normal",
templateId: raw.templateId,
templateVariables: raw.data,
@@ -464,6 +482,7 @@ export class EnricherWorker extends BaseWorker {
contactId: contact?.id,
destination,
campaignId: raw.campaignId,
workflowInstanceId: raw.workflowInstanceId,
};
const msgPriority = raw.priority ?? "normal";
@@ -520,6 +539,9 @@ export class EnricherWorker extends BaseWorker {
"event enriched",
);
} catch (err) {
// Release the marker, or a retry landing inside its TTL is taken for a
// duplicate and acked without ever being processed.
await this.idempotency.unmark(dedupeId).catch(() => {});
throw err;
}
+7 -2
View File
@@ -14,7 +14,12 @@ import { workflowWaiters, workflowSteps, workflowInstances } from "@/db/schema.j
import { eq, and, or, isNull, gt } from "drizzle-orm";
import { type WorkerOptions } from "@/shared/index.js";
import { startHealthReporter } from "@/workers/index.js";
import { createStreamConsumer, createWorkerRuntime, shutdownWorker } from "@/workers/bootstrap.js";
import {
createStreamConsumer,
createWorkerRuntime,
shutdownWorker,
uniqueConsumerId,
} from "@/workers/bootstrap.js";
loadEnv();
const config = readBaseConfig();
@@ -376,7 +381,7 @@ export async function startEventWorker() {
{
stream: STREAMS.EVENTS_INBOUND,
group: CONSUMER_GROUPS.EVENTS,
consumer: `events-${process.pid}`,
consumer: uniqueConsumerId("events"),
batchSize: eventsConcurrency,
bufferAcks: true,
// Log rows are not latency-sensitive; let reads fill up.
+45 -4
View File
@@ -11,16 +11,18 @@ import {
buildStreamEvent,
type NotificationScheduledPayload,
} from "@/index.js";
import { getPriorityBucket, type WorkerOptions } from "@/shared/index.js";
import { getPriorityBucket, globalEmitter, type WorkerOptions } from "@/shared/index.js";
import { scheduledPayloads } from "@/db/schema.js";
import { asc, gt, inArray } from "drizzle-orm";
import { and, asc, eq, gt, inArray, or, sql as drizzleSql } from "drizzle-orm";
import { startHealthReporter } from "@/workers/index.js";
import {
createPriorityProducers,
createStreamConsumer,
createWorkerRuntime,
shutdownWorker,
uniqueConsumerId,
} from "@/workers/bootstrap.js";
import { findBlockedAtRelease } from "./send-gate.js";
// ─── Bootstrap ─────────────────────────────────────────────────────────────
loadEnv();
@@ -137,6 +139,11 @@ export async function executeSchedulerPoll(
.where(inArray(scheduledPayloads.taskId, taskIds));
const payloadMap = new Map(dbPayloads.map((row: any) => [row.taskId, row.payload]));
// What the person did while this sat parked still applies. A failed
// lookup throws to the catch below: the claimed tasks reappear after the
// visibility timeout instead of going out unchecked.
const blocked = await findBlockedAtRelease(db, [...payloadMap.values()] as any[]);
const batchedEvents: Record<"critical" | "normal" | "low", Omit<any, "id" | "timestamp">[]> = {
critical: [],
normal: [],
@@ -170,6 +177,23 @@ export async function executeSchedulerPoll(
}
const dispatchPayload = payload as any;
const blockReason = blocked.get(taskId);
if (blockReason) {
logger.info({ taskId, reason: blockReason }, "parked send no longer allowed — dropping");
globalEmitter.emit("notification:skipped", {
projectId: dispatchPayload.projectId,
eventId: dispatchPayload.enrichedEventId,
recipientId: dispatchPayload.recipientId,
reason: blockReason,
});
released.add(taskId);
dbCleanupIds.push(taskId);
const shard = parseInt(taskId.slice(-1), 16) || 0;
cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);
continue;
}
const p = getPriorityBucket(dispatchPayload.priority);
batchedEvents[p].push(
@@ -194,7 +218,24 @@ export async function executeSchedulerPoll(
if (dbCleanupIds.length > 0) {
try {
await db.delete(scheduledPayloads).where(inArray(scheduledPayloads.taskId, dbCleanupIds));
// Only the payload that was released. By now delivery may have
// throttled the task and parked it again under the same id with a
// later send time; deleting by id alone removed that newer payload,
// and its ZSET entry was then dropped as canceled — the send was lost.
const sendAt = drizzleSql`${scheduledPayloads.payload}->>'scheduledAt'`;
await db.delete(scheduledPayloads).where(
or(
...dbCleanupIds.map((taskId) => {
const released = (payloadMap.get(taskId) as any)?.scheduledAt;
return and(
eq(scheduledPayloads.taskId, taskId),
typeof released === "string"
? drizzleSql`${sendAt} = ${released}`
: drizzleSql`${sendAt} IS NULL`,
);
}),
),
);
} catch (err) {
logger.error(
{ err },
@@ -284,7 +325,7 @@ export async function startSchedulerWorker() {
{
stream: STREAMS.SCHEDULED,
group: CONSUMER_GROUPS.SCHEDULER,
consumer: `scheduler-${process.pid}`,
consumer: uniqueConsumerId("scheduler"),
batchSize: config.WORKER_CONCURRENCY,
},
));
+117
View File
@@ -0,0 +1,117 @@
import { and, eq, inArray } from "drizzle-orm";
import { suppressions } from "@/db/schema.js";
import { ContactRepository, TemplateRepository, UserRepository } from "@/repositories/index.js";
import { normaliseTarget } from "@/shared/index.js";
export type BlockReason =
"suppressed" | "recipient_not_found" | "user_opted_out" | "channel_disabled" | "contact_inactive";
/** The fields of a parked `notification.dispatched` payload the gate reads. */
interface ParkedTask {
taskId: string;
projectId: string;
recipientId: string;
channel: string;
templateId?: string;
destination?: string;
}
/**
* Re-checks parked sends at the moment they are released.
*
* The engine clears a message against suppressions, opt-outs and contacts when
* it first sees it. A send parked for later — a `sendAt`, a quiet-hours
* deferral, a retry — can sit for hours or days, and whatever the person did in
* between (unsubscribed, complained, hard-bounced, removed the address) must
* still stop it. Without this the scheduler sent to them anyway.
*
* Reads Postgres directly rather than through the repositories' caches: this
* runs once per poll, not once per message, and a stale "still subscribed" is
* the very thing it exists to prevent.
*
* Returns the blocked tasks with the reason. Throws if a lookup fails, so the
* caller holds the batch rather than sending it unchecked.
*/
export async function findBlockedAtRelease(
db: any,
tasks: ParkedTask[],
): Promise<Map<string, BlockReason>> {
const blocked = new Map<string, BlockReason>();
const byProject = new Map<string, ParkedTask[]>();
for (const t of tasks) {
if (!t?.projectId || !t.recipientId) continue;
if (!byProject.has(t.projectId)) byProject.set(t.projectId, []);
byProject.get(t.projectId)!.push(t);
}
for (const [projectId, group] of byProject) {
const recipientIds = [...new Set(group.map((t) => t.recipientId))];
const templateIds = [...new Set(group.map((t) => t.templateId).filter(Boolean))] as string[];
const targets = [
...new Set(group.map((t) => t.destination && normaliseTarget(t.destination)).filter(Boolean)),
] as string[];
const templateRepo = new TemplateRepository(db);
const [suppressedRows, users, contactsByUser, templates] = await Promise.all([
targets.length > 0
? db
.select({ channel: suppressions.channel, target: suppressions.target })
.from(suppressions)
.where(
and(eq(suppressions.projectId, projectId), inArray(suppressions.target, targets)),
)
: [],
new UserRepository(db).findRecordsByIds(projectId, recipientIds),
new ContactRepository(db).findActiveByUserIds(projectId, recipientIds),
Promise.all(templateIds.map((id) => templateRepo.findById(projectId, id))),
]);
const suppressed = new Set(
(suppressedRows as { channel: string; target: string }[]).map(
(r) => `${r.channel}:${normaliseTarget(r.target)}`,
),
);
const userById = new Map(users.map((u) => [u.userId, u]));
const topicsByTemplate = new Map<string, string[]>();
templateIds.forEach((id, i) => topicsByTemplate.set(id, templates[i]?.topics ?? []));
for (const t of group) {
const topics = t.templateId ? (topicsByTemplate.get(t.templateId) ?? []) : [];
const reason = decide(t, topics, suppressed, userById.get(t.recipientId), contactsByUser);
if (reason) blocked.set(t.taskId, reason);
}
}
return blocked;
}
function decide(
t: ParkedTask,
topics: string[],
suppressed: Set<string>,
user:
| { preferences: { topics?: Record<string, boolean>; channels?: Record<string, boolean> } }
| undefined,
contactsByUser: Map<string, { channel: string; target: string; preferences?: any }[]>,
): BlockReason | undefined {
// Checked first and for every priority, as the engine does: a suppression
// records that the address asked us to stop or is dead.
if (t.destination && suppressed.has(`${t.channel}:${normaliseTarget(t.destination)}`)) {
return "suppressed";
}
if (!user) return "recipient_not_found";
if (topics.some((topic) => user.preferences.topics?.[topic] === false)) return "user_opted_out";
if (user.preferences.channels?.[t.channel] === false) return "channel_disabled";
if (t.destination) {
const contact = (contactsByUser.get(t.recipientId) ?? []).find(
(c) => c.channel === t.channel && c.target === t.destination,
);
if (!contact) return "contact_inactive";
if (topics.some((topic) => contact.preferences?.topics?.[topic] === false)) {
return "user_opted_out";
}
}
return undefined;
}
+84 -22
View File
@@ -26,7 +26,12 @@ import {
} from "@/workflows/index.js";
import { type WorkerOptions } from "@/shared/index.js";
import { startHealthReporter } from "@/workers/index.js";
import { createStreamConsumer, createWorkerRuntime, shutdownWorker } from "@/workers/bootstrap.js";
import {
createStreamConsumer,
createWorkerRuntime,
shutdownWorker,
uniqueConsumerId,
} from "@/workers/bootstrap.js";
/** How long a workflow instance lock is held before it self-expires. */
const WORKFLOW_LOCK_TTL_SECONDS = 60;
@@ -34,6 +39,11 @@ const WORKFLOW_LOCK_TTL_SECONDS = 60;
const WORKFLOW_LOCK_RENEW_MS = (WORKFLOW_LOCK_TTL_SECONDS / 3) * 1000;
/** How long a claimed workflow timer stays invisible to other pollers. */
const WORKFLOW_TIMER_VISIBILITY_MS = 60_000;
/** Wake-ups for sleeping instances: waits, event timeouts, deferred resumes. */
export const WORKFLOW_TIMERS_KEY = "notif:workflow:timers";
/** How soon a resume that found its instance busy is tried again. */
const WORKFLOW_RESUME_RETRY_MS = 2_000;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
loadEnv();
const config = readBaseConfig();
@@ -119,6 +129,25 @@ export class WorkflowWorker extends BaseWorker {
}
}
/**
* Re-delivers a resume through the timer ZSET a few seconds from now. The
* poll loop publishes it once the instance is pending again, and drops it if
* the instance has finished. The member carries no timestamp, so repeated
* deferrals of one instance collapse into a single entry.
*/
private async deferResume(payload: any): Promise<void> {
await this.redisCli.zadd(
WORKFLOW_TIMERS_KEY,
Date.now() + WORKFLOW_RESUME_RETRY_MS,
JSON.stringify({
instanceId: payload.instanceId,
name: payload.name,
projectId: payload.projectId,
input: payload.input ?? {},
}),
);
}
async process(message: StreamMessage): Promise<void> {
const { event } = message;
const publishPromises: Promise<void>[] = [];
@@ -189,7 +218,12 @@ export class WorkflowWorker extends BaseWorker {
"NX",
);
if (!acquired) {
this.logger.info({ instanceId }, "Workflow is locked by another process, skipping");
// Another process is mid-run on this instance — typically still
// persisting the very suspension this resume answers, when the awaited
// event arrived quickly. Acking here dropped the resume, and with the
// waiter already consumed nothing would ever wake the instance again.
this.logger.info({ instanceId }, "Workflow is locked by another process, deferring resume");
await this.deferResume(payload);
return;
}
@@ -225,6 +259,13 @@ export class WorkflowWorker extends BaseWorker {
instance = rows[0]!;
}
if (instance.status === "running") {
// Its last holder died mid-run (the reaper will set it back to
// pending) or has not finished suspending. Either way this resume
// is still owed.
await this.deferResume(payload);
return;
}
if (instance.status !== "pending") {
this.logger.info({ instanceId }, "Workflow is not pending, skipping");
return;
@@ -259,6 +300,9 @@ export class WorkflowWorker extends BaseWorker {
instance!.input,
payload.projectId,
`wf-${instanceId}-${stepId}`,
// The contract wants a uuid, which the API always issues; anything
// else would fail validation and drop the notification.
UUID_RE.test(instanceId) ? instanceId : undefined,
);
const result = await new Promise<{ messageId: string; notificationId: string }>(
@@ -295,7 +339,15 @@ export class WorkflowWorker extends BaseWorker {
},
wait: async (duration) => {
const stepId = String(currentStepIndex++);
if (stepOutputMap.has(stepId)) return;
if (stepOutputMap.has(stepId)) {
// A resume that arrives early — a duplicate trigger, a replayed
// message — must not cut the sleep short. The timer is still set.
const until = Number(stepOutputMap.get(stepId)?.scheduledAt);
if (Number.isFinite(until) && until > Date.now()) {
throw new SuspendExecutionError("wait", { duration });
}
return;
}
// Simple duration parse (e.g. '2h' -> ms)
let ms = 0;
@@ -311,7 +363,7 @@ export class WorkflowWorker extends BaseWorker {
// If the process dies after this point the source event is retried;
// if it dies after the database write, the timer is already durable.
await this.redisCli.zadd(
"notif:workflow:timers",
WORKFLOW_TIMERS_KEY,
resumeAt,
JSON.stringify({
instanceId: instance!.id,
@@ -335,7 +387,12 @@ export class WorkflowWorker extends BaseWorker {
const stepId = String(currentStepIndex++);
if (stepOutputMap.has(stepId)) {
const out = stepOutputMap.get(stepId);
if (out && typeof out === "object" && (out as any).timedOut === true) {
// Still waiting: neither the event nor the timeout has filled the
// step in, so this resume is spurious.
if (out === null || out === undefined) {
throw new SuspendExecutionError("waitForEvent", { eventName });
}
if (typeof out === "object" && (out as any).timedOut === true) {
return null;
}
return out;
@@ -355,7 +412,7 @@ export class WorkflowWorker extends BaseWorker {
const resumeAt = Date.now() + ms;
await this.redisCli.zadd(
"notif:workflow:timers",
WORKFLOW_TIMERS_KEY,
resumeAt,
JSON.stringify({
instanceId: instance!.id,
@@ -368,16 +425,8 @@ export class WorkflowWorker extends BaseWorker {
}),
);
// Register waiter
await this.dbConn.insert(workflowWaiters).values({
instanceId: instance!.id,
projectId: payload.projectId,
eventName: eventName,
matchCriteria: options.match,
expiresAt: new Date(resumeAt),
});
// Register step as pending event
// The pending step before the waiter: an event that matches the
// waiter the moment it exists must find a step to record itself in.
await this.dbConn.insert(workflowSteps).values({
instanceId: instance!.id,
projectId: payload.projectId,
@@ -386,6 +435,14 @@ export class WorkflowWorker extends BaseWorker {
output: null, // this will be updated by event worker or timeout
});
await this.dbConn.insert(workflowWaiters).values({
instanceId: instance!.id,
projectId: payload.projectId,
eventName: eventName,
matchCriteria: options.match,
expiresAt: new Date(resumeAt),
});
throw new SuspendExecutionError("waitForEvent", { eventName });
},
run: async (stepName, fn) => {
@@ -466,7 +523,7 @@ export async function startWorkflowWorker() {
{
stream: STREAMS.WORKFLOW_INBOUND,
group: CONSUMER_GROUPS.WORKFLOW,
consumer: `workflow-${process.pid}`,
consumer: uniqueConsumerId("workflow"),
batchSize: config.WORKER_CONCURRENCY,
},
));
@@ -488,7 +545,7 @@ export async function startWorkflowWorker() {
try {
const now = Date.now();
const tasks = await redis.native.schedulerPoll(
"notif:workflow:timers",
WORKFLOW_TIMERS_KEY,
now,
100,
WORKFLOW_TIMER_VISIBILITY_MS,
@@ -505,8 +562,13 @@ export async function startWorkflowWorker() {
.where(eq(workflowInstances.id, task.instanceId))
.limit(1)
)[0];
if (!inst || inst.status === "completed" || inst.status === "failed") {
await redis.native.zrem("notif:workflow:timers", taskStr);
if (
!inst ||
inst.status === "completed" ||
inst.status === "failed" ||
inst.status === "canceled"
) {
await redis.native.zrem(WORKFLOW_TIMERS_KEY, taskStr);
continue;
}
// A timer can be observed while the worker is still persisting the
@@ -530,7 +592,7 @@ export async function startWorkflowWorker() {
// If the waiter was already deleted (by EventWorker when event arrived before timeout),
// this timer is stale. Clean up from Redis and skip re-resuming the workflow.
if (deleted.length === 0) {
await redis.native.zrem("notif:workflow:timers", taskStr);
await redis.native.zrem(WORKFLOW_TIMERS_KEY, taskStr);
continue;
}
@@ -556,7 +618,7 @@ export async function startWorkflowWorker() {
await workflowProducer.publish(
buildStreamEvent("workflow.resumed", task, "scheduler", undefined),
);
await redis.native.zrem("notif:workflow:timers", taskStr);
await redis.native.zrem(WORKFLOW_TIMERS_KEY, taskStr);
logger.info({ instanceId: task.instanceId }, "Workflow resumed from timer");
}
} catch (err) {
+6
View File
@@ -10,6 +10,12 @@ export interface DeliveryResult {
providerMessageId?: string;
invalidToken?: boolean;
error?: string;
/**
* `false` marks a failure another attempt cannot fix — a malformed address,
* a rejected sender. Anything else (an outage, a timeout, a 5xx) is retried
* up to the task's `maxAttempts`.
*/
retryable?: boolean;
}
export interface WebhookEvent {
+8 -3
View File
@@ -220,7 +220,7 @@ export abstract class BaseWorker {
{ err, eventId: event.id },
"non-retryable error on inline message — moving to dead-letter queue",
);
await this.consumer.deadLetter(event);
await this.consumer.deadLetter(event, (err as Error).message);
globalEmitter.emit("notification:failed", event.id, (err as Error).message, event.type);
return;
}
@@ -239,7 +239,12 @@ export abstract class BaseWorker {
{ messageId: message.id, retryCount },
"max retries exceeded, moving to dead-letter queue",
);
await this.consumer.nack(message.id, message.event, stream);
await this.consumer.nack(
message.id,
message.event,
stream,
`max retries exceeded (${retryCount - 1} attempts)`,
);
globalEmitter.emit(
"notification:failed",
message.id,
@@ -285,7 +290,7 @@ export abstract class BaseWorker {
{ err, messageId: message.id },
"non-retryable error encountered, immediately moving to dead-letter queue without retry loop",
);
await this.consumer.nack(message.id, message.event, stream);
await this.consumer.nack(message.id, message.event, stream, (err as Error).message);
globalEmitter.emit(
"notification:failed",
message.id,
+2
View File
@@ -90,6 +90,7 @@ export function buildStepNotifyPayload(
instanceInput: unknown,
projectId: string,
idempotencyKey?: string,
workflowInstanceId?: string,
): NotificationRequestedPayload {
return {
projectId,
@@ -102,5 +103,6 @@ export function buildStepNotifyPayload(
fallback: args.fallback ?? false,
scheduledAt: args.sendAt,
idempotencyKey,
...(workflowInstanceId ? { workflowInstanceId } : {}),
};
}
+8 -3
View File
@@ -427,7 +427,11 @@ describe("API operational handlers", () => {
);
expect(res.statusCode).toBe(200);
expect(parse(res)).toEqual({ success: true, replayedId: "1-0" });
expect(parse(res)).toEqual({
success: true,
replayedId: "1-0",
stream: STREAMS.INBOUND_CRITICAL,
});
expect(deps.redis.native.xadd).toHaveBeenCalledWith(
STREAMS.INBOUND_CRITICAL,
"*",
@@ -534,10 +538,11 @@ describe("API operational handlers", () => {
it("cancelWorkflow returns 204 once the instance is canceled", async () => {
const res = createMockRes();
await handlers.cancelWorkflow(createMockReq(), res, ctx({ params: { id: "wf_1" } }));
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", "wf_1");
expect(deps.workflowRepo.cancelInstance).toHaveBeenCalledWith("proj_1", id);
});
it("cancelWorkflow 400s when the instance cannot be canceled", async () => {
+21 -17
View File
@@ -380,6 +380,19 @@ describe("API compliance handlers", () => {
describe("getCampaignStats", () => {
const statsCtx = (campaign = "spring-sale") => ctx({ params: { campaign } });
/**
* The grouped rows the stats query returns for `sent` tasks on a channel,
* shaped like the real delivery log: every attempt writes a `dispatched`
* row and then an `attempt` row carrying its outcome.
*/
const sends = (channel: string, delivered: number, failed = 0) => [
{ channel, kind: "dispatched", status: "dispatched", tasks: String(delivered + failed) },
...(delivered
? [{ channel, kind: "attempt", status: "delivered", tasks: String(delivered) }]
: []),
...(failed ? [{ channel, kind: "attempt", status: "failed", tasks: String(failed) }] : []),
];
it("400s when no campaign is named", async () => {
const res = createMockRes();
await handlers.getCampaignStats(createMockReq(), res, ctx({ params: {} }));
@@ -399,10 +412,7 @@ describe("API compliance handlers", () => {
});
it("splits delivery rows into sent, delivered and failed", async () => {
mockDb.queueSelect([
{ channel: "email", kind: "delivery", status: "delivered", tasks: "90" },
{ channel: "email", kind: "delivery", status: "failed", tasks: "10" },
]);
mockDb.queueSelect(sends("email", 90, 10));
const res = createMockRes();
await handlers.getCampaignStats(createMockReq(), res, statsCtx());
@@ -416,7 +426,7 @@ describe("API compliance handlers", () => {
it("counts engagement kinds separately from the send total", async () => {
mockDb.queueSelect([
{ channel: "email", kind: "delivery", status: "delivered", tasks: "100" },
...sends("email", 100),
{ channel: "email", kind: "opened", status: "opened", tasks: "40" },
{ channel: "email", kind: "clicked", status: "clicked", tasks: "10" },
]);
@@ -434,7 +444,7 @@ describe("API compliance handlers", () => {
it("rounds a rate to two decimals", async () => {
mockDb.queueSelect([
{ channel: "email", kind: "delivery", status: "delivered", tasks: "3" },
...sends("email", 3),
{ channel: "email", kind: "opened", status: "opened", tasks: "1" },
]);
const res = createMockRes();
@@ -445,7 +455,7 @@ describe("API compliance handlers", () => {
});
it("reports a rate as null, not zero, when its denominator is zero", async () => {
mockDb.queueSelect([{ channel: "email", kind: "delivery", status: "failed", tasks: "5" }]);
mockDb.queueSelect(sends("email", 0, 5));
const res = createMockRes();
await handlers.getCampaignStats(createMockReq(), res, statsCtx());
@@ -458,11 +468,7 @@ describe("API compliance handlers", () => {
});
it("breaks the funnel down per channel and totals across them", async () => {
mockDb.queueSelect([
{ channel: "email", kind: "delivery", status: "delivered", tasks: "50" },
{ channel: "sms", kind: "delivery", status: "delivered", tasks: "30" },
{ channel: "sms", kind: "delivery", status: "failed", tasks: "20" },
]);
mockDb.queueSelect([...sends("email", 50), ...sends("sms", 30, 20)]);
const res = createMockRes();
await handlers.getCampaignStats(createMockReq(), res, statsCtx());
@@ -476,7 +482,7 @@ describe("API compliance handlers", () => {
});
it("warns that opens are untracked rather than zero on a channel that cannot report them", async () => {
mockDb.queueSelect([{ channel: "sms", kind: "delivery", status: "delivered", tasks: "10" }]);
mockDb.queueSelect(sends("sms", 10));
const res = createMockRes();
await handlers.getCampaignStats(createMockReq(), res, statsCtx());
@@ -487,9 +493,7 @@ describe("API compliance handlers", () => {
});
it("warns when email was delivered but no engagement ever arrived", async () => {
mockDb.queueSelect([
{ channel: "email", kind: "delivery", status: "delivered", tasks: "10" },
]);
mockDb.queueSelect(sends("email", 10));
const res = createMockRes();
await handlers.getCampaignStats(createMockReq(), res, statsCtx());
@@ -501,7 +505,7 @@ describe("API compliance handlers", () => {
it("stays quiet when engagement is both possible and present", async () => {
mockDb.queueSelect([
{ channel: "email", kind: "delivery", status: "delivered", tasks: "10" },
...sends("email", 10),
{ channel: "email", kind: "opened", status: "opened", tasks: "4" },
]);
const res = createMockRes();
+18 -11
View File
@@ -173,6 +173,9 @@ describe("API Handlers", () => {
listApiKeys: vi.fn().mockResolvedValue([]),
deleteApiKey: vi.fn().mockResolvedValue(true),
updateApiKeyHash: vi.fn().mockResolvedValue(true),
findThrottleSettings: vi
.fn()
.mockResolvedValue({ throttleLimit: null, throttleWindowHours: null }),
} as any,
workflowRepo: {
listDefinitions: vi.fn().mockResolvedValue([]),
@@ -509,6 +512,10 @@ describe("API Handlers", () => {
});
describe("New CRUD Endpoints", () => {
const PROJECT_UUID = "11111111-1111-4111-8111-111111111111";
const KEY_UUID = "22222222-2222-4222-8222-222222222222";
const WF_ID = "33333333-3333-4333-8333-333333333333";
it("listWorkflows", async () => {
const res = createMockRes();
await handlers.listWorkflows({} as any, res, {
@@ -525,11 +532,11 @@ describe("API Handlers", () => {
const res = createMockRes();
await handlers.getWorkflow({} as any, res, {
projectId: "proj_1",
params: { id: "wf_1" },
params: { id: WF_ID },
query: new URLSearchParams(),
} as any);
expect(res.statusCode).toBe(200);
expect(deps.workflowRepo.getInstance).toHaveBeenCalledWith("proj_1", "wf_1");
expect(deps.workflowRepo.getInstance).toHaveBeenCalledWith("proj_1", WF_ID);
});
it("listUsers", async () => {
@@ -590,11 +597,11 @@ describe("API Handlers", () => {
const res = createMockRes();
await handlers.updateProject(req, res, {
projectId: "proj_1",
params: { id: "proj_1" },
params: { id: PROJECT_UUID },
query: new URLSearchParams(),
} as any);
expect(res.statusCode).toBe(200);
expect(deps.projectRepo.updateSettings).toHaveBeenCalledWith("proj_1", {
expect(deps.projectRepo.updateSettings).toHaveBeenCalledWith(PROJECT_UUID, {
rateLimitRpm: 1000,
});
});
@@ -603,11 +610,11 @@ describe("API Handlers", () => {
const res = createMockRes();
await handlers.deleteProject({} as any, res, {
projectId: "proj_1",
params: { id: "proj_1" },
params: { id: PROJECT_UUID },
query: new URLSearchParams(),
} as any);
expect(res.statusCode).toBe(204);
expect(deps.projectRepo.delete).toHaveBeenCalledWith("proj_1");
expect(deps.projectRepo.delete).toHaveBeenCalledWith(PROJECT_UUID);
});
it("createProjectKey", async () => {
@@ -616,7 +623,7 @@ describe("API Handlers", () => {
(deps.projectRepo.createApiKey as any).mockResolvedValue({ id: "key_1" });
await handlers.createProjectKey(req, res, {
projectId: "proj_1",
params: { id: "proj_1" },
params: { id: PROJECT_UUID },
query: new URLSearchParams(),
} as any);
expect(res.statusCode).toBe(201);
@@ -630,11 +637,11 @@ describe("API Handlers", () => {
(deps.projectRepo.listApiKeys as any).mockResolvedValue([{ id: "key_1", role: "admin" }]);
await handlers.listProjectKeys({ method: "GET" } as any, res, {
projectId: "proj_1",
params: { id: "proj_1" },
params: { id: PROJECT_UUID },
query: new URLSearchParams(),
} as any);
expect(res.statusCode).toBe(200);
expect(deps.projectRepo.listApiKeys).toHaveBeenCalledWith("proj_1");
expect(deps.projectRepo.listApiKeys).toHaveBeenCalledWith(PROJECT_UUID);
});
it("deleteProjectKey", async () => {
@@ -642,11 +649,11 @@ describe("API Handlers", () => {
(deps.projectRepo.deleteApiKey as any).mockResolvedValue(true);
await handlers.deleteProjectKey({ method: "DELETE" } as any, res, {
projectId: "proj_1",
params: { id: "proj_1", keyId: "key_1" },
params: { id: PROJECT_UUID, keyId: KEY_UUID },
query: new URLSearchParams(),
} as any);
expect(res.statusCode).toBe(204);
expect(deps.projectRepo.deleteApiKey).toHaveBeenCalledWith("proj_1", "key_1");
expect(deps.projectRepo.deleteApiKey).toHaveBeenCalledWith(PROJECT_UUID, KEY_UUID);
});
it("deleteProjectKey returns 404 if key does not exist", async () => {
+14 -16
View File
@@ -26,23 +26,21 @@ describe("Crash Testing (Chaos Monkey)", () => {
const spawnWorker = (type: string, id: string) => {
logger.info(`Spawning worker: ${type} (${id})`);
const isWin = process.platform === "win32";
const cp = spawn(
isWin ? "cmd.exe" : "npx",
isWin
? ["/c", "npx", "--no-install", "tsx", "tests/chaos/worker-runner.ts"]
: ["--no-install", "tsx", "tests/chaos/worker-runner.ts"],
{
env: {
...process.env,
WORKER_TYPE: type,
DATABASE_URL: dbUrl,
REDIS_URL: redisUrl,
LOG_LEVEL: "silent",
},
stdio: "ignore", // ignore output to keep logs clean
// The worker is this node binary's own child, with tsx loaded as an import
// hook. Launched through `cmd /c npx tsx` (or `npx tsx`) instead, a kill
// reached only the wrapper: on Windows the worker itself survived every
// "crash", so no crash was ever tested, and each respawn leaked another
// worker until the machine ran out of memory.
const cp = spawn(process.execPath, ["--import", "tsx", "tests/chaos/worker-runner.ts"], {
env: {
...process.env,
WORKER_TYPE: type,
DATABASE_URL: dbUrl,
REDIS_URL: redisUrl,
LOG_LEVEL: "silent",
},
);
stdio: "ignore", // ignore output to keep logs clean
});
cp.on("exit", (code, signal) => {
logger.info(`Worker ${type} (${id}) exited with signal ${signal} (code ${code})`);
+16 -17
View File
@@ -2,16 +2,13 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { NotifkitClient } from "@/client.js";
/**
* Stubs `fetch` with a minimal Response double. `status` drives `ok` unless it
* is given explicitly, which lets the error-path tests cover a 4xx that still
* carries a JSON body.
* Stubs `fetch` with a real `Response` carrying `body` as JSON, so the client
* reads it the way it reads a server's. The request/response round trip itself
* is covered against a running server in tests/integration/api.test.ts; these
* pin down what the client puts on the wire.
*/
function stubFetch(body: unknown, status = 200) {
const fn = vi.fn().mockResolvedValue({
status,
ok: status >= 200 && status < 300,
json: async () => body,
});
const fn = vi.fn().mockImplementation(async () => new Response(JSON.stringify(body), { status }));
vi.stubGlobal("fetch", fn);
return fn;
}
@@ -76,18 +73,20 @@ describe("NotifkitClient", () => {
});
it("returns undefined on 204 without parsing a body", async () => {
const fetchMock = vi.fn().mockResolvedValue({
status: 204,
ok: true,
json: async () => {
throw new Error("204 responses have no body to parse");
},
});
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
await expect(client().deleteUser("usr_1")).resolves.toBeUndefined();
});
it("reports the status when an error response is not JSON", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response("<html>Bad Gateway</html>", { status: 502 })),
);
await expect(client().addUser({ id: "usr_1" })).rejects.toThrow(
"Request failed with status 502",
);
});
it("sends no body when none is supplied", async () => {
const fetchMock = stubFetch({ workflows: [] });
await client().listWorkflows();
+209
View File
@@ -0,0 +1,209 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { randomUUID } from "node:crypto";
import { MockLanguageModelV4 } from "ai/test";
import { APICallError } from "ai";
import { STREAMS } from "@/contracts/index.js";
import { useInfra, waitFor, settle } from "./support/infra.js";
import { RecordingTransport, startNotifkit, type Running, type Api } from "./support/pipeline.js";
/**
* AI-generated content, end to end. The language model is the one stand-in:
* it is an external paid API, so a local model records every prompt it is
* sent and answers deterministically. Everything else — engine, AI worker,
* delivery, Redis, Postgres — is real.
*/
const infra = useInfra();
let app: Running;
const email = new RecordingTransport("email");
const prompts: string[] = [];
let failNext: Error | null = null;
const model = new MockLanguageModelV4({
doGenerate: async (opts: any) => {
const prompt = opts.prompt.flatMap((m: any) => m.content.map((c: any) => c.text)).join("");
prompts.push(prompt);
if (failNext) {
const err = failNext;
failNext = null;
throw err;
}
return {
content: [{ type: "text", text: `AI(${prompt})` }],
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 1, text: 1, reasoning: 0 },
},
warnings: [],
} as any;
},
});
beforeAll(async () => {
app = await startNotifkit(infra, {
services: ["api", "enricher", "engine", "ai", "scheduler", "delivery", "events"],
providers: [email],
aiModel: model as any,
env: { RATE_LIMIT_PER_HOUR: "100000" },
});
}, 120_000);
afterAll(async () => {
await app?.stop();
}, 60_000);
async function project() {
const p = await app.project();
await p.api("PUT", "/v1/templates", {
templates: [
{ id: "digest", channel: "email", content: { subject: "Digest", text: "{{summary}}" } },
{
id: "smart",
channel: "email",
content: { subject: "{{headline}}" },
aiPrompts: { headline: "Write a headline about {{topic}}" },
},
],
});
return p;
}
async function notify(api: Api, body: Record<string, unknown>) {
const res = await api("POST", "/v1/notify", body);
expect(res.status).toBe(202);
}
describe("AI-generated notifications", () => {
it("generates each prompt with the request's data and sends the result", async () => {
const p = await project();
await p.api("POST", "/v1/users", { id: "u", email: "u@x.com" });
const marker = randomUUID().slice(0, 8);
await notify(p.api, {
user: "u",
template: "digest",
data: { name: marker },
aiPrompts: { summary: "Summarise the week for {{name}}" },
});
await waitFor("delivered", () => email.for(p.id).length === 1);
expect(prompts).toContain(`Summarise the week for ${marker}`);
expect(email.for(p.id)[0]!.renderedContent.content).toEqual({
subject: "Digest",
text: `AI(Summarise the week for ${marker})`,
});
expect(email.for(p.id)[0]!.destination).toBe("u@x.com");
});
it("uses the template's own prompts", async () => {
const p = await project();
await p.api("POST", "/v1/users", { id: "u", email: "u@x.com" });
const topic = randomUUID().slice(0, 8);
await notify(p.api, { user: "u", template: "smart", data: { topic } });
await waitFor("delivered", () => email.for(p.id).length === 1);
expect(email.for(p.id)[0]!.renderedContent.content).toEqual({
subject: `AI(Write a headline about ${topic})`,
});
});
it("generates once per address, sending each address its own copy", async () => {
const p = await project();
await p.api("POST", "/v1/users", { id: "u", email: ["a@x.com", "b@x.com"] });
await notify(p.api, { user: "u", template: "digest", aiPrompts: { summary: "hi" } });
await waitFor("both delivered", () => email.for(p.id).length === 2);
expect(
email
.for(p.id)
.map((t) => t.destination)
.sort(),
).toEqual(["a@x.com", "b@x.com"]);
});
it("never sends AI mail to a suppressed address", async () => {
const p = await project();
await p.api("POST", "/v1/suppressions", { channel: "email", target: "gone@x.com" });
await p.api("POST", "/v1/users", { id: "u", email: "gone@x.com" });
await p.api("POST", "/v1/users", { id: "control", email: "control@x.com" });
await notify(p.api, { user: "u", template: "digest", aiPrompts: { summary: "hi" } });
await notify(p.api, { user: "control", template: "digest", aiPrompts: { summary: "hi" } });
await waitFor("control delivered", () => email.for(p.id).length === 1);
await settle(1_000);
expect(email.for(p.id).map((t) => t.destination)).toEqual(["control@x.com"]);
});
it("sends once when an AI task is replayed after a crash lost its completion marker", async () => {
const p = await project();
await p.api("POST", "/v1/users", { id: "u", email: "u@x.com" });
await notify(p.api, { user: "u", template: "digest", aiPrompts: { summary: "once" } });
await waitFor("delivered", () => email.for(p.id).length === 1);
// A crash between dispatching and recording completion: the marker never
// landed, and the pending entry is replayed.
const [entry] = (await infra.redis.xrange(STREAMS.AI_PENDING, "-", "+")).filter(([, f]) =>
f[1]!.includes(p.id),
);
for (const key of await infra.redis.keys("notif:processed:ai:*")) await infra.redis.del(key);
await infra.redis.xadd(STREAMS.AI_PENDING, "*", "data", entry![1][1]!);
await settle(3_000);
expect(email.for(p.id)).toHaveLength(1);
});
it("fails a notification once, without retrying, when the model rejects the request", async () => {
const p = await project();
await p.api("POST", "/v1/users", { id: "u", email: "u@x.com" });
const failures: unknown[][] = [];
const on = (...a: unknown[]) => failures.push(a);
app.server.on("notification:failed", on);
try {
const marker = `reject-${randomUUID().slice(0, 8)}`;
failNext = new APICallError({
message: "invalid prompt",
url: "https://model",
requestBodyValues: {},
statusCode: 400,
});
await notify(p.api, { user: "u", template: "digest", aiPrompts: { summary: marker } });
await waitFor("failure reported", () => failures.length > 0);
await settle(1_000);
expect(prompts.filter((pr) => pr === marker)).toHaveLength(1);
expect(email.for(p.id)).toEqual([]);
} finally {
app.server.off("notification:failed", on);
}
});
it("caps the number of prompts run for one notification", async () => {
const p = await project();
await p.api("POST", "/v1/users", { id: "u", email: "u@x.com" });
const tag = randomUUID().slice(0, 8);
const aiPrompts = Object.fromEntries(
Array.from({ length: 8 }, (_, i) => [`k${i}`, `${tag}-${i}`]),
);
await notify(p.api, { user: "u", template: "digest", aiPrompts });
await waitFor("delivered", () => email.for(p.id).length === 1);
expect(prompts.filter((pr) => pr.startsWith(tag))).toHaveLength(5);
});
it("holds a scheduled AI notification until its time", async () => {
const p = await project();
await p.api("POST", "/v1/users", { id: "u", email: "u@x.com" });
await notify(p.api, {
user: "u",
template: "digest",
aiPrompts: { summary: "later" },
sendAt: new Date(Date.now() + 3_000).toISOString(),
});
await waitFor(
"parked",
async () => (await p.api("GET", "/v1/notifications/scheduled")).body.scheduled?.length === 1,
);
expect(email.for(p.id)).toEqual([]);
await waitFor("sent", () => email.for(p.id).length === 1, 20_000);
}, 30_000);
});
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { randomUUID } from "node:crypto";
import { DeliveryWorker } from "@/services/delivery/main.js";
import { StreamConsumer, PendingMessageScanner, StreamProducer } from "@/queue/index.js";
import { IdempotencyGuard } from "@/idempotency/index.js";
import { ContactRepository, TemplateRepository, UserRepository } from "@/repositories/index.js";
import { TemplateCache } from "@/templates/index.js";
import { transportRegistry, type Transport, type DeliveryResult } from "@/transport/index.js";
import { STREAMS, CONSUMER_GROUPS, OUTBOUND_STREAMS, buildStreamEvent } from "@/contracts/index.js";
import { createPriorityProducers } from "@/workers/bootstrap.js";
import { globalEmitter } from "@/shared/index.js";
import { createLogger } from "@/logger/index.js";
import { useInfra, waitFor, settle } from "./support/infra.js";
/**
* The delivery worker on its own: real streams, real idempotency leases,
* real Postgres for the outbox and contacts, and transports that behave like
* providers do when they are slow, down, or rejecting.
*/
const infra = useInfra();
const logger = createLogger({ name: "test", level: "silent" });
let project: string;
const workers: DeliveryWorker[] = [];
beforeEach(async () => {
await infra.reset();
project = randomUUID();
await infra.sql`INSERT INTO projects (id, name) VALUES (${project}, 'p')`;
});
afterEach(async () => {
await Promise.all(workers.splice(0).map((w) => w.stop()));
});
class FakeProvider implements Transport {
calls = 0;
constructor(
readonly channel: any,
private readonly behaviour: (n: number) => Promise<DeliveryResult> | DeliveryResult,
readonly limits?: { limit: number; windowSeconds: number },
) {}
async send(): Promise<DeliveryResult> {
return this.behaviour(++this.calls);
}
}
function registry(...transports: Transport[]) {
const r = new (transportRegistry.constructor as any)();
transports.forEach((t, i) => r.register(t, transports.length - i));
return r;
}
function worker(reg: any, name = "d1") {
const consumer = new StreamConsumer({
redis: infra.redis,
stream: [...OUTBOUND_STREAMS],
group: CONSUMER_GROUPS.DELIVERY,
consumer: name,
dlqStream: STREAMS.DEAD_LETTER,
blockMs: 100,
logger,
});
const pendingScanner = new PendingMessageScanner({
redis: infra.redis,
stream: [...OUTBOUND_STREAMS],
group: CONSUMER_GROUPS.DELIVERY,
consumer: name,
logger,
});
const w = new DeliveryWorker({
consumer,
pendingScanner,
logger,
concurrency: 10,
transportRegistry: reg,
idempotency: new IdempotencyGuard({
redis: infra.redis,
keyPrefix: "notif:processed:delivery",
}),
redis: infra.redis,
scheduledProducer: new StreamProducer({
redis: infra.redis,
stream: STREAMS.SCHEDULED,
logger,
maxLen: 1000,
}),
enrichedProducers: createPriorityProducers(infra.redis, logger, "ENRICHED"),
contactRepo: new ContactRepository(infra.db),
eventsProducer: new StreamProducer({
redis: infra.redis,
stream: STREAMS.EVENTS_INBOUND,
logger,
maxLen: 1000,
}),
globalEmitter,
db: infra.db,
templateCache: new TemplateCache(new TemplateRepository(infra.db)),
});
workers.push(w);
return w;
}
function dispatched(overrides: Record<string, unknown> = {}) {
return {
...buildStreamEvent(
"notification.dispatched",
{
projectId: project,
taskId: `task-${randomUUID()}`,
enrichedEventId: randomUUID(),
recipientId: "u",
channel: "email",
priority: "normal",
templateVariables: {},
renderedContent: { content: { subject: "s" } },
destination: "u@x.com",
recipient: {
id: "u",
email: "u@x.com",
locale: "en",
timezone: "UTC",
preferences: { optedOut: false, channels: [] },
},
deliveryOptions: { maxAttempts: 3, timeoutMs: 500 },
...overrides,
},
"engine",
),
id: randomUUID(),
timestamp: new Date().toISOString(),
};
}
async function publish(ev: any) {
await infra.redis.xadd(STREAMS.OUTBOUND_NORMAL, "*", "data", JSON.stringify(ev));
}
const events = async () =>
(await infra.redis.xrange(STREAMS.EVENTS_INBOUND, "-", "+")).map(([, f]) => JSON.parse(f[1]!));
const parked = async () =>
(await infra.sql`SELECT task_id, payload FROM scheduled_payloads ORDER BY task_id`).map(
(r) => r.payload as any,
);
const dlq = async () =>
(await infra.redis.xrange(STREAMS.DEAD_LETTER, "-", "+")).map(([, f]) => JSON.parse(f[1]!));
describe("DeliveryWorker", () => {
it("parks a send the provider timed out on for a later attempt, instead of failing it", async () => {
const hang = new FakeProvider("email", () => new Promise(() => {}));
const w = worker(registry(hang));
const ev = dispatched();
await w.processInline(ev as any);
await w.stop();
expect(await dlq()).toEqual([]);
expect((await events()).some((e) => e.type === "notification.failed")).toBe(false);
const [retry] = await parked();
expect(retry).toMatchObject({ taskId: ev.payload.taskId, deliveryAttemptCount: 1 });
// Backed off by at least the circuit breaker's reset time.
expect(Date.parse(retry.scheduledAt)).toBeGreaterThanOrEqual(Date.now() + 25_000);
// Parked as it arrived: no rendered body or abort signal carried along.
expect(retry.signal).toBeUndefined();
expect(await infra.redis.xlen(STREAMS.SCHEDULED)).toBe(1);
// The attempt is still in the log, as dispatched.
expect((await events()).filter((e) => e.type === "notification.dispatched")).toHaveLength(1);
// Released, so the re-queued attempt can take the lease.
expect(await infra.redis.exists(`notif:processed:delivery:${ev.payload.taskId}`)).toBe(0);
});
it("delivers on a later attempt once the provider is back", async () => {
const flaky = new FakeProvider("email", (n) =>
n === 1
? { success: false, error: "503 Service Unavailable" }
: { success: true, providerMessageId: "late-1" },
);
const w = worker(registry(flaky));
await w.processInline(dispatched() as any);
// What the scheduler would release when the retry falls due.
const [retry] = await parked();
await w.processInline({
...buildStreamEvent("notification.dispatched", retry, "scheduler"),
id: randomUUID(),
timestamp: new Date().toISOString(),
} as any);
await w.stop();
expect(flaky.calls).toBe(2);
const delivered = (await events()).find((e) => e.type === "notification.delivered");
expect(delivered.payload).toMatchObject({ providerMessageId: "late-1", attempt: 2 });
expect(await dlq()).toEqual([]);
});
it("fails a provider that is still down on the last attempt, and dead-letters the task", async () => {
const hang = new FakeProvider("email", () => new Promise(() => {}));
const w = worker(registry(hang));
await w.start();
const ev = dispatched({ deliveryAttemptCount: 2 });
await publish(ev);
await waitFor("dead-lettered", async () => (await dlq()).length === 1, 5_000);
expect((await dlq())[0].dlq.reason).toMatch(/timeout/i);
await waitFor("failure event", async () =>
(await events()).some((e) => e.type === "notification.failed"),
);
const failed = (await events()).find((e) => e.type === "notification.failed");
expect(failed.payload).toMatchObject({
taskId: ev.payload.taskId,
failureCode: "provider_error",
attempt: 3,
});
expect(await parked()).toEqual([]);
// Released, so a replay from the DLQ can try again.
expect(await infra.redis.exists(`notif:processed:delivery:${ev.payload.taskId}`)).toBe(0);
});
it("fails over to the next provider for the channel, in priority order", async () => {
const primary = new FakeProvider("email", () => ({
success: false,
error: "503 from primary",
}));
const backup = new FakeProvider("email", () => ({
success: true,
providerMessageId: "backup-1",
}));
const w = worker(registry(primary, backup));
await w.start();
const ev = dispatched();
await publish(ev);
await waitFor("delivered", async () =>
(await events()).some((e) => e.type === "notification.delivered"),
);
expect(primary.calls).toBe(1);
expect(backup.calls).toBe(1);
const [outbox] =
await infra.sql`SELECT provider_message_id FROM delivery_outbox WHERE task_id = ${ev.payload.taskId as string}`;
await waitFor(
"outbox",
async () =>
(await infra.sql`SELECT 1 FROM delivery_outbox WHERE provider_message_id = 'backup-1'`)
.length === 1,
);
void outbox;
});
it("does not retry a failure the provider says is permanent", async () => {
const reject = new FakeProvider("email", () => ({
success: false,
error: "invalid recipient",
retryable: false,
}));
await worker(registry(reject)).processInline(dispatched() as any);
expect(reject.calls).toBe(1);
expect(await parked()).toEqual([]);
expect((await dlq())[0].dlq.reason).toBe("invalid recipient");
});
it("stops calling a provider that keeps failing once its circuit opens, and parks what it turned away", async () => {
const down = new FakeProvider("email", () => ({ success: false, error: "down" }));
const w = worker(registry(down));
// One at a time: sends already in flight when the circuit opens still go out.
for (let i = 0; i < 8; i++) await w.processInline(dispatched() as any);
expect(down.calls).toBe(5);
// An open circuit is an outage, not a verdict on the message: nothing is
// dead-lettered, and every task waits for another attempt.
expect(await dlq()).toEqual([]);
const retries = await parked();
expect(retries).toHaveLength(8);
expect(retries.every((r) => r.deliveryAttemptCount === 1)).toBe(true);
});
it("keeps a circuit per provider, even for two providers of the same class", async () => {
const down = new FakeProvider("email", () => ({ success: false, error: "down" }));
const backup = new FakeProvider("email", () => ({ success: true, providerMessageId: "b" }));
const w = worker(registry(down, backup));
for (let i = 0; i < 8; i++) await w.processInline(dispatched() as any);
// The backup's successes are not the primary's: its circuit opens after
// five failures, and every task still goes out through the backup.
expect(down.calls).toBe(5);
expect(backup.calls).toBe(8);
expect(await dlq()).toEqual([]);
});
it("sends a task once when two workers race for it", async () => {
let sends = 0;
const slow = new FakeProvider("email", async () => {
sends++;
await settle(200);
return { success: true, providerMessageId: "p" };
});
const reg = registry(slow);
const ev = dispatched();
await Promise.all([
worker(reg, "a").processInline(ev as any),
worker(reg, "b").processInline(ev as any),
]);
await worker(reg, "c").processInline(ev as any);
expect(sends).toBe(1);
});
it("defers sends over the provider's limit, and fails them once the retries run out", async () => {
const limited = new FakeProvider("sms", () => ({ success: true, providerMessageId: "x" }), {
limit: 1,
windowSeconds: 60,
});
const w = worker(registry(limited));
await w.start();
const first = dispatched({ channel: "sms", destination: "+1" });
const second = dispatched({ channel: "sms", destination: "+2" });
const exhausted = dispatched({ channel: "sms", destination: "+3", throttleAttemptCount: 3 });
for (const ev of [first, second, exhausted]) await publish(ev);
await waitFor("one sent", () => limited.calls === 1);
await waitFor("deferred", async () => (await infra.redis.xlen(STREAMS.SCHEDULED)) === 1);
const [row] = await infra.sql`SELECT payload FROM scheduled_payloads`;
expect(row!.payload.throttleAttemptCount).toBe(1);
expect(Date.parse(row!.payload.scheduledAt)).toBeGreaterThan(Date.now());
await waitFor("exhausted one failed", async () =>
(await events()).some(
(e) =>
e.type === "notification.failed" &&
e.payload.failureCode === "provider_throttle_exceeded",
),
);
expect(limited.calls).toBe(1);
});
describe("provider rate limits are per provider, not per channel", () => {
const ok = () => ({ success: true, providerMessageId: "x" });
it("sends through the backup once the primary's limit is spent", async () => {
const primary = new FakeProvider("email", ok, { limit: 1, windowSeconds: 60 });
const backup = new FakeProvider("email", ok, { limit: 5, windowSeconds: 60 });
const w = worker(registry(primary, backup));
for (let i = 0; i < 3; i++) await w.processInline(dispatched() as any);
expect(primary.calls).toBe(1);
expect(backup.calls).toBe(2);
expect(await parked()).toEqual([]);
});
it("uses an unlimited backup behind a limited primary", async () => {
const primary = new FakeProvider("email", ok, { limit: 1, windowSeconds: 60 });
const backup = new FakeProvider("email", ok);
const w = worker(registry(primary, backup));
for (let i = 0; i < 3; i++) await w.processInline(dispatched() as any);
expect([primary.calls, backup.calls]).toEqual([1, 2]);
});
it("defers a send only once every provider for the channel is at its limit", async () => {
const primary = new FakeProvider("email", ok, { limit: 1, windowSeconds: 60 });
const backup = new FakeProvider("email", ok, { limit: 1, windowSeconds: 60 });
const w = worker(registry(primary, backup));
for (let i = 0; i < 3; i++) await w.processInline(dispatched() as any);
expect([primary.calls, backup.calls]).toEqual([1, 1]);
const deferred = await parked();
expect(deferred).toHaveLength(1);
expect(deferred[0].throttleAttemptCount).toBe(1);
});
});
it("dead-letters a task whose template was deleted after it was dispatched", async () => {
const ok = new FakeProvider("email", () => ({ success: true, providerMessageId: "x" }));
const w = worker(registry(ok));
await w.start();
await publish(dispatched({ renderedContent: undefined, templateId: "gone" }));
await waitFor("dead-lettered", async () => (await dlq()).length === 1);
expect((await dlq())[0].dlq.reason).toMatch(/template "gone" not found/);
expect(ok.calls).toBe(0);
});
it("renders an unrendered task from its template at send time", async () => {
let seen: any;
const capture = new FakeProvider("email", () => ({ success: true, providerMessageId: "x" }));
(capture as any).send = async (task: any) => {
seen = task.renderedContent;
return { success: true, providerMessageId: "x" };
};
await new TemplateRepository(infra.db).upsertMany(project, [
{ id: "t", channel: "email", content: { subject: "Hi {{n}}" } },
]);
await worker(registry(capture)).processInline(
dispatched({
renderedContent: undefined,
templateId: "t",
templateVariables: { n: "Bo" },
}) as any,
);
expect(seen).toEqual({ content: { subject: "Hi Bo" } });
});
it("with no provider for the channel, falls back along the chain or records the failure", async () => {
const w = worker(registry());
await w.processInline(dispatched({ channel: "sms", fallbackChain: ["email"] }) as any);
const enriched = await infra.redis.xrange(STREAMS.ENRICHED_NORMAL, "-", "+");
const next = JSON.parse(enriched[0]![1][1]!).payload;
expect(next.channel).toBe("email");
expect(next.fallbackChain).toBeUndefined();
await w.processInline(dispatched({ channel: "sms" }) as any);
await w.stop();
const failed = (await events()).find((e) => e.type === "notification.failed");
expect(failed.payload.failureCode).toBe("no_transport");
});
describe("push", () => {
async function pushUser(token: string) {
await new UserRepository(infra.db).upsertFull(project, {
userId: "u",
segments: [],
preferences: {},
});
await new ContactRepository(infra.db).upsert(project, "u", "push", token);
}
it("deactivates a dead token and falls back to the next channel", async () => {
await pushUser("dead");
const push = new FakeProvider("push", () => ({ success: false, invalidToken: true }));
await worker(registry(push)).processInline(
dispatched({ channel: "push", destination: "dead", fallbackChain: ["email"] }) as any,
);
const [contact] = await new ContactRepository(infra.db).findByUserId(project, "u");
expect(contact!.active).toBe(false);
expect(await infra.redis.xrange(STREAMS.ENRICHED_NORMAL, "-", "+")).toHaveLength(1);
});
it("retries a push that failed, and dead-letters it once out of attempts", async () => {
await pushUser("tok");
const push = new FakeProvider("push", () => ({ success: false, error: "FCM unavailable" }));
const w = worker(registry(push));
await w.processInline(dispatched({ channel: "push", destination: "tok" }) as any);
expect(await dlq()).toEqual([]);
expect(await parked()).toHaveLength(1);
await w.processInline(
dispatched({ channel: "push", destination: "tok", deliveryAttemptCount: 2 }) as any,
);
expect((await dlq())[0].dlq.reason).toBe("FCM unavailable");
const [contact] = await new ContactRepository(infra.db).findByUserId(project, "u");
expect(contact!.active).toBe(true);
});
it("delivers a push and records the provider's id", async () => {
const push = new FakeProvider("push", () => ({ success: true, providerMessageId: "fcm-1" }));
const w = worker(registry(push));
await w.processInline(dispatched({ channel: "push", destination: "tok" }) as any);
await w.stop();
const delivered = (await events()).find((e) => e.type === "notification.delivered");
expect(delivered.payload).toMatchObject({ providerMessageId: "fcm-1", channel: "push" });
// The dispatch entry rides on the outcome rather than being a second event.
expect(delivered.payload.dispatchedAt).toBeDefined();
});
});
});
+305
View File
@@ -0,0 +1,305 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { randomUUID } from "node:crypto";
import { EventWorker } from "@/services/events/main.js";
import { StreamConsumer, PendingMessageScanner, StreamProducer } from "@/queue/index.js";
import { STREAMS, CONSUMER_GROUPS, buildStreamEvent } from "@/contracts/index.js";
import { insertMessageLogs, upsertOutboxProviderIds } from "@/db/bulk.js";
import { createLogger } from "@/logger/index.js";
import { useInfra, waitFor, settle } from "./support/infra.js";
/**
* The events worker against real streams and a real delivery log: outcome
* events become log rows, and inbound events wake the workflows waiting on
* them.
*/
const infra = useInfra();
const logger = createLogger({ name: "test", level: "silent" });
let project: string;
let worker: EventWorker | undefined;
beforeEach(async () => {
await infra.reset();
project = randomUUID();
});
afterEach(async () => {
await worker?.stop();
worker = undefined;
});
/**
* The worker is slow to make its first read, as it is on a loaded machine
* running the whole suite. A wait that passes before the worker has read
* anything then fails here every time, rather than once in a while.
*/
const FIRST_READ_DELAY_MS = 300;
async function start({ concurrency = 10 } = {}) {
const consumer = new StreamConsumer({
redis: infra.redis,
stream: STREAMS.EVENTS_INBOUND,
group: CONSUMER_GROUPS.EVENTS,
consumer: "e1",
blockMs: 100,
logger,
});
const read = consumer.readBatch.bind(consumer);
const stop = consumer.stop.bind(consumer);
let stopped = false;
consumer.stop = async () => {
stopped = true;
await stop();
};
consumer.readBatch = async function* () {
await settle(FIRST_READ_DELAY_MS);
if (!stopped) yield* read();
};
const pendingScanner = new PendingMessageScanner({
redis: infra.redis,
stream: STREAMS.EVENTS_INBOUND,
group: CONSUMER_GROUPS.EVENTS,
consumer: "e1",
logger,
});
worker = new EventWorker({
consumer,
pendingScanner,
logger,
concurrency,
db: infra.db,
workflowProducer: new StreamProducer({
redis: infra.redis,
stream: STREAMS.WORKFLOW_INBOUND,
logger,
maxLen: 1000,
}),
});
await worker.start();
return worker;
}
async function emit(type: string, payload: Record<string, unknown>) {
const ev = {
...buildStreamEvent(type, payload, "test"),
id: randomUUID(),
timestamp: new Date().toISOString(),
};
await infra.redis.xadd(STREAMS.EVENTS_INBOUND, "*", "data", JSON.stringify(ev));
return ev;
}
/** The events group's XINFO entry: `lag` is entries not yet read, `pending` read but not acked. */
async function group(): Promise<Record<string, unknown> | undefined> {
const groups = (await infra.redis.xinfo("GROUPS", STREAMS.EVENTS_INBOUND)) as unknown[][];
return groups
.map((flat) =>
Object.fromEntries(flat.flatMap((v, i) => (i % 2 ? [] : [[String(v), flat[i + 1]]]))),
)
.find((g) => g.name === CONSUMER_GROUPS.EVENTS);
}
/**
* Every entry on the stream has been read by the group and acked. Pending
* alone is not enough: it is also 0 before the worker has read anything.
*/
const drained = async () => {
const g = await group();
return g?.lag === 0 && g.pending === 0;
};
const resumes = async () =>
(await infra.redis.xrange(STREAMS.WORKFLOW_INBOUND, "-", "+")).map(([, f]) => JSON.parse(f[1]!));
describe("EventWorker: delivery log", () => {
it("writes a dispatched row and an outcome row, in that order, once each", async () => {
await start();
const dispatchedAt = new Date(Date.now() - 5_000).toISOString();
const outcome = {
projectId: project,
taskId: "t1",
channel: "email",
attempt: 1,
providerMessageId: "pm-1",
dispatchedAt,
templateId: "tpl",
campaignId: "c",
};
await emit("notification.delivered", outcome);
await emit("notification.delivered", outcome); // a replay
await emit("notification.failed", {
projectId: project,
taskId: "t2",
channel: "sms",
attempt: 2,
});
await emit("notification.dispatched", { projectId: project, taskId: "t3", channel: "push" });
await waitFor("read and acked", drained);
const rows =
await infra.sql`SELECT task_id, kind, status, attempt, provider_message_id, template_id, campaign_id, "timestamp" FROM message_logs ORDER BY task_id, "timestamp"`;
expect(rows.map((r) => [r.task_id, r.kind, r.status, r.attempt])).toEqual([
["t1", "dispatched", "dispatched", 1],
["t1", "attempt", "delivered", 1],
["t2", "attempt", "failed", 2],
["t3", "dispatched", "dispatched", 1],
]);
expect(new Date(rows[0]!.timestamp).toISOString()).toBe(dispatchedAt);
expect(rows[1]).toMatchObject({
provider_message_id: "pm-1",
template_id: "tpl",
campaign_id: "c",
});
});
it("flushes everything buffered when it stops", async () => {
// Hold rows in the buffer: a flush threshold out of reach (half of 100)
// and no interval flush, so only stopping can write them.
const w = await start({ concurrency: 100 });
clearInterval((w as any).flushInterval);
for (let i = 0; i < 5; i++)
await emit("notification.delivered", {
projectId: project,
taskId: `s${i}`,
channel: "email",
});
await waitFor("all five buffered", () => (w as any).messageLogBuffer.length === 5);
expect(await infra.sql`SELECT 1 FROM message_logs`).toEqual([]);
await w.stop();
worker = undefined;
const [{ n }] = (await infra.sql`SELECT count(*)::int AS n FROM message_logs`) as any;
expect(n).toBe(5);
});
it("ignores event types it has no use for", async () => {
await start();
await emit("something.else", { projectId: project });
await waitFor("read and acked", drained);
expect(await infra.sql`SELECT 1 FROM message_logs`).toEqual([]);
});
});
describe("EventWorker: waking workflows", () => {
async function waitingInstance(
eventName: string,
match: Record<string, unknown>,
expiresInMs = 3600_000,
) {
const [inst] = await infra.sql`
INSERT INTO workflow_instances (project_id, name, status, input) VALUES (${project}, 'wf', 'pending', ${JSON.stringify({ user: { id: "u" } })}::jsonb) RETURNING id`;
const id = inst!.id as string;
await infra.sql`INSERT INTO workflow_steps (project_id, instance_id, step_index, action) VALUES (${project}, ${id}, '0', 'waitForEvent')`;
await infra.sql`INSERT INTO workflow_waiters (project_id, instance_id, event_name, match_criteria, expires_at)
VALUES (${project}, ${id}, ${eventName}, ${JSON.stringify(match)}::jsonb, ${new Date(Date.now() + expiresInMs).toISOString()})`;
return id;
}
it("resumes the instance whose criteria match, recording the event on its step", async () => {
await start();
const alice = await waitingInstance("order.paid", { "customer.id": "alice" });
const bob = await waitingInstance("order.paid", { "customer.id": "bob" });
await emit("event.received", {
projectId: project,
eventName: "order.paid",
payload: { customer: { id: "alice" }, total: 5 },
});
await waitFor("resumed", async () => (await resumes()).length === 1);
const [resume] = await resumes();
expect(resume.type).toBe("workflow.resumed");
expect(resume.payload).toMatchObject({
projectId: project,
instanceId: alice,
name: "wf",
input: { user: { id: "u" } },
reason: "event_matched",
});
const [step] = await infra.sql`SELECT output FROM workflow_steps WHERE instance_id = ${alice}`;
expect(step!.output).toEqual({ customer: { id: "alice" }, total: 5 });
const waiters = await infra.sql`SELECT instance_id FROM workflow_waiters`;
expect(waiters.map((w) => w.instance_id)).toEqual([bob]);
});
it("does not wake anything for an event in another project, of another name, or after the waiter expired", async () => {
await start();
await waitingInstance("order.paid", {});
await waitingInstance("expired", {}, -1_000);
await emit("event.received", { projectId: randomUUID(), eventName: "order.paid", payload: {} });
await emit("event.received", { projectId: project, eventName: "order.refunded", payload: {} });
await emit("event.received", { projectId: project, eventName: "expired", payload: {} });
await emit("event.received", { eventName: "order.paid", payload: {} });
await waitFor("read and acked", drained);
await settle(200);
expect(await resumes()).toEqual([]);
});
it("drops a waiter whose instance no longer exists", async () => {
await start();
const id = await waitingInstance("ping", {});
// Orphan the waiter as a crash between deletes would.
await infra.sql`ALTER TABLE workflow_waiters DISABLE TRIGGER ALL`;
await infra.sql`DELETE FROM workflow_instances WHERE id = ${id}`.catch(() => {});
await infra.sql`ALTER TABLE workflow_waiters ENABLE TRIGGER ALL`;
const orphaned = (await infra.sql`SELECT 1 FROM workflow_waiters`).length;
await emit("event.received", {
projectId: project,
eventName: "ping",
payload: "not an object",
});
await waitFor("read and acked", drained);
expect(await resumes()).toEqual([]);
if (orphaned) expect(await infra.sql`SELECT 1 FROM workflow_waiters`).toEqual([]);
});
});
describe("bulk log writers", () => {
it("insertMessageLogs writes typed rows and skips ones already recorded", async () => {
const row = {
projectId: project,
taskId: "b1",
providerMessageId: null,
channel: "email",
attempt: 1,
kind: "attempt",
status: "delivered",
templateId: null,
workflowInstanceId: null,
campaignId: null,
};
await insertMessageLogs(infra.db, [
row,
{
...row,
taskId: "b2",
workflowInstanceId: randomUUID(),
timestamp: "2020-01-01T00:00:00.000Z",
},
]);
await insertMessageLogs(infra.db, [row]);
await insertMessageLogs(infra.db, []);
const rows = await infra.sql`SELECT task_id, "timestamp" FROM message_logs ORDER BY task_id`;
expect(rows.map((r) => r.task_id)).toEqual(["b1", "b2"]);
expect(new Date(rows[1]!.timestamp).toISOString()).toBe("2020-01-01T00:00:00.000Z");
});
it("upsertOutboxProviderIds records and updates provider ids, even with a task twice in one batch", async () => {
const base = { taskId: "o1", channel: "email", destination: "a@x.com" };
await upsertOutboxProviderIds(infra.db, [{ ...base, providerMessageId: null }]);
await upsertOutboxProviderIds(infra.db, [
{ ...base, providerMessageId: "first" },
{ ...base, providerMessageId: "retry" },
{ taskId: "o2", channel: "sms", destination: "+1", providerMessageId: "s" },
]);
await upsertOutboxProviderIds(infra.db, []);
const rows =
await infra.sql`SELECT task_id, provider_message_id FROM delivery_outbox ORDER BY task_id`;
expect(rows.map((r) => [r.task_id, r.provider_message_id])).toEqual([
["o1", "retry"],
["o2", "s"],
]);
});
});
+440
View File
@@ -0,0 +1,440 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { randomUUID } from "node:crypto";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "../../packages/mcp/src/index.js";
import { NotifkitApi, NotifkitApiError } from "../../packages/mcp/src/client.js";
import { STREAMS } from "@/contracts/index.js";
import { useInfra, waitFor } from "./support/infra.js";
import { ADMIN_KEY, RecordingTransport, startNotifkit, type Running } from "./support/pipeline.js";
/**
* The MCP server as an agent uses it: a real MCP client, connected over the
* SDK's in-memory transport, calls tools; the tools call a running notifkit.
* Every tool is exercised and judged by its effect, not by the URL it built.
*/
const infra = useInfra();
let app: Running;
const email = new RecordingTransport("email");
const telegram = new RecordingTransport("telegram");
const slack = new RecordingTransport("slack");
beforeAll(async () => {
app = await startNotifkit(infra, {
services: ["api", "enricher", "engine", "delivery", "scheduler", "events", "workflow"],
providers: [email, telegram, slack],
env: { RATE_LIMIT_PER_HOUR: "100000" },
});
}, 120_000);
afterAll(async () => {
await app?.stop();
}, 60_000);
async function agent(apiKey: string, projectId?: string) {
const server = createServer({ baseUrl: app.baseUrl, apiKey, projectId });
const client = new Client({ name: "test-agent", version: "1.0.0" });
const [a, b] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(a), client.connect(b)]);
async function call(name: string, args: Record<string, unknown> = {}) {
const res: any = await client.callTool({ name, arguments: args });
const text = res.content[0].text as string;
let body: any = text;
try {
body = JSON.parse(text);
} catch {
// plain text result
}
return { isError: Boolean(res.isError), text, body };
}
return { client, call, close: () => client.close() };
}
async function projectAgent() {
const p = await app.project();
return { ...p, ...(await agent(p.apiKey)) };
}
describe("MCP server", () => {
it("lists all of its tools with input schemas", async () => {
const a = await projectAgent();
const { tools } = await a.client.listTools();
expect(tools).toHaveLength(46);
for (const t of tools) expect(t.inputSchema.type).toBe("object");
await a.close();
});
it("sends to existing users and reports the campaign", async () => {
const a = await projectAgent();
expect(
(
await a.call("upsert_template", {
id: "hi",
channel: "email",
content: { subject: "Hi {{name}}" },
})
).isError,
).toBe(false);
expect(
(await a.call("upsert_user", { id: "u1", email: "u1@x.com", segments: ["beta"] })).isError,
).toBe(false);
const sent = await a.call("send_notification", {
template: "hi",
user: "u1",
data: { name: "Ann" },
campaign: "c-1",
});
expect(sent.isError).toBe(false);
await waitFor("delivered", () => email.for(a.id).length === 1);
expect(email.for(a.id)[0]!.renderedContent.content).toEqual({ subject: "Hi Ann" });
await waitFor(
"stats",
async () =>
(await a.call("get_campaign_stats", { campaign: "c-1" })).body?.totals?.delivered === 1,
);
const campaigns = await a.call("list_campaigns", {});
expect(campaigns.body.campaigns.map((c: any) => c.campaign)).toEqual(["c-1"]);
await a.close();
});
it("send_campaign reaches every distinct address once, on email and on chat channels", async () => {
const a = await projectAgent();
await a.call("upsert_template", {
id: "promo",
channel: "email",
content: { subject: "Sale" },
});
await a.call("upsert_template", { id: "ping", channel: "telegram", content: { text: "Ping" } });
await a.call("upsert_template", { id: "note", channel: "slack", content: { text: "Note" } });
const res = await a.call("send_campaign", {
campaign: "spring",
template: "promo",
emails: ["A@x.com", "a@x.com", "b@x.com"],
});
expect(res.body).toMatchObject({ queued: 2, duplicatesRemoved: 1 });
await waitFor("emails", () => email.for(a.id).length === 2);
expect(
email
.for(a.id)
.map((t) => t.destination)
.sort(),
).toEqual(["a@x.com", "b@x.com"]);
await a.call("send_campaign", {
campaign: "chat",
template: "ping",
channel: "telegram",
recipients: ["-1001", "-1002"],
});
await waitFor("telegram", () => telegram.for(a.id).length === 2, 10_000);
expect(
telegram
.for(a.id)
.map((t) => t.destination)
.sort(),
).toEqual(["-1001", "-1002"]);
await a.call("send_campaign", {
campaign: "room",
template: "note",
channel: "slack",
recipients: ["C123"],
});
await waitFor("slack", () => slack.for(a.id).length === 1, 10_000);
// A user given the same address twice, once as `email` and once as a contact.
const both = await a.call("upsert_user", {
id: "twice",
email: "t@x.com",
contacts: [{ channel: "email", target: "t@x.com" }],
});
expect(both.isError).toBe(false);
expect((await a.call("send_campaign", { campaign: "x", template: "promo" })).isError).toBe(
true,
);
await a.close();
});
it("previews a template exactly as the pipeline renders it", async () => {
const a = await projectAgent();
const content = {
subject: "Re: {{topic}}",
html: "<p>{{body}}</p>",
text: "{{body}} / {{{body}}}",
};
await a.call("upsert_template", { id: "rich", channel: "email", content });
await a.call("upsert_user", { id: "u", email: "u@x.com" });
const data = { topic: "a\r\nBcc: evil", body: "<b>bold</b>" };
const preview = await a.call("preview_template", { id: "rich", data: { ...data, extra: 1 } });
await a.call("send_notification", { template: "rich", user: "u", data });
await waitFor("sent", () => email.for(a.id).length === 1);
expect(preview.body.rendered).toEqual(email.for(a.id)[0]!.renderedContent.content);
expect(preview.body).toMatchObject({ templateId: "rich", channel: "email" });
expect(preview.body.resolvedVariables.sort()).toEqual(["body", "topic"]);
const raw = await a.call("render_template", { content: { text: "{{missing}}!" } });
expect(raw.body).toMatchObject({ rendered: { text: "!" }, unresolvedVariables: ["missing"] });
expect((await a.call("preview_template", {})).isError).toBe(true);
await a.close();
});
it("manages templates", async () => {
const a = await projectAgent();
await a.call("upsert_template", {
id: "t1",
channel: "email",
topic: "news",
content: { subject: "s" },
});
await a.call("upsert_template", { id: "t2", channel: "sms", content: { text: "x" } });
expect(
(await a.call("list_templates", { channel: "sms" })).body.templates.map((t: any) => t.id),
).toEqual(["t2"]);
expect(
(await a.call("list_templates", { topic: "news" })).body.templates.map((t: any) => t.id),
).toEqual(["t1"]);
expect((await a.call("get_template", { id: "t1" })).body.topics).toEqual(["news"]);
expect((await a.call("delete_template", { id: "t1" })).isError).toBe(false);
const gone = await a.call("get_template", { id: "t1" });
expect(gone.isError).toBe(true);
expect(gone.text).toContain("HTTP 404");
await a.close();
});
it("manages users, contacts and preferences", async () => {
const a = await projectAgent();
await a.call("upsert_user", {
id: "u",
email: "u@x.com",
language: "de",
segments: ["vip"],
preferences: { topics: { news: true } },
});
await a.call("update_user", { id: "u", timezone: "Asia/Tokyo" });
await a.call("add_user_contact", { userId: "u", channel: "sms", target: "+1555" });
const user = (await a.call("get_user", { id: "u" })).body;
expect(user).toMatchObject({
userId: "u",
language: "de",
timezone: "Asia/Tokyo",
segments: ["vip"],
});
expect(
(await a.call("get_user_contacts", { userId: "u" })).body.contacts
.map((c: any) => c.target)
.sort(),
).toEqual(["+1555", "u@x.com"]);
expect(
(await a.call("list_users", { segment: "vip" })).body.users.map((u: any) => u.userId),
).toEqual(["u"]);
expect((await a.call("list_segments")).body.segments).toEqual(["vip"]);
await a.call("update_user_preferences", {
userId: "u",
topics: { news: false },
quietHours: [{ start: "22:00", end: "06:00" }],
});
expect((await a.call("get_user_preferences", { userId: "u" })).body).toMatchObject({
topics: { news: false },
quietHours: [{ start: "22:00", end: "06:00" }],
});
await a.call("delete_user_contact", { userId: "u", channel: "sms", target: "+1555" });
expect((await a.call("get_user_contacts", { userId: "u" })).body.contacts).toHaveLength(1);
await a.call("delete_user", { id: "u" });
expect((await a.call("get_user", { id: "u" })).isError).toBe(true);
await a.close();
});
it("manages suppressions", async () => {
const a = await projectAgent();
await a.call("suppress_address", {
channel: "email",
target: "Stop@X.com",
reason: "complained",
});
expect(
(await a.call("list_suppressions", { channel: "email" })).body.suppressions.map(
(s: any) => s.target,
),
).toEqual(["stop@x.com"]);
await a.call("unsuppress_address", { channel: "email", target: "stop@x.com" });
expect((await a.call("list_suppressions", {})).body.suppressions).toEqual([]);
await a.close();
});
it("lists, inspects and cancels scheduled sends, and reads delivery logs", async () => {
const a = await projectAgent();
await a.call("upsert_template", { id: "t", channel: "email", content: { subject: "s" } });
await a.call("upsert_user", { id: "u", email: "u@x.com" });
await a.call("send_notification", {
template: "t",
user: "u",
sendAt: new Date(Date.now() + 3600_000).toISOString(),
});
await a.call("send_notification", { template: "t", user: "u" });
let scheduled: any[] = [];
await waitFor("parked", async () => {
scheduled = (await a.call("list_scheduled", {})).body.scheduled ?? [];
return scheduled.length === 1;
});
expect((await a.call("cancel_notification", { taskId: scheduled[0].taskId })).isError).toBe(
false,
);
expect((await a.call("list_scheduled", {})).body.scheduled).toEqual([]);
await waitFor(
"logged",
async () =>
(await a.call("get_delivery_logs", { status: "delivered" })).body.logs?.length === 1,
);
const log = (await a.call("get_delivery_logs", { status: "delivered" })).body.logs[0];
expect((await a.call("get_notification", { taskId: log.taskId })).body.status).toBe(
"delivered",
);
await a.close();
});
it("defines, runs, inspects and cancels workflows, and ingests events", async () => {
const a = await projectAgent();
await a.call("upsert_template", { id: "t", channel: "email", content: { subject: "s" } });
await a.call("upsert_user", { id: "u", email: "u@x.com" });
await a.call("create_workflow", {
name: "drip",
steps: [
{ action: "notify", payload: { template: "t" } },
{ action: "waitForEvent", event: "clicked", options: { timeout: "1h" } },
],
});
expect(
(await a.call("list_workflows", { search: "dri" })).body.workflows.map((w: any) => w.name),
).toEqual(["drip"]);
const run = (await a.call("trigger_workflow", { name: "drip", user: "u" })).body;
await waitFor(
"waiting",
async () =>
(await a.call("get_workflow_run", { instanceId: run.instanceId })).body.status ===
"pending",
);
await waitFor("first step sent", () => email.for(a.id).length === 1);
expect((await a.call("ingest_event", { name: "unrelated", properties: {} })).isError).toBe(
false,
);
expect((await a.call("cancel_workflow_run", { instanceId: run.instanceId })).isError).toBe(
false,
);
expect((await a.call("get_workflow_run", { instanceId: run.instanceId })).body.status).toBe(
"canceled",
);
await a.close();
});
it("reports system health, metrics and dead letters", async () => {
const a = await projectAgent();
expect((await a.call("get_system_health")).body.status).toBe("healthy");
expect((await a.call("get_system_metrics")).body.deliveryStats).toBeDefined();
const ev = {
id: randomUUID(),
type: "notification.dispatched",
timestamp: new Date().toISOString(),
payload: { projectId: a.id },
metadata: { traceId: "t", source: "s", retryCount: 0 },
dlq: {
originalStream: STREAMS.OUTBOUND_NORMAL,
ackedAt: new Date().toISOString(),
reason: "x",
},
};
const id1 = await infra.redis.xadd(STREAMS.DEAD_LETTER, "*", "data", JSON.stringify(ev));
const id2 = await infra.redis.xadd(
STREAMS.DEAD_LETTER,
"*",
"data",
JSON.stringify({ ...ev, id: randomUUID() }),
);
const dead = (await a.call("get_dead_letters")).body.messages.map((m: any) => m.id);
expect(dead).toEqual(expect.arrayContaining([id1, id2]));
expect((await a.call("delete_dead_letter", { id: id1 })).isError).toBe(false);
expect((await a.call("replay_dead_letter", { id: id2 })).body).toMatchObject({
success: true,
stream: STREAMS.OUTBOUND_NORMAL,
});
await a.close();
});
it("administers projects and keys with the admin key", async () => {
const admin = await agent(ADMIN_KEY);
const created = (await admin.call("create_project", { name: "Via MCP" })).body;
expect(created.apiKey).toMatch(/^nk_live_/);
expect((await admin.call("list_projects")).body.projects.map((p: any) => p.id)).toContain(
created.id,
);
await admin.call("update_project", { id: created.id, throttleLimit: 3 });
const key = (
await admin.call("create_project_key", { projectId: created.id, role: "read_only" })
).body;
expect(
(await admin.call("list_project_keys", { projectId: created.id })).body.keys,
).toHaveLength(2);
expect(
(await admin.call("delete_project_key", { projectId: created.id, keyId: key.id })).isError,
).toBe(false);
expect((await admin.call("delete_project", { id: created.id })).isError).toBe(false);
expect((await admin.call("list_projects")).body.projects.map((p: any) => p.id)).not.toContain(
created.id,
);
await admin.close();
});
it("reports an API failure to the model instead of crashing", async () => {
const bad = await agent("nk_live_not_a_key");
const res = await bad.call("list_templates", {});
expect(res.isError).toBe(true);
expect(res.text).toContain("HTTP 401");
await bad.close();
});
});
describe("NotifkitApi (the MCP server's HTTP client)", () => {
it("talks to a real server: query strings, bodies, 204s and errors", async () => {
const p = await app.project();
const api = new NotifkitApi({ baseUrl: `${app.baseUrl}/`, apiKey: p.apiKey });
await api.put("/v1/templates", {
templates: [
{ id: "a", channel: "email", content: {} },
{ id: "b", channel: "sms", content: {} },
],
});
expect(
(
await api.get<any>("/v1/templates", { channel: "sms", limit: 5, topic: undefined })
).templates.map((t: any) => t.id),
).toEqual(["b"]);
await api.post("/v1/users", { id: "u" });
await api.patch("/v1/users/u", { language: "fr" });
expect(await api.delete("/v1/templates/a")).toBeUndefined();
const err: any = await api.get("/v1/templates/a").catch((e) => e);
expect(err).toBeInstanceOf(NotifkitApiError);
expect(err.status).toBe(404);
expect(err.body).toMatchObject({ error: "template_not_found" });
const admin = new NotifkitApi({ baseUrl: app.baseUrl, apiKey: ADMIN_KEY, projectId: p.id });
expect((await admin.get<any>("/v1/users/u")).language).toBe("fr");
});
});
+3
View File
@@ -0,0 +1,3 @@
import { definePipelineSuite } from "./support/pipeline-cases.js";
definePipelineSuite({ fused: true });
@@ -0,0 +1,3 @@
import { definePipelineSuite } from "./support/pipeline-cases.js";
definePipelineSuite({ fused: false });
+646
View File
@@ -0,0 +1,646 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { createHmac, randomBytes, randomUUID } from "node:crypto";
import { TelegramTransport } from "../../packages/provider-telegram/src/index.js";
import { DiscordTransport } from "../../packages/provider-discord/src/index.js";
import { SlackTransport } from "../../packages/provider-slack/src/index.js";
import { WhatsAppTransport } from "../../packages/provider-whatsapp/src/index.js";
import { ResendTransport } from "../../packages/provider-resend/src/index.js";
import { TwilioTransport } from "../../packages/provider-twilio/src/index.js";
import { ConsoleTransport } from "../../packages/provider-console/src/index.js";
import type { NotificationDispatchedPayload } from "@/contracts/index.js";
import { startFakeInternet, type FakeInternet } from "./support/fake-internet.js";
import { useInfra, waitFor } from "./support/infra.js";
import { startNotifkit, apiClient, type Running } from "./support/pipeline.js";
/**
* Each provider sends through its real code — real fetch, real request
* bodies, real status handling — against a local stand-in for the vendor's
* API. Webhook signatures are computed the way each vendor documents them,
* not by the code under test.
*/
let net: FakeInternet;
beforeAll(async () => {
net = await startFakeInternet();
});
afterAll(async () => {
await net?.close();
});
beforeEach(() => {
net.requests.length = 0;
});
function task(
overrides: Partial<NotificationDispatchedPayload> & { content?: Record<string, unknown> } = {},
): NotificationDispatchedPayload {
const { content, ...rest } = overrides;
return {
projectId: randomUUID(),
taskId: `task-${randomUUID()}`,
enrichedEventId: randomUUID(),
recipientId: "u1",
channel: "email",
priority: "normal",
templateVariables: {},
deliveryOptions: { maxAttempts: 3, timeoutMs: 2_000 },
destination: "dest",
renderedContent: { content: content ?? { subject: "Hello", text: "Body" } },
...rest,
} as NotificationDispatchedPayload;
}
// ─── Telegram ───────────────────────────────────────────────────────────────
describe("TelegramTransport", () => {
const t = new TelegramTransport({ botToken: "123:abc", parseMode: "HTML" });
it("posts the message to the bot API and returns its message id", async () => {
net.on("api.telegram.org", () => ({ json: { ok: true, result: { message_id: 77 } } }));
const r = await t.send(
task({
destination: "-100200",
content: { subject: "Alert", text: "Disk full", disableNotification: true },
}),
);
expect(r).toEqual({ success: true, providerMessageId: "77" });
const [req] = net.requests;
expect(req!.method).toBe("POST");
expect(req!.path).toBe("/bot123:abc/sendMessage");
expect(req!.json()).toEqual({
chat_id: "-100200",
text: "Alert\n\nDisk full",
parse_mode: "HTML",
disable_notification: true,
});
});
it("sends a subject-only template as just the subject", async () => {
net.on("api.telegram.org", () => ({ json: { ok: true, result: { message_id: 1 } } }));
await t.send(task({ content: { subject: "Only a subject" } }));
expect(net.requests[0]!.json().text).toBe("Only a subject");
});
it("marks a blocked bot or vanished chat as a dead destination", async () => {
net.on("api.telegram.org", () => ({
status: 403,
json: { ok: false, error_code: 403, description: "Forbidden: bot was blocked by the user" },
}));
expect(await t.send(task())).toMatchObject({
success: false,
invalidToken: true,
error: "Forbidden: bot was blocked by the user",
});
net.on("api.telegram.org", () => ({
status: 400,
json: { ok: false, error_code: 400, description: "Bad Request: chat not found" },
}));
expect((await t.send(task())).invalidToken).toBe(true);
});
it("treats a rate limit as retryable, not as a dead destination", async () => {
net.on("api.telegram.org", () => ({
status: 429,
json: { ok: false, error_code: 429, description: "Too Many Requests: retry after 5" },
}));
expect(await t.send(task())).toMatchObject({ success: false, invalidToken: false });
});
it("reports a network failure or a non-JSON gateway page as a failed send", async () => {
net.on("api.telegram.org", () => "hang-up");
expect((await t.send(task())).success).toBe(false);
net.on("api.telegram.org", () => ({ status: 502, text: "<html>Bad Gateway</html>" }));
expect((await t.send(task())).success).toBe(false);
});
it("refuses a task with no chat id without calling the API", async () => {
expect((await t.send(task({ destination: undefined }))).success).toBe(false);
expect(net.requests).toEqual([]);
});
});
// ─── Discord ────────────────────────────────────────────────────────────────
describe("DiscordTransport", () => {
const d = new DiscordTransport({ username: "Alerts" });
const hook = "https://discord.com/api/webhooks/1/abc";
it("posts to the webhook, waiting for the created message's id", async () => {
net.on("discord.com", () => ({ json: { id: "msg-9" } }));
const r = await d.send(
task({
destination: hook,
content: { subject: "Deploy", text: "done", avatarUrl: "https://x/a.png" },
}),
);
expect(r).toEqual({ success: true, providerMessageId: "msg-9" });
expect(net.requests[0]!.path).toBe("/api/webhooks/1/abc?wait=true");
expect(net.requests[0]!.json()).toEqual({
content: "**Deploy**\ndone",
username: "Alerts",
avatar_url: "https://x/a.png",
});
});
it("sends a subject-only template as just the subject", async () => {
net.on("discord.com", () => ({ json: { id: "m" } }));
await d.send(task({ destination: hook, content: { subject: "Heads up" } }));
expect(net.requests[0]!.json().content).toBe("**Heads up**");
});
it("refuses to post anywhere but Discord", async () => {
for (const bad of [
"http://discord.com/api/webhooks/1/a",
"https://evil.com/api/webhooks/1/a",
"https://discord.com/other/1",
]) {
expect(await d.send(task({ destination: bad }))).toMatchObject({
success: false,
invalidToken: true,
});
}
expect(net.requests).toEqual([]);
});
it("marks a deleted webhook dead and a server error retryable", async () => {
net.on("discord.com", () => ({
status: 404,
json: { message: "Unknown Webhook", code: 10015 },
}));
expect(await d.send(task({ destination: hook }))).toMatchObject({
success: false,
invalidToken: true,
});
net.on("discord.com", () => ({ status: 500, text: "oops" }));
expect(await d.send(task({ destination: hook }))).toMatchObject({
success: false,
invalidToken: false,
});
});
});
// ─── Slack ──────────────────────────────────────────────────────────────────
describe("SlackTransport", () => {
it("posts to chat.postMessage with the bot token", async () => {
net.on("slack.com", () => ({ json: { ok: true, channel: "C1", ts: "1700.1" } }));
const s = new SlackTransport({ botToken: "xoxb-1" });
const r = await s.send(
task({ destination: "C1", content: { text: "hi", blocks: [{ type: "divider" }] } }),
);
expect(r).toEqual({ success: true, providerMessageId: "C1:1700.1" });
const req = net.requests[0]!;
expect(req.path).toBe("/api/chat.postMessage");
expect(req.headers.authorization).toBe("Bearer xoxb-1");
expect(req.json()).toEqual({ channel: "C1", text: "hi", blocks: [{ type: "divider" }] });
});
it("uses a template's own channel over the recipient's", async () => {
net.on("slack.com", () => ({ json: { ok: true, ts: "1" } }));
await new SlackTransport({ token: "xoxb" }).send(
task({ destination: "D-user", content: { channel: "C-alerts", subject: "S" } }),
);
expect(net.requests[0]!.json()).toMatchObject({ channel: "C-alerts", text: "S" });
});
it("marks an archived channel dead, but not a rate limit", async () => {
const s = new SlackTransport({ botToken: "xoxb" });
net.on("slack.com", () => ({ json: { ok: false, error: "is_archived" } }));
expect(await s.send(task({ destination: "C" }))).toMatchObject({
success: false,
invalidToken: true,
});
net.on("slack.com", () => ({ json: { ok: false, error: "ratelimited" } }));
expect(await s.send(task({ destination: "C" }))).toMatchObject({
success: false,
invalidToken: false,
});
});
it("posts to an incoming webhook, and reads its plain-text errors", async () => {
const s = new SlackTransport({ webhookUrl: "https://hooks.slack.com/services/T/B/x" });
net.on("hooks.slack.com", () => ({ text: "ok" }));
expect(
await s.send(task({ destination: undefined, content: { text: "hello" } })),
).toMatchObject({ success: true });
expect(net.requests[0]!.json()).toEqual({ text: "hello" });
net.on("hooks.slack.com", () => ({ status: 404, text: "channel_not_found" }));
expect(await s.send(task({ destination: undefined }))).toMatchObject({
success: false,
invalidToken: true,
});
net.on("hooks.slack.com", () => "hang-up");
expect((await s.send(task({ destination: undefined }))).success).toBe(false);
});
it("explains, rather than calls Slack, when it has no way to send", async () => {
expect((await new SlackTransport().send(task({ destination: undefined }))).error).toMatch(
/No destination/,
);
expect((await new SlackTransport().send(task({ destination: "C1" }))).error).toMatch(
/no botToken/,
);
expect(net.requests).toEqual([]);
});
});
// ─── WhatsApp ───────────────────────────────────────────────────────────────
describe("WhatsAppTransport", () => {
const w = new WhatsAppTransport({
phoneNumberId: "PN1",
accessToken: "tok",
verifyToken: "vt",
appSecret: "shh",
});
it("sends a text message to the digits of the number", async () => {
net.on("graph.facebook.com", () => ({ json: { messages: [{ id: "wamid.1" }] } }));
const r = await w.send(
task({ destination: "+1 (555) 010-0200", content: { text: "Your code is 1" } }),
);
expect(r).toEqual({ success: true, providerMessageId: "wamid.1" });
const req = net.requests[0]!;
expect(req.path).toBe("/v21.0/PN1/messages");
expect(req.headers.authorization).toBe("Bearer tok");
expect(req.json()).toEqual({
messaging_product: "whatsapp",
to: "15550100200",
type: "text",
text: { body: "Your code is 1" },
});
});
it("flags an expired access token, and gives up on a hung API within the task's timeout", async () => {
net.on("graph.facebook.com", () => ({
status: 401,
json: { error: { code: 190, message: "Session expired" } },
}));
expect(await w.send(task({ destination: "+1555", content: { text: "x" } }))).toMatchObject({
success: false,
invalidToken: true,
error: "Session expired",
});
net.on("graph.facebook.com", () => ({ delayMs: 3_000, json: {} }));
const started = Date.now();
const r = await w.send(
task({
destination: "+1555",
content: { text: "x" },
deliveryOptions: { maxAttempts: 3, timeoutMs: 300 },
}),
);
expect(r.success).toBe(false);
expect(Date.now() - started).toBeLessThan(2_000);
});
it("answers Meta's verification handshake only with the right token", () => {
expect(
w.verifyWebhookChallenge(
new URLSearchParams("hub.mode=subscribe&hub.verify_token=vt&hub.challenge=42"),
),
).toBe("42");
expect(
w.verifyWebhookChallenge(
new URLSearchParams("hub.mode=subscribe&hub.verify_token=no&hub.challenge=42"),
),
).toBeUndefined();
});
it("accepts only webhooks signed with the app secret", () => {
const raw = JSON.stringify({ entry: [] });
const sig = `sha256=${createHmac("sha256", "shh").update(raw).digest("hex")}`;
expect(w.verifyWebhook(raw, { "x-hub-signature-256": sig })).toBe(true);
expect(w.verifyWebhook(raw + " ", { "x-hub-signature-256": sig })).toBe(false);
expect(w.verifyWebhook(raw, {})).toBe(false);
expect(
new WhatsAppTransport({ phoneNumberId: "p", accessToken: "t" }).verifyWebhook(raw, {
"x-hub-signature-256": sig,
}),
).toBe(false);
});
it("maps read and failed statuses, ignoring the rest", async () => {
const events = await w.parseWebhook({
entry: [
{
changes: [
{
value: {
statuses: [
{ id: "a", status: "read", recipient_id: "1555", timestamp: "1700000000" },
{ id: "b", status: "failed", recipient_id: "1666", errors: [{ code: 131026 }] },
{ id: "c", status: "delivered" },
],
},
},
],
},
],
});
expect(events.map((e) => [e.providerMessageId, e.status, e.recipient, e.bounceType])).toEqual([
["a", "opened", "1555", undefined],
["b", "bounced", "1666", undefined],
]);
});
});
// ─── Resend ─────────────────────────────────────────────────────────────────
/** Signs a payload the way Resend (via Svix) does. */
function svixSign(secret: string, id: string, timestamp: number, payload: string) {
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const sig = createHmac("sha256", key).update(`${id}.${timestamp}.${payload}`).digest("base64");
return { "svix-id": id, "svix-timestamp": String(timestamp), "svix-signature": `v1,${sig}` };
}
describe("ResendTransport", () => {
const secret = `whsec_${randomBytes(24).toString("base64")}`;
const r = new ResendTransport({
apiKey: "re_test",
from: "Acme <no-reply@acme.test>",
webhookSecret: secret,
});
it("sends the rendered email with its headers and returns Resend's id", async () => {
net.on("api.resend.com", () => ({ json: { id: "re-1" } }));
const res = await r.send(
task({
destination: "u@x.com",
content: {
subject: "Hi",
html: "<b>Hi</b>",
text: "Hi",
from: "Sales <sales@acme.test>",
replyTo: "help@acme.test",
},
deliveryOptions: {
maxAttempts: 3,
timeoutMs: 1000,
headers: { "List-Unsubscribe": "<https://u>" },
},
}),
);
expect(res).toEqual({ success: true, providerMessageId: "re-1" });
const req = net.requests[0]!;
expect(req.path).toBe("/emails");
expect(req.headers.authorization).toBe("Bearer re_test");
expect(req.json()).toMatchObject({
from: "Sales <sales@acme.test>",
to: "u@x.com",
subject: "Hi",
html: "<b>Hi</b>",
text: "Hi",
reply_to: "help@acme.test",
headers: { "List-Unsubscribe": "<https://u>" },
});
});
it("escapes a text-only body when it builds the HTML part", async () => {
net.on("api.resend.com", () => ({ json: { id: "re-2" } }));
await r.send(
task({
destination: "u@x.com",
content: { subject: "S", text: 'Hi <img src=x onerror="alert(1)">' },
}),
);
const html = net.requests[0]!.json().html as string;
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
});
it("reports Resend's rejection", async () => {
net.on("api.resend.com", () => ({
status: 422,
json: { statusCode: 422, name: "validation_error", message: "Invalid `to` field" },
}));
expect(await r.send(task({ destination: "nope" }))).toMatchObject({
success: false,
error: "Invalid `to` field",
});
});
it("verifies Svix-signed webhooks and maps bounces, complaints, opens and clicks", async () => {
const now = Math.floor(Date.now() / 1000);
const payload = JSON.stringify({
type: "email.bounced",
created_at: new Date().toISOString(),
data: {
email_id: "re-1",
to: ["u@x.com"],
bounce: { type: "Permanent", message: "no such user" },
},
});
const headers = svixSign(secret, "msg_1", now, payload);
expect(r.verifyWebhook(payload, headers)).toBe(true);
const [bounce] = await r.parseWebhook(JSON.parse(payload), payload, headers);
expect(bounce).toMatchObject({
providerMessageId: "re-1",
status: "bounced",
bounceType: "hard",
recipient: "u@x.com",
});
const click = JSON.stringify({
type: "email.clicked",
data: { email_id: "re-2", to: ["u@x.com"], click: { link: "https://acme.test" } },
});
const [c] = await r.parseWebhook(null, click, svixSign(secret, "msg_2", now, click));
expect(c).toMatchObject({ status: "clicked", metadata: { url: "https://acme.test" } });
const delivered = JSON.stringify({ type: "email.delivered", data: { email_id: "re-3" } });
expect(
await r.parseWebhook(null, delivered, svixSign(secret, "msg_3", now, delivered)),
).toEqual([]);
});
it("rejects a forged, replayed-from-long-ago or unsigned webhook", async () => {
const payload = JSON.stringify({
type: "email.complained",
data: { email_id: "re-1", to: ["u@x.com"] },
});
const forged = svixSign(
`whsec_${randomBytes(24).toString("base64")}`,
"m",
Math.floor(Date.now() / 1000),
payload,
);
expect(r.verifyWebhook(payload, forged)).toBe(false);
expect(
r.verifyWebhook(
payload,
svixSign(secret, "m", Math.floor(Date.now() / 1000) - 3600, payload),
),
).toBe(false);
expect(r.verifyWebhook(payload, {})).toBe(false);
expect(await r.parseWebhook(JSON.parse(payload), payload, forged)).toEqual([]);
expect(
new ResendTransport({ apiKey: "k", from: "f" }).verifyWebhook(
payload,
svixSign(secret, "m", Math.floor(Date.now() / 1000), payload),
),
).toBe(false);
});
});
// ─── Twilio (webhook side; sending goes through Twilio's own HTTPS client) ──
/** Twilio's documented request signature: HMAC-SHA1 over URL + sorted params. */
function twilioSign(authToken: string, url: string, params: Record<string, string>) {
const data =
url +
Object.keys(params)
.sort()
.map((k) => k + params[k])
.join("");
return createHmac("sha1", authToken).update(data).digest("base64");
}
describe("TwilioTransport webhooks", () => {
const url = "https://api.acme.test/webhooks/twilio";
const t = new TwilioTransport({
accountSid: "AC1",
authToken: "secret",
from: "+1000",
statusCallbackUrl: url,
});
it("mounts at the callback URL's path", () => {
expect(t.webhookPath).toBe("/webhooks/twilio");
expect(
() =>
new TwilioTransport({
accountSid: "a",
authToken: "b",
from: "c",
statusCallbackUrl: "not a url",
}),
).toThrow();
});
it("verifies Twilio's signature over every posted field", async () => {
const params = {
MessageSid: "SM1",
MessageStatus: "undelivered",
To: "+1555",
ErrorCode: "30005",
NewField: "x",
};
const raw = new URLSearchParams(params).toString();
const sig = twilioSign("secret", url, params);
expect(await t.verifyWebhook(raw, { "x-twilio-signature": sig })).toBe(true);
expect(
await t.verifyWebhook(raw.replace("30005", "30006"), { "x-twilio-signature": sig }),
).toBe(false);
expect(await t.verifyWebhook(raw, {})).toBe(false);
const [e] = await t.parseWebhook({}, raw, { "x-twilio-signature": sig });
expect(e).toMatchObject({
providerMessageId: "SM1",
status: "bounced",
bounceType: "hard",
recipient: "+1555",
});
});
it("treats a STOP reply as an unsubscribe and ignores progress reports", async () => {
const stop = { MessageSid: "SM2", MessageStatus: "failed", To: "+1555", ErrorCode: "21610" };
const raw = new URLSearchParams(stop).toString();
const [e] = await t.parseWebhook({}, raw, {
"x-twilio-signature": twilioSign("secret", url, stop),
});
expect(e!.status).toBe("unsubscribed");
const sent = { MessageSid: "SM3", MessageStatus: "sent" };
const raw2 = new URLSearchParams(sent).toString();
expect(
await t.parseWebhook({}, raw2, { "x-twilio-signature": twilioSign("secret", url, sent) }),
).toEqual([]);
});
it("refuses to send without a destination or a body", async () => {
expect((await t.send(task({ destination: undefined }))).success).toBe(false);
expect((await t.send(task({ destination: "+1", content: { subject: "only" } }))).success).toBe(
false,
);
});
});
describe("ConsoleTransport", () => {
it("delivers after the configured latency and returns a message id", async () => {
const c = new ConsoleTransport({ channel: "sms", latencyMs: "50-60" });
const started = Date.now();
const r = await c.send(task());
expect(Date.now() - started).toBeGreaterThanOrEqual(45);
expect(r.success).toBe(true);
expect(r.providerMessageId).toMatch(/^console-/);
expect(new ConsoleTransport().channel).toBe("push");
});
});
// ─── A provider end to end ─────────────────────────────────────────────────
describe("Resend inside notifkit", () => {
const infra = useInfra();
let app: Running;
const secret = `whsec_${randomBytes(24).toString("base64")}`;
beforeAll(async () => {
app = await startNotifkit(infra, {
services: ["api", "enricher", "engine", "delivery", "events"],
providers: [
new ResendTransport({
apiKey: "re_live",
from: "no-reply@acme.test",
webhookSecret: secret,
}),
],
env: { RATE_LIMIT_PER_HOUR: "100000" },
});
}, 120_000);
afterAll(async () => {
await app?.stop();
}, 60_000);
it("delivers through Resend's API, then suppresses the address when Resend reports a hard bounce", async () => {
let n = 0;
net.on("api.resend.com", () => ({ json: { id: `re-${++n}` } }));
const p = await app.project();
await p.api("PUT", "/v1/templates", {
templates: [
{ id: "t", channel: "email", content: { subject: "Hi {{name}}", text: "Hello" } },
],
});
await p.api("POST", "/v1/users", { id: "u", email: "gone@x.com" });
await p.api("POST", "/v1/notify", { user: "u", template: "t", data: { name: "Al" } });
await waitFor("sent to Resend", () => net.requests.some((r) => r.host === "api.resend.com"));
const sent = net.requests.find((r) => r.host === "api.resend.com")!.json();
expect(sent).toMatchObject({ to: "gone@x.com", subject: "Hi Al", from: "no-reply@acme.test" });
await waitFor("logged", async () => {
const rows =
await infra.sql`SELECT 1 FROM message_logs WHERE provider_message_id = 're-1' AND status = 'delivered'`;
return rows.length === 1;
});
const payload = JSON.stringify({
type: "email.bounced",
data: { email_id: "re-1", to: ["gone@x.com"], bounce: { type: "Permanent" } },
});
const res = await apiClient(app.baseUrl)("POST", "/webhooks/resend", payload, {
"content-type": "application/json",
...svixSign(secret, "msg_e2e", Math.floor(Date.now() / 1000), payload),
});
expect(res.status).toBe(200);
const suppressions = await p.api("GET", "/v1/suppressions");
expect(suppressions.body.suppressions.map((s: any) => [s.target, s.reason])).toEqual([
["gone@x.com", "bounced"],
]);
});
});
+401
View File
@@ -0,0 +1,401 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { randomUUID } from "node:crypto";
import { StreamProducer, StreamConsumer, PendingMessageScanner } from "@/queue/index.js";
import { STREAMS, CONSUMER_GROUPS, buildStreamEvent, type StreamName } from "@/contracts/index.js";
import { createLogger } from "@/logger/index.js";
import { useInfra, waitFor, settle } from "./support/infra.js";
const infra = useInfra();
const logger = createLogger({ name: "test", level: "silent" });
const A = STREAMS.INBOUND_NORMAL;
const B = STREAMS.INBOUND_LOW;
const DLQ = STREAMS.DEAD_LETTER;
const GROUP = CONSUMER_GROUPS.ENRICHER;
function event(n = 0) {
return buildStreamEvent("test.event", { n }, "test");
}
/** A full envelope, as it would sit in a stream. */
function envelope(n = 0) {
return { ...event(n), id: randomUUID(), timestamp: new Date().toISOString() };
}
const consumers: StreamConsumer[] = [];
function consumer(opts: Partial<ConstructorParameters<typeof StreamConsumer>[0]> = {}) {
const c = new StreamConsumer({
redis: infra.redis,
stream: A,
group: GROUP,
consumer: `c-${randomUUID().slice(0, 6)}`,
blockMs: 100,
logger,
...opts,
});
consumers.push(c);
return c;
}
/** Reads until `count` messages arrived or the timeout passes. */
async function readN(c: StreamConsumer, count: number, timeoutMs = 5_000) {
const got: any[] = [];
const it = c.readBatch();
const deadline = Date.now() + timeoutMs;
while (got.length < count && Date.now() < deadline) {
const next = await Promise.race([
it.next(),
new Promise<{ done: true; value: undefined }>((r) =>
setTimeout(() => r({ done: true, value: undefined }), deadline - Date.now()),
),
]);
if (next.done) break;
got.push(...next.value);
}
await c.stop();
return got;
}
async function pendingCount(stream: string, group = GROUP): Promise<number> {
const summary = (await infra.redis.xpending(stream, group)) as any[];
return Number(summary[0]);
}
beforeEach(async () => {
await infra.redis.flushdb();
});
afterEach(async () => {
await Promise.all(consumers.splice(0).map((c) => c.stop().catch(() => {})));
});
describe("StreamProducer", () => {
it("writes an envelope with a fresh id and timestamp, and returns the stream entry id", async () => {
const producer = new StreamProducer({ redis: infra.redis, stream: A, logger, maxLen: 1000 });
const entryId = await producer.publish(event(7));
const entries = await infra.redis.xrange(A, "-", "+");
expect(entries).toHaveLength(1);
expect(entries[0]![0]).toBe(entryId);
const stored = JSON.parse(entries[0]![1][1]!);
expect(stored.payload).toEqual({ n: 7 });
expect(stored.id).toMatch(/^[0-9a-f-]{36}$/);
expect(Number.isNaN(Date.parse(stored.timestamp))).toBe(false);
});
it("keeps every message and their order when hundreds are published at once", async () => {
const producer = new StreamProducer({ redis: infra.redis, stream: A, logger, maxLen: 10_000 });
const ids = await Promise.all(
Array.from({ length: 350 }, (_, n) => producer.publish(event(n))),
);
expect(new Set(ids).size).toBe(350);
const entries = await infra.redis.xrange(A, "-", "+");
expect(entries.map((e) => JSON.parse(e[1][1]!).payload.n)).toEqual(
Array.from({ length: 350 }, (_, n) => n),
);
});
it("trims the stream towards maxLen", async () => {
const producer = new StreamProducer({ redis: infra.redis, stream: A, logger, maxLen: 10 });
for (let batch = 0; batch < 10; batch++) {
await Promise.all(Array.from({ length: 100 }, (_, n) => producer.publish(event(n))));
}
// "~" trims whole radix-tree nodes (100 entries by default), so the length
// lands near, not at, the cap — but nowhere near the 1000 written.
expect(await infra.redis.xlen(A)).toBeLessThanOrEqual(200);
});
it("rejects the publish when Redis refuses the write", async () => {
await infra.redis.set(A, "not a stream");
const producer = new StreamProducer({ redis: infra.redis, stream: A, logger, maxLen: 100 });
await expect(producer.publish(event())).rejects.toThrow(/WRONGTYPE/);
});
it("publishBatch returns the ids of the events it actually published", async () => {
const producer = new StreamProducer({ redis: infra.redis, stream: A, logger, maxLen: 1000 });
const { messageIds, eventIds } = await producer.publishBatch([event(1), event(2), event(3)]);
const entries = await infra.redis.xrange(A, "-", "+");
expect(entries.map((e) => e[0])).toEqual(messageIds);
// Callers record eventIds as the notification id (workflow step.notify
// does), so each must be the id of the event that entry holds.
expect(entries.map((e) => JSON.parse(e[1][1]!).id)).toEqual(eventIds);
});
it("publishBatch of nothing writes nothing", async () => {
const producer = new StreamProducer({ redis: infra.redis, stream: A, logger, maxLen: 1000 });
expect(await producer.publishBatch([])).toEqual({ messageIds: [], eventIds: [] });
expect(await infra.redis.exists(A)).toBe(0);
});
});
describe("StreamConsumer", () => {
it("delivers messages published before the group existed", async () => {
await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope(1)));
const c = consumer();
await c.ensureGroup();
const got = await readN(c, 1);
expect(got.map((m) => m.event.payload.n)).toEqual([1]);
});
it("ensureGroup is safe to call repeatedly and creates missing streams", async () => {
const c = consumer({ stream: [A, B] });
await c.ensureGroup();
await c.ensureGroup();
const groups = (await infra.redis.xinfo("GROUPS", B)) as any[];
expect(groups).toHaveLength(1);
});
it("reads from several streams and tags each message with its stream", async () => {
const c = consumer({ stream: [A, B] });
await c.ensureGroup();
await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope(1)));
await infra.redis.xadd(B, "*", "data", JSON.stringify(envelope(2)));
const got = await readN(c, 2);
const byN = Object.fromEntries(got.map((m) => [m.event.payload.n, m]));
expect(byN[1]!.stream).toBe(A);
expect(byN[2].stream).toBe(B);
expect(byN[1].deliveryCount).toBe(1);
});
it("acks and skips entries it cannot parse instead of leaving them pending", async () => {
const c = consumer();
await c.ensureGroup();
await infra.redis.xadd(A, "*", "other", "x");
await infra.redis.xadd(A, "*", "data", "{not json");
await infra.redis.xadd(A, "*", "data", JSON.stringify({ hello: "world" }));
await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope(9)));
const got = await readN(c, 1);
expect(got.map((m) => m.event.payload.n)).toEqual([9]);
// Only the good one is still pending; it has not been acked yet.
expect(await pendingCount(A)).toBe(1);
});
it("ack removes the message from the pending list", async () => {
const c = consumer();
await c.ensureGroup();
await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope()));
const [msg] = await readN(c, 1);
expect(await pendingCount(A)).toBe(1);
await c.ack(msg.id, msg.stream);
expect(await pendingCount(A)).toBe(0);
});
it("buffered acks land once the timer fires, and on flushAcks", async () => {
const c = consumer({ bufferAcks: true, ackFlushMs: 20, batchSize: 100 });
await c.ensureGroup();
for (let n = 0; n < 3; n++) await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope(n)));
const got = await readN(c, 3);
await c.ack(got[0].id, A);
expect(await pendingCount(A)).toBe(3); // still buffered
await waitFor("timer flush", async () => (await pendingCount(A)) === 2, 2_000);
await c.ack([got[1].id, got[2].id], A);
await c.flushAcks();
expect(await pendingCount(A)).toBe(0);
});
it("buffered acks flush immediately once a batch is full", async () => {
const c = consumer({ bufferAcks: true, ackFlushMs: 60_000, batchSize: 2 });
await c.ensureGroup();
for (let n = 0; n < 2; n++) await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope(n)));
const got = await readN(c, 2);
await c.ack(got[0].id, A);
await c.ack(got[1].id, A);
expect(await pendingCount(A)).toBe(0);
});
it("stop() flushes buffered acks", async () => {
const c = consumer({ bufferAcks: true, ackFlushMs: 60_000, batchSize: 100 });
await c.ensureGroup();
await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope()));
const [msg] = await readN(c, 1);
await c.ack(msg.id, A);
await c.stop();
expect(await pendingCount(A)).toBe(0);
});
it("nack moves the message to the dead-letter stream and acks it", async () => {
const c = consumer({ dlqStream: DLQ });
await c.ensureGroup();
const original = envelope(5);
await infra.redis.xadd(A, "*", "data", JSON.stringify(original));
const [msg] = await readN(c, 1);
await c.nack(msg.id, msg.event, msg.stream);
expect(await pendingCount(A)).toBe(0);
const dlq = await infra.redis.xrange(DLQ, "-", "+");
expect(dlq).toHaveLength(1);
const dead = JSON.parse(dlq[0]![1][1]!);
expect(dead.id).toBe(original.id);
expect(dead.dlq.originalStream).toBe(A);
});
it("nack leaves the message pending when the dead-letter write fails", async () => {
await infra.redis.set(DLQ, "wrong type");
const c = consumer({ dlqStream: DLQ });
await c.ensureGroup();
await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope()));
const [msg] = await readN(c, 1);
await expect(c.nack(msg.id, msg.event, msg.stream)).rejects.toThrow();
// Never acked against a DLQ write that did not land.
expect(await pendingCount(A)).toBe(1);
});
it("nack without a dead-letter stream just acks", async () => {
const c = consumer();
await c.ensureGroup();
await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope()));
const [msg] = await readN(c, 1);
await c.nack(msg.id, msg.event, msg.stream);
expect(await pendingCount(A)).toBe(0);
expect(await infra.redis.exists(DLQ)).toBe(0);
});
it("deadLetter records an in-process event against the consumer's stream", async () => {
const c = consumer({ dlqStream: DLQ });
const ev = envelope(3);
await c.deadLetter(ev);
const [[, fields]] = (await infra.redis.xrange(DLQ, "-", "+")) as any;
const dead = JSON.parse(fields[1]);
expect(dead.id).toBe(ev.id);
expect(dead.dlq.originalStream).toBe(A);
});
it("recreates its group and keeps reading after Redis loses its data", async () => {
const c = consumer();
await c.ensureGroup();
const it = c.readBatch();
await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope(1)));
const first = await it.next();
expect(first.value![0]!.event.payload.n).toBe(1);
await infra.redis.flushdb();
// Give the blocked read a moment to hit NOGROUP before anything is written.
await settle(300);
await infra.redis.xadd(A, "*", "data", JSON.stringify(envelope(2)));
const second = await Promise.race([
it.next(),
settle(5_000).then(() => ({ value: [] as any[] })),
]);
expect(second.value!.map((m: any) => m.event.payload.n)).toEqual([2]);
});
it("stop() ends the read loop", async () => {
const c = consumer();
await c.ensureGroup();
const it = c.readBatch();
const pending = it.next();
await settle(50);
await c.stop();
const result = await Promise.race([pending, settle(3_000).then(() => "hung" as const)]);
expect(result).not.toBe("hung");
});
});
describe("PendingMessageScanner", () => {
async function deliverUnacked(stream: StreamName, n: number, consumerName = "dead-worker") {
// In production the scanner's consumer has already created the group on
// every stream it reads.
for (const s of [A, B])
await infra.redis.xgroup("CREATE", s, GROUP, "0", "MKSTREAM").catch(() => {});
for (let i = 0; i < n; i++) {
await infra.redis.xadd(stream, "*", "data", JSON.stringify(envelope(i)));
}
await infra.redis.xreadgroup("GROUP", GROUP, consumerName, "COUNT", n, "STREAMS", stream, ">");
}
function scanner(name = "rescuer", stream: StreamName | StreamName[] = [A, B]) {
return new PendingMessageScanner({
redis: infra.redis,
stream,
group: GROUP,
consumer: name,
logger,
});
}
it("counts and lists pending entries across its streams", async () => {
await deliverUnacked(A, 2);
await deliverUnacked(B, 3);
const s = scanner();
expect(await s.getPendingCount()).toBe(5);
const entries = await s.getPendingEntries(100);
expect(entries).toHaveLength(5);
expect(entries.filter((e) => e.stream === B)).toHaveLength(3);
expect(entries.every((e) => e.consumer === "dead-worker" && e.deliveryCount === 1)).toBe(true);
expect(await s.getPendingEntries(4)).toHaveLength(4);
expect(await s.getPendingEntries(100, A)).toHaveLength(2);
});
it("claims only entries idle past the threshold and bumps their delivery count", async () => {
await deliverUnacked(A, 2);
const s = scanner();
expect(await s.autoclaim(60_000)).toEqual([]);
await settle(150);
const claimed = await s.autoclaim(100);
expect(claimed).toHaveLength(2);
expect(claimed.every((m) => m.stream === A && m.deliveryCount === 2)).toBe(true);
const entries = await s.getPendingEntries(10);
expect(entries.every((e) => e.consumer === "rescuer")).toBe(true);
});
it("backs off exponentially with each redelivery", async () => {
await deliverUnacked(A, 1);
const s = scanner();
await settle(350);
expect(await s.autoclaim(300)).toHaveLength(1); // delivered once: needs > 300ms idle
// Delivered twice now, so it needs > 600ms idle. Checked straight after the
// claim reset its idle time, so a slow machine cannot make this pass or fail.
expect(await s.autoclaim(300)).toHaveLength(0);
await settle(700);
expect(await s.autoclaim(300)).toHaveLength(1);
});
it("never hands one entry to two racing scanners", async () => {
await deliverUnacked(A, 20);
await settle(150);
const [x, y] = await Promise.all([
scanner("x").autoclaim(100, 50),
scanner("y").autoclaim(100, 50),
]);
const ids = [...x, ...y].map((m) => m.id);
expect(ids).toHaveLength(20);
expect(new Set(ids).size).toBe(20);
});
it("does not claim a message from one stream using an id pending on another", async () => {
await infra.redis.xgroup("CREATE", A, GROUP, "0", "MKSTREAM");
await infra.redis.xgroup("CREATE", B, GROUP, "0", "MKSTREAM");
// Same entry id in both streams; only B's is pending.
await infra.redis.xadd(A, "1-1", "data", JSON.stringify(envelope(100)));
await infra.redis.xadd(B, "1-1", "data", JSON.stringify(envelope(200)));
await infra.redis.xreadgroup("GROUP", GROUP, "dead", "STREAMS", B, ">");
await settle(150);
const claimed = await scanner().autoclaim(100);
expect(claimed.map((m) => [m.stream, m.event.payload.n])).toEqual([[B, 200]]);
expect(await pendingCount(A)).toBe(0);
});
it("respects the claim limit", async () => {
await deliverUnacked(A, 10);
await settle(150);
expect(await scanner().autoclaim(100, 3)).toHaveLength(3);
});
});
+692
View File
@@ -0,0 +1,692 @@
import { describe, it, expect, beforeEach } from "vitest";
import { randomUUID } from "node:crypto";
import {
UserRepository,
PreferenceRepository,
ContactRepository,
TemplateRepository,
ProjectRepository,
WorkflowRepository,
SegmentRepository,
AdminUserRepository,
} from "@/repositories/index.js";
import { useInfra } from "./support/infra.js";
const infra = useInfra();
let P: string; // project under test
let OTHER: string; // a second tenant, to prove isolation
async function newProject(name = "p") {
const id = randomUUID();
await infra.sql`INSERT INTO projects (id, name) VALUES (${id}, ${name})`;
return id;
}
beforeEach(async () => {
await infra.reset();
P = await newProject("main");
OTHER = await newProject("other");
});
function user(userId: string, extra: Record<string, unknown> = {}) {
return { userId, segments: [], preferences: {}, ...extra } as any;
}
// ─── UserRepository ─────────────────────────────────────────────────────────
describe("UserRepository", () => {
it("upsertFull stores the profile, segments, preferences and quiet hours", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertFull(P, {
userId: "u1",
email: "a@x.com",
language: "fr",
timezone: "Europe/Paris",
segments: ["vip", "beta"],
preferences: {
topics: { marketing: false, news: true },
channels: { sms: false },
quietHours: [{ start: "22:00", end: "07:00" }],
},
});
const rec = await new UserRepository(infra.db).findRecordById(P, "u1");
expect(rec).toMatchObject({
userId: "u1",
email: "a@x.com",
language: "fr",
timezone: "Europe/Paris",
preferences: {
topics: { marketing: false, news: true },
channels: { sms: false },
quietHours: [{ start: "22:00", end: "07:00" }],
},
});
expect(rec!.segments.sort()).toEqual(["beta", "vip"]);
expect(await repo.findById(P, "u1")).toEqual({
userId: "u1",
email: "a@x.com",
language: "fr",
timezone: "Europe/Paris",
});
});
it("upsertFull on an existing user updates it and replaces quiet hours", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertFull(
P,
user("u1", {
email: "old@x.com",
preferences: { quietHours: [{ start: "01:00", end: "02:00" }] },
}),
);
await repo.upsertFull(
P,
user("u1", { email: "new@x.com", preferences: { topics: { a: false }, quietHours: [] } }),
);
const rec = await new UserRepository(infra.db).findRecordById(P, "u1");
expect(rec!.email).toBe("new@x.com");
expect(rec!.preferences.topics).toEqual({ a: false });
expect(rec!.preferences.quietHours).toBeUndefined();
const { n } = (
await infra.sql`SELECT count(*)::int AS n FROM users WHERE project_id = ${P}`
)[0]!;
expect(n).toBe(1);
});
it("keeps the same external id separate per project", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertFull(P, user("same", { email: "p@x.com" }));
await repo.upsertFull(OTHER, user("same", { email: "o@x.com" }));
expect((await repo.findById(P, "same"))!.email).toBe("p@x.com");
expect((await repo.findById(OTHER, "same"))!.email).toBe("o@x.com");
expect(await repo.findById(P, "nobody")).toBeNull();
expect(await repo.findRecordById(P, "nobody")).toBeNull();
});
it("findRecordsByIds returns the users that exist and serves repeats from cache", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertManyFull(P, [user("a"), user("b"), user("c")]);
const first = await repo.findRecordsByIds(P, ["a", "b", "missing"]);
expect(first.map((r) => r.userId).sort()).toEqual(["a", "b"]);
// Once cached, a row deleted behind the repository's back is still served.
await infra.sql`DELETE FROM users WHERE external_id = 'a'`;
const second = await repo.findRecordsByIds(P, ["a", "c"]);
expect(second.map((r) => r.userId).sort()).toEqual(["a", "c"]);
expect(await repo.findRecordsByIds(P, [])).toEqual([]);
});
it("writes through the repository invalidate its cache", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertFull(P, user("u", { email: "one@x.com" }));
expect((await repo.findRecordById(P, "u"))!.email).toBe("one@x.com");
await repo.updatePartial(P, "u", { email: "two@x.com" });
expect((await repo.findRecordById(P, "u"))!.email).toBe("two@x.com");
await repo.upsertFull(P, user("u", { email: "three@x.com" }));
expect((await repo.findRecordById(P, "u"))!.email).toBe("three@x.com");
await repo.delete(P, "u");
expect(await repo.findRecordById(P, "u")).toBeNull();
});
it("upsertManyFull inserts many users, de-duplicating repeated rows", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertManyFull(P, [
user("a", { segments: ["s", "s"], preferences: { topics: { t: true } } }),
user("a", { segments: ["s"], preferences: { topics: { t: false } } }),
user("b", {
preferences: { channels: { email: false }, quietHours: [{ start: "10:00", end: "11:00" }] },
}),
]);
const [a, b] = await Promise.all([repo.findRecordById(P, "a"), repo.findRecordById(P, "b")]);
expect(a!.segments).toEqual(["s"]);
expect(a!.preferences.topics).toEqual({ t: false });
expect(b!.preferences.channels).toEqual({ email: false });
expect(b!.preferences.quietHours).toEqual([{ start: "10:00", end: "11:00" }]);
await repo.upsertManyFull(P, []);
});
it("upsertManyFull replaces quiet hours only for users that supplied them", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertManyFull(P, [
user("a", { preferences: { quietHours: [{ start: "01:00", end: "02:00" }] } }),
user("b", { preferences: { quietHours: [{ start: "03:00", end: "04:00" }] } }),
]);
await repo.upsertManyFull(P, [user("a", { preferences: { quietHours: [] } }), user("b")]);
const fresh = new UserRepository(infra.db);
expect((await fresh.findRecordById(P, "a"))!.preferences.quietHours).toBeUndefined();
expect((await fresh.findRecordById(P, "b"))!.preferences.quietHours).toEqual([
{ start: "03:00", end: "04:00" },
]);
});
it("updatePartial changes only what it is given, and replaces segments", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertFull(
P,
user("u", { email: "e@x.com", language: "en", timezone: "UTC", segments: ["a", "b"] }),
);
expect(
await repo.updatePartial(P, "u", {
language: "de",
segments: ["c"],
preferences: {
topics: { x: false },
channels: { push: false },
quietHours: [{ start: "05:00", end: "06:00" }],
},
}),
).toBe(true);
const rec = await new UserRepository(infra.db).findRecordById(P, "u");
expect(rec).toMatchObject({
email: "e@x.com",
language: "de",
timezone: "UTC",
segments: ["c"],
preferences: {
topics: { x: false },
channels: { push: false },
quietHours: [{ start: "05:00", end: "06:00" }],
},
});
expect(await repo.updatePartial(P, "missing", { language: "x" })).toBe(false);
expect(await repo.updatePartial(OTHER, "u", { language: "x" })).toBe(false);
});
it("updatePartial can clear an email with null", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertFull(P, user("u", { email: "e@x.com" }));
await repo.updatePartial(P, "u", { email: null });
expect((await repo.findById(P, "u"))!.email ?? null).toBeNull();
});
it("delete removes the user and everything hanging off it, in its own project only", async () => {
const repo = new UserRepository(infra.db);
const contacts = new ContactRepository(infra.db);
await repo.upsertFull(P, user("u", { segments: ["s"] }));
await repo.upsertFull(OTHER, user("u"));
await contacts.upsert(P, "u", "email", "u@x.com", { topics: { t: false } });
expect(await repo.delete(P, "u")).toBe(true);
expect(await repo.delete(P, "u")).toBe(false);
expect(await repo.findById(OTHER, "u")).not.toBeNull();
const { n } = (await infra.sql`SELECT count(*)::int AS n FROM user_contacts`)[0]!;
expect(n).toBe(0);
});
describe("list", () => {
it("filters by search, segment, language, timezone and channel", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertFull(
P,
user("alice", {
email: "alice@corp.com",
language: "en",
timezone: "UTC",
segments: ["vip"],
}),
);
await repo.upsertFull(
P,
user("bob", { email: "bob@home.net", language: "fr", timezone: "Europe/Paris" }),
);
await new ContactRepository(infra.db).upsert(P, "bob", "sms", "+100");
await repo.upsertFull(OTHER, user("alice-other"));
const ids = async (f: any) =>
(await repo.list(P, 50, undefined, f)).users.map((u) => u.userId).sort();
expect(await ids(undefined)).toEqual(["alice", "bob"]);
expect(await ids({ search: "CORP" })).toEqual(["alice"]);
expect(await ids({ search: "bo" })).toEqual(["bob"]);
expect(await ids({ segment: "vip" })).toEqual(["alice"]);
expect(await ids({ language: "fr" })).toEqual(["bob"]);
expect(await ids({ timezone: "UTC" })).toEqual(["alice"]);
expect(await ids({ channel: "sms" })).toEqual(["bob"]);
});
it("pages through every user, including users imported in one batch", async () => {
const repo = new UserRepository(infra.db);
// A bulk import writes every row in one transaction, so they all share
// one created_at.
await repo.upsertManyFull(
P,
Array.from({ length: 25 }, (_, i) => user(`bulk-${i}`)),
);
const seen: string[] = [];
let cursor: string | undefined;
for (let page = 0; page < 10; page++) {
const { users, nextCursor } = await repo.list(P, 10, cursor);
seen.push(...users.map((u) => u.userId));
if (!nextCursor) break;
cursor = nextCursor;
}
expect(seen).toHaveLength(25);
expect(new Set(seen).size).toBe(25);
});
});
it("findUsersBySegment and findUsersByTopic stay inside the project", async () => {
const repo = new UserRepository(infra.db);
await repo.upsertFull(
P,
user("a", { segments: ["vip"], preferences: { topics: { news: true } } }),
);
await repo.upsertFull(
P,
user("b", { segments: ["vip"], preferences: { topics: { news: false } } }),
);
await repo.upsertFull(
OTHER,
user("c", { segments: ["vip"], preferences: { topics: { news: true } } }),
);
expect((await repo.findUsersBySegment(P, "vip")).sort()).toEqual(["a", "b"]);
expect(await repo.findUsersBySegment(P, "none")).toEqual([]);
expect(await repo.findUsersByTopic(P, "news")).toEqual(["a"]);
});
});
// ─── PreferenceRepository ───────────────────────────────────────────────────
describe("PreferenceRepository", () => {
it("reports topic opt-ins, defaulting to opted in", async () => {
await new UserRepository(infra.db).upsertFull(
P,
user("u", { preferences: { topics: { promo: false, news: true } } }),
);
const prefs = new PreferenceRepository(infra.db);
expect(await prefs.isOptedIn(P, "u", "promo")).toBe(false);
expect(await prefs.isOptedIn(P, "u", "news")).toBe(true);
expect(await prefs.isOptedIn(P, "u", "unknown")).toBe(true);
expect(
(await prefs.findByUserId(P, "u")).sort((a, b) => a.eventType.localeCompare(b.eventType)),
).toEqual([
{ userId: "u", eventType: "news", optedIn: true },
{ userId: "u", eventType: "promo", optedIn: false },
]);
expect(await prefs.findByUserId(P, "nobody")).toEqual([]);
expect(await prefs.findByUserId(OTHER, "u")).toEqual([]);
});
});
// ─── ContactRepository ──────────────────────────────────────────────────────
describe("ContactRepository", () => {
beforeEach(async () => {
const users = new UserRepository(infra.db);
await users.upsertManyFull(P, [user("u"), user("v")]);
await users.upsertFull(OTHER, user("u"));
});
it("upsert stores a contact with its topic preferences", async () => {
const repo = new ContactRepository(infra.db);
await repo.upsert(P, "u", "email", "u@x.com", { topics: { promo: false } });
await repo.upsert(P, "u", "email", "u@x.com", { topics: { promo: true, news: false } });
const contacts = await repo.findByUserId(P, "u");
expect(contacts).toHaveLength(1);
expect(contacts[0]).toMatchObject({
userId: "u",
channel: "email",
target: "u@x.com",
active: true,
preferences: { topics: { promo: true, news: false } },
});
expect(await repo.findByUserId(P, "v")).toEqual([]);
expect(await repo.findByUserId(P, "nobody")).toEqual([]);
});
it("upsert for an unknown user is a no-op", async () => {
const repo = new ContactRepository(infra.db);
await repo.upsert(P, "ghost", "sms", "+1");
const { n } = (await infra.sql`SELECT count(*)::int AS n FROM user_contacts`)[0]!;
expect(n).toBe(0);
});
it("findActiveByUserIds returns only enabled contacts, grouped by user", async () => {
const repo = new ContactRepository(infra.db);
await repo.upsert(P, "u", "email", "u@x.com");
await repo.upsert(P, "u", "sms", "+1");
await repo.upsert(P, "v", "push", "tok");
await repo.upsert(OTHER, "u", "email", "other@x.com");
await repo.deactivate(P, "u", "sms", "+1");
const map = await new ContactRepository(infra.db).findActiveByUserIds(P, ["u", "v", "nobody"]);
expect(map.get("u")!.map((c) => c.target)).toEqual(["u@x.com"]);
expect(map.get("v")!.map((c) => c.channel)).toEqual(["push"]);
expect(map.has("nobody")).toBe(false);
expect((await repo.findActiveByUserIds(P, [])).size).toBe(0);
});
it("carries each contact's topic opt-outs, so a per-address unsubscribe can be honoured", async () => {
const repo = new ContactRepository(infra.db);
await repo.upsert(P, "u", "email", "work@x.com", { topics: { promo: false } });
const [contact] = (await repo.findActiveByUserIds(P, ["u"])).get("u")!;
expect(contact!.preferences.topics).toEqual({ promo: false });
});
it("sees writes made through the repository immediately", async () => {
const repo = new ContactRepository(infra.db);
await repo.upsert(P, "u", "email", "a@x.com");
expect((await repo.findActiveByUserIds(P, ["u"])).get("u")).toHaveLength(1);
await repo.upsert(P, "u", "sms", "+2");
expect((await repo.findActiveByUserIds(P, ["u"])).get("u")).toHaveLength(2);
await repo.deactivate(P, "u", "sms", "+2");
expect((await repo.findActiveByUserIds(P, ["u"])).get("u")).toHaveLength(1);
await repo.delete(P, "u", "email", "a@x.com");
expect((await repo.findActiveByUserIds(P, ["u"])).has("u")).toBe(false);
await repo.upsertMany(P, [{ userId: "u", channel: "email", target: "b@x.com" }]);
expect((await repo.findActiveByUserIds(P, ["u"])).get("u")).toHaveLength(1);
});
it("re-adding a deactivated contact turns it back on", async () => {
const repo = new ContactRepository(infra.db);
await repo.upsert(P, "u", "push", "tok");
expect(await repo.deactivate(P, "u", "push", "tok")).toBe(true);
expect((await repo.findByUserId(P, "u"))[0]!.active).toBe(false);
await repo.upsert(P, "u", "push", "tok");
expect((await repo.findByUserId(P, "u"))[0]!.active).toBe(true);
});
it("deactivate and delete report whether anything matched", async () => {
const repo = new ContactRepository(infra.db);
await repo.upsert(P, "u", "email", "u@x.com");
expect(await repo.deactivate(P, "u", "email", "nope@x.com")).toBe(false);
expect(await repo.deactivate(P, "ghost", "email", "u@x.com")).toBe(false);
expect(await repo.delete(OTHER, "u", "email", "u@x.com")).toBe(false);
expect(await repo.delete(P, "ghost", "email", "u@x.com")).toBe(false);
expect(await repo.delete(P, "u", "email", "u@x.com")).toBe(true);
expect(await repo.findByUserId(P, "u")).toEqual([]);
});
it("upsertMany adds contacts for known users with their topics, skipping unknown users", async () => {
const repo = new ContactRepository(infra.db);
await repo.upsertMany(P, [
{
userId: "u",
channel: "email",
target: "u@x.com",
preferences: { topics: { promo: false } },
},
{ userId: "v", channel: "sms", target: "+9" },
{ userId: "ghost", channel: "sms", target: "+0" },
]);
await repo.upsertMany(P, []);
expect((await repo.findByUserId(P, "u"))[0]!.preferences.topics).toEqual({ promo: false });
expect((await repo.findByUserId(P, "v"))[0]!.target).toBe("+9");
const { n } = (await infra.sql`SELECT count(*)::int AS n FROM user_contacts`)[0]!;
expect(n).toBe(2);
await repo.upsertMany(P, [{ userId: "ghost", channel: "sms", target: "+0" }]);
});
});
// ─── TemplateRepository ─────────────────────────────────────────────────────
describe("TemplateRepository", () => {
it("upserts, reads, lists and deletes templates per project", async () => {
const repo = new TemplateRepository(infra.db);
expect(await repo.upsertMany(P, [])).toBe(0);
expect(
await repo.upsertMany(P, [
{
id: "welcome",
channel: "email",
topics: ["onboarding"],
content: { subject: "Hi" },
aiPrompts: { intro: "say hi" },
},
{ id: "otp", channel: "sms", content: { text: "{{code}}" } },
]),
).toBe(2);
await repo.upsertMany(OTHER, [{ id: "welcome", channel: "push", content: {} }]);
expect(await repo.findById(P, "welcome")).toEqual({
id: "welcome",
channel: "email",
topics: ["onboarding"],
content: { subject: "Hi" },
aiPrompts: { intro: "say hi" },
});
expect((await repo.findById(P, "otp"))!.topics).toEqual([]);
expect(await repo.findById(P, "nope")).toBeNull();
expect((await repo.list(P)).map((t) => t.id).sort()).toEqual(["otp", "welcome"]);
await repo.upsertMany(P, [
{ id: "welcome", channel: "email", topics: [], content: { subject: "Hello" } },
]);
const updated = await repo.findById(P, "welcome");
expect(updated!.content).toEqual({ subject: "Hello" });
expect(updated!.aiPrompts).toBeNull();
expect(await repo.delete(P, "welcome")).toBe(true);
expect(await repo.delete(P, "welcome")).toBe(false);
expect(await repo.findById(OTHER, "welcome")).not.toBeNull();
});
});
// ─── ProjectRepository ──────────────────────────────────────────────────────
describe("ProjectRepository", () => {
it("lists projects newest first", async () => {
const newest = await newProject("newest");
const list = await new ProjectRepository(infra.db).list();
expect(list[0]!.id).toBe(newest);
expect(list.map((p) => p.id)).toEqual(expect.arrayContaining([P, OTHER]));
});
it("reads and updates throttle and rate-limit settings", async () => {
const repo = new ProjectRepository(infra.db);
expect(await repo.findThrottleSettings(P)).toEqual({
throttleLimit: null,
throttleWindowHours: null,
});
expect(
await repo.updateSettings(P, { throttleLimit: 5, throttleWindowHours: 2, rateLimitRpm: 100 }),
).toBe(true);
expect(await repo.findThrottleSettings(P)).toEqual({
throttleLimit: 5,
throttleWindowHours: 2,
});
expect(await repo.updateSettings(P, { throttleLimit: null })).toBe(true);
expect((await repo.findThrottleSettings(P))!.throttleLimit).toBeNull();
expect(await repo.findThrottleSettings(randomUUID())).toBeNull();
expect(await repo.updateSettings(randomUUID(), { throttleLimit: 1 })).toBe(false);
});
it("creates, lists and deletes API keys within a project", async () => {
const repo = new ProjectRepository(infra.db);
const a = await repo.createApiKey(P, "hash-a");
const b = await repo.createApiKey(P, "hash-b", "read_only");
const keys = await repo.listApiKeys(P);
expect(keys.map((k) => k.id).sort()).toEqual([a.id, b.id].sort());
expect(keys.find((k) => k.id === b.id)!.role).toBe("read_only");
expect(keys[0]).not.toHaveProperty("keyHash");
expect(await repo.deleteApiKey(OTHER, a.id)).toBe(false);
expect(await repo.deleteApiKey(P, a.id)).toBe(true);
expect(await repo.listApiKeys(P)).toHaveLength(1);
await expect(repo.createApiKey(OTHER, "hash-b")).rejects.toThrow();
});
it("delete removes the project and all of its data, leaving other projects alone", async () => {
const users = new UserRepository(infra.db);
const contacts = new ContactRepository(infra.db);
const repo = new ProjectRepository(infra.db);
for (const pid of [P, OTHER]) {
await users.upsertFull(
pid,
user("u", {
segments: ["s"],
preferences: {
topics: { t: false },
channels: { sms: false },
quietHours: [{ start: "01:00", end: "02:00" }],
},
}),
);
await contacts.upsert(pid, "u", "email", "u@x.com", { topics: { t: false } });
await new TemplateRepository(infra.db).upsertMany(pid, [
{ id: "t", channel: "email", content: {} },
]);
await repo.createApiKey(pid, `hash-${pid}`);
await infra.sql`INSERT INTO suppressions (project_id, channel, target, reason) VALUES (${pid}, 'email', 'u@x.com', 'manual')`;
await infra.sql`INSERT INTO message_logs (project_id, task_id, channel, status) VALUES (${pid}, ${randomUUID()}, 'email', 'delivered')`;
await infra.sql`INSERT INTO workflow_definitions (project_id, name, steps) VALUES (${pid}, 'wf', '[]')`;
const [inst] =
await infra.sql`INSERT INTO workflow_instances (project_id, name) VALUES (${pid}, 'wf') RETURNING id`;
await infra.sql`INSERT INTO workflow_steps (project_id, instance_id, step_index, action) VALUES (${pid}, ${inst!.id}, '0', 'run')`;
}
expect(await repo.delete(P)).toBe(true);
expect(await repo.delete(P)).toBe(false);
for (const table of [
"users",
"templates",
"project_api_keys",
"suppressions",
"message_logs",
"workflow_definitions",
"workflow_instances",
"workflow_steps",
]) {
const { mine } = (
await infra.sql.unsafe(`SELECT count(*)::int AS mine FROM ${table} WHERE project_id = $1`, [
P,
])
)[0]!;
const { theirs } = (
await infra.sql.unsafe(
`SELECT count(*)::int AS theirs FROM ${table} WHERE project_id = $1`,
[OTHER],
)
)[0]!;
expect({ table, mine }).toEqual({ table, mine: 0 });
expect({ table, theirs }).toEqual({ table, theirs: 1 });
}
const { n } = (await infra.sql`SELECT count(*)::int AS n FROM user_contacts`)[0]!;
expect(n).toBe(1);
});
});
// ─── WorkflowRepository ─────────────────────────────────────────────────────
describe("WorkflowRepository", () => {
async function instance(pid: string, status = "pending") {
const [row] = await infra.sql`
INSERT INTO workflow_instances (project_id, name, status) VALUES (${pid}, 'wf', ${status}) RETURNING id`;
return row!.id as string;
}
it("lists a project's definitions", async () => {
await infra.sql`INSERT INTO workflow_definitions (project_id, name, steps) VALUES (${P}, 'a', '[]'), (${OTHER}, 'b', '[]')`;
const defs = await new WorkflowRepository(infra.db).listDefinitions(P);
expect(defs.map((d) => d.name)).toEqual(["a"]);
});
it("returns an instance with its steps and waiters, only to its own project", async () => {
const id = await instance(P);
await infra.sql`INSERT INTO workflow_steps (project_id, instance_id, step_index, action, output) VALUES (${P}, ${id}, '0', 'notify', '{"ok":true}')`;
await infra.sql`INSERT INTO workflow_waiters (project_id, instance_id, event_name, match_criteria, expires_at) VALUES (${P}, ${id}, 'paid', '{}', now() + interval '1 hour')`;
const repo = new WorkflowRepository(infra.db);
const got = await repo.getInstance(P, id);
expect(got.id).toBe(id);
expect(got.steps.map((s: any) => s.action)).toEqual(["notify"]);
expect(got.waiters.map((w: any) => w.eventName)).toEqual(["paid"]);
expect(await repo.getInstance(OTHER, id)).toBeNull();
expect(await repo.getInstance(P, randomUUID())).toBeNull();
});
it("cancels a pending or running instance and drops its waiters", async () => {
const repo = new WorkflowRepository(infra.db);
const pending = await instance(P, "pending");
const running = await instance(P, "running");
const done = await instance(P, "completed");
await infra.sql`INSERT INTO workflow_waiters (project_id, instance_id, event_name, match_criteria, expires_at) VALUES (${P}, ${pending}, 'e', '{}', now() + interval '1 hour')`;
expect(await repo.cancelInstance(P, pending)).toBe(true);
expect(await repo.cancelInstance(P, running)).toBe(true);
expect(await repo.cancelInstance(P, done)).toBe(false);
expect(await repo.cancelInstance(OTHER, pending)).toBe(false);
const { status } = (
await infra.sql`SELECT status FROM workflow_instances WHERE id = ${pending}`
)[0]!;
expect(status).toBe("canceled");
const { n } = (await infra.sql`SELECT count(*)::int AS n FROM workflow_waiters`)[0]!;
expect(n).toBe(0);
});
});
// ─── SegmentRepository ──────────────────────────────────────────────────────
describe("SegmentRepository", () => {
it("lists a project's distinct segments", async () => {
const users = new UserRepository(infra.db);
await users.upsertManyFull(P, [
user("a", { segments: ["vip", "beta"] }),
user("b", { segments: ["vip"] }),
]);
await users.upsertFull(OTHER, user("c", { segments: ["secret"] }));
expect((await new SegmentRepository(infra.db).listSegments(P)).sort()).toEqual(["beta", "vip"]);
});
});
// ─── AdminUserRepository ────────────────────────────────────────────────────
describe("AdminUserRepository", () => {
it("creates admins with a normalised email, finds them by email or username, and counts them", async () => {
const repo = new AdminUserRepository(infra.db);
expect(await repo.count()).toBe(0);
const admin = await repo.create({
email: " Boss@Example.COM ",
username: " boss ",
passwordHash: "h1",
});
expect(admin.email).toBe("boss@example.com");
expect(admin.username).toBe("boss");
expect(admin.role).toBe("admin");
expect((await repo.findByEmailOrUsername("BOSS@example.com"))!.id).toBe(admin.id);
expect((await repo.findByEmailOrUsername(" boss "))!.id).toBe(admin.id);
expect(await repo.findByEmailOrUsername("nobody")).toBeNull();
expect((await repo.findById(admin.id))!.email).toBe("boss@example.com");
expect(await repo.findById(randomUUID())).toBeNull();
expect(await repo.count()).toBe(1);
await expect(repo.create({ email: "boss@example.com", passwordHash: "x" })).rejects.toThrow();
});
it("updates a password and never lists hashes", async () => {
const repo = new AdminUserRepository(infra.db);
const a = await repo.create({ email: "a@x.com", passwordHash: "old" });
await repo.create({ email: "b@x.com", passwordHash: "hb", role: "viewer" });
expect(await repo.updatePassword(a.id, "new")).toBe(true);
expect((await repo.findById(a.id))!.passwordHash).toBe("new");
expect(await repo.updatePassword(randomUUID(), "x")).toBe(false);
const list = await repo.list();
expect(list.map((u) => u.email)).toEqual(["a@x.com", "b@x.com"]);
expect(list.every((u) => !("passwordHash" in u))).toBe(true);
expect(list[1]!.role).toBe("viewer");
});
});
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, afterEach } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import {
hashPassword,
verifyPassword,
createAdminSession,
getAdminSession,
revokeAdminSession,
DUMMY_PASSWORD_HASH,
} from "@/services/auth/index.js";
import { loadEnv, parseConfig, baseConfigSchema } from "@/config/index.js";
import { ValidationError } from "@/shared/index.js";
import { createDatabase, runMigrations } from "@/db/index.js";
import { useInfra, waitFor, settle } from "./support/infra.js";
/**
* Process-level pieces against the real thing: admin sessions in Redis,
* configuration read from a real .env file, migrations against Postgres, and
* the server shutting down on a real signal.
*/
const infra = useInfra();
describe("admin sessions", () => {
const admin = { id: randomUUID(), email: "a@x.com", username: "a", role: "admin" } as any;
it("creates a session readable by its token, and revokes it", async () => {
const session = await createAdminSession(infra.redis, admin);
expect(session.token).toMatch(/^nk_sess_[0-9a-f]{64}$/);
expect(await getAdminSession(infra.redis, session.token)).toMatchObject({
adminId: admin.id,
email: "a@x.com",
role: "admin",
});
expect(await infra.redis.ttl(`notif:session:${session.token}`)).toBeGreaterThan(6 * 24 * 3600);
await revokeAdminSession(infra.redis, session.token);
expect(await getAdminSession(infra.redis, session.token)).toBeNull();
await revokeAdminSession(infra.redis, "");
});
it("expires a session at its TTL", async () => {
const session = await createAdminSession(infra.redis, admin, 1);
await settle(1_100);
expect(await getAdminSession(infra.redis, session.token)).toBeNull();
});
it("rejects tokens that are not sessions, and corrupt session data", async () => {
expect(await getAdminSession(infra.redis, "nk_live_abc")).toBeNull();
expect(await getAdminSession(infra.redis, "")).toBeNull();
await infra.redis.set("notif:session:nk_sess_corrupt", "{not json");
expect(await getAdminSession(infra.redis, "nk_sess_corrupt")).toBeNull();
await infra.redis.set(
"notif:session:nk_sess_stale",
JSON.stringify({ expiresAt: Date.now() - 1 }),
);
expect(await getAdminSession(infra.redis, "nk_sess_stale")).toBeNull();
expect(await infra.redis.exists("notif:session:nk_sess_stale")).toBe(0);
});
it("hashes passwords with a fresh salt and verifies them", async () => {
const [a, b] = await Promise.all([hashPassword("pw"), hashPassword("pw")]);
expect(a).not.toBe(b);
expect(await verifyPassword("pw", a)).toBe(true);
expect(await verifyPassword("PW", a)).toBe(false);
expect(await verifyPassword("pw", "garbage")).toBe(false);
expect(await verifyPassword("pw", DUMMY_PASSWORD_HASH)).toBe(false);
});
});
describe("configuration", () => {
const keys = ["NOTIFKIT_TEST_A", "NOTIFKIT_TEST_B"];
afterEach(() => keys.forEach((k) => delete process.env[k]));
it("loads a .env file without overriding what the environment already set", () => {
const dir = mkdtempSync(join(tmpdir(), "nk-env-"));
try {
writeFileSync(join(dir, ".env"), "NOTIFKIT_TEST_A=from-file\nNOTIFKIT_TEST_B=from-file\n");
process.env.NOTIFKIT_TEST_B = "from-env";
loadEnv(join(dir, ".env"));
expect(process.env.NOTIFKIT_TEST_A).toBe("from-file");
expect(process.env.NOTIFKIT_TEST_B).toBe("from-env");
loadEnv(join(dir, "missing.env"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("applies defaults and rejects invalid values field by field", () => {
const cfg = parseConfig(baseConfigSchema, { ADMIN_API_KEY: " key \n", PIPELINE_FUSED: "1" });
expect(cfg).toMatchObject({
PORT: 3000,
WORKER_CONCURRENCY: 10,
ADMIN_API_KEY: "key",
PIPELINE_FUSED: true,
});
const err = (() => {
try {
parseConfig(baseConfigSchema, { PORT: "99999", REDIS_URL: "not a url" });
} catch (e) {
return e;
}
})() as ValidationError;
expect(err).toBeInstanceOf(ValidationError);
expect(err.code).toBe("VALIDATION_ERROR");
expect(Object.keys(err.fields!).sort()).toEqual(["PORT", "REDIS_URL"]);
expect(parseConfig(z.object({ n: z.coerce.number() }), { n: "4" })).toEqual({ n: 4 });
});
});
describe("database", () => {
it("migrations are idempotent on an up-to-date database", async () => {
const { db, sql } = createDatabase({ url: infra.dbUrl, maxConnections: 1 });
try {
await runMigrations(db);
const [{ n }] =
(await sql`SELECT count(*)::int AS n FROM drizzle.__drizzle_migrations`) as any;
expect(n).toBeGreaterThanOrEqual(8);
const [{ canceled }] = (await sql`SELECT 'canceled'::workflow_status AS canceled`) as any;
expect(canceled).toBe("canceled");
} finally {
await sql.end();
}
});
});
describe("process lifecycle", () => {
it.skipIf(process.platform === "win32")(
"shuts the server down gracefully on SIGTERM and exits cleanly",
async () => {
const script = `
import { NotifkitServer } from "./src/server.ts";
const server = new NotifkitServer({
services: ["enricher", "engine", "delivery"],
redisUrl: process.env.REDIS_URL, databaseUrl: process.env.DATABASE_URL,
logLevel: "silent", autoMigrate: false,
});
await server.start();
console.log("READY");
`;
const child = spawn("npx", ["--no-install", "tsx", "--eval", script], {
env: { ...process.env, REDIS_URL: infra.redisUrl, DATABASE_URL: infra.dbUrl },
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
child.stdout.on("data", (c) => (out += c));
const exited = new Promise<number | null>((r) => child.on("exit", (code) => r(code)));
await waitFor("server ready", () => out.includes("READY"), 60_000);
await waitFor(
"workers reporting",
async () => (await infra.redis.exists("notif:health:engine")) === 1,
20_000,
);
child.kill("SIGTERM");
expect(await exited).toBe(0);
},
90_000,
);
});
+410
View File
@@ -0,0 +1,410 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { randomUUID } from "node:crypto";
import {
executeSchedulerPoll,
rebuildScheduledQueue,
SchedulerWorker,
SCHEDULER_SENTINEL_KEY,
} from "@/services/scheduler/main.js";
import { createPriorityProducers } from "@/workers/bootstrap.js";
import { StreamConsumer, PendingMessageScanner } from "@/queue/index.js";
import { STREAMS, CONSUMER_GROUPS, registry, buildStreamEvent } from "@/contracts/index.js";
import { createLogger } from "@/logger/index.js";
import { ContactRepository, TemplateRepository, UserRepository } from "@/repositories/index.js";
import { useInfra, waitFor } from "./support/infra.js";
/**
* The scheduler's pieces against real Redis ZSETs and a real
* scheduled_payloads table: parking a task, releasing it when due, and
* rebuilding the ZSETs from Postgres after Redis loses its data.
*/
const infra = useInfra();
const logger = createLogger({ name: "test", level: "silent" });
beforeEach(async () => {
await infra.reset();
});
const shardOf = (taskId: string) => parseInt(taskId.slice(-1), 16) || 0;
const zkey = (taskId: string) => `notif:scheduled:zset:${shardOf(taskId)}`;
async function park(taskId: string, at: number, payload: Record<string, unknown> = {}) {
const full = {
projectId: randomUUID(),
taskId,
channel: "email",
priority: "normal",
scheduledAt: new Date(at).toISOString(),
...payload,
};
await infra.sql`INSERT INTO scheduled_payloads (task_id, payload) VALUES (${taskId}, ${JSON.stringify(full)}::jsonb)`;
await infra.redis.zadd(
zkey(taskId),
at,
JSON.stringify({ taskId, enrichedEventId: randomUUID(), traceId: `trace-${taskId}` }),
);
return full;
}
async function released(stream: string) {
return (await infra.redis.xrange(stream, "-", "+")).map(([, f]) => JSON.parse(f[1]!));
}
async function zsize() {
let n = 0;
for (let i = 0; i < 16; i++) n += await infra.redis.zcard(`notif:scheduled:zset:${i}`);
return n;
}
describe("executeSchedulerPoll", () => {
it("releases due tasks onto the outbound lane for their priority, and only due ones", async () => {
const producers = createPriorityProducers(infra.redis, logger, "OUTBOUND");
const due = await park("task-a", Date.now() - 1_000, { priority: "critical" });
await park("task-b", Date.now() - 1_000, { priority: "low" });
await park("task-c", Date.now() + 60_000);
expect(await executeSchedulerPoll(infra.redis, producers, logger, infra.db)).toBe(false);
const critical = await released(STREAMS.OUTBOUND_CRITICAL);
expect(critical).toHaveLength(1);
expect(critical[0]).toMatchObject({
type: "notification.dispatched",
payload: due,
metadata: { traceId: "trace-task-a", source: "scheduler" },
});
expect(await released(STREAMS.OUTBOUND_LOW)).toHaveLength(1);
expect(await released(STREAMS.OUTBOUND_NORMAL)).toHaveLength(0);
const left = await infra.sql`SELECT task_id FROM scheduled_payloads ORDER BY task_id`;
expect(left.map((r) => r.task_id)).toEqual(["task-c"]);
expect(await zsize()).toBe(1);
});
it("drops a parked task whose payload was canceled, without sending it", async () => {
const producers = createPriorityProducers(infra.redis, logger, "OUTBOUND");
await park("task-x", Date.now() - 1_000);
await infra.sql`DELETE FROM scheduled_payloads`;
await executeSchedulerPoll(infra.redis, producers, logger, infra.db);
expect(await released(STREAMS.OUTBOUND_NORMAL)).toEqual([]);
expect(await zsize()).toBe(0);
});
it("skips a member that is not valid JSON and releases the rest", async () => {
const producers = createPriorityProducers(infra.redis, logger, "OUTBOUND");
await infra.redis.zadd("notif:scheduled:zset:0", Date.now() - 1, "{not json");
await park("task-0", Date.now() - 1_000);
await executeSchedulerPoll(infra.redis, producers, logger, infra.db);
expect(await released(STREAMS.OUTBOUND_NORMAL)).toHaveLength(1);
});
it("does nothing while another scheduler holds the poll lock", async () => {
const producers = createPriorityProducers(infra.redis, logger, "OUTBOUND");
await park("task-l", Date.now() - 1_000);
await infra.redis.set("notif:lock:scheduler:poll", "someone-else", "EX", 30);
expect(await executeSchedulerPoll(infra.redis, producers, logger, infra.db)).toBe(false);
expect(await released(STREAMS.OUTBOUND_NORMAL)).toEqual([]);
// And it must not have released someone else's lock.
expect(await infra.redis.get("notif:lock:scheduler:poll")).toBe("someone-else");
});
it("never releases one task twice when schedulers race", async () => {
const producers = createPriorityProducers(infra.redis, logger, "OUTBOUND");
for (let i = 0; i < 30; i++) await park(`race-${i.toString(16)}`, Date.now() - 1_000);
for (let round = 0; round < 10; round++) {
await Promise.all(
Array.from({ length: 4 }, () =>
executeSchedulerPoll(infra.redis, producers, logger, infra.db),
),
);
}
const out = await released(STREAMS.OUTBOUND_NORMAL);
expect(out).toHaveLength(30);
expect(new Set(out.map((e) => e.payload.taskId)).size).toBe(30);
});
it("asks to be called again while a shard still has a backlog", async () => {
const producers = createPriorityProducers(infra.redis, logger, "OUTBOUND");
// Every id ends in "0", so all 150 land on one shard (100 per poll).
const pipeline = infra.redis.pipeline();
const past = Date.now() - 1_000;
for (let i = 0; i < 150; i++) {
const taskId = `bulk-${i}-0`;
pipeline.zadd(zkey(taskId), past, JSON.stringify({ taskId, enrichedEventId: randomUUID() }));
}
await pipeline.exec();
await infra.sql`INSERT INTO scheduled_payloads (task_id, payload)
SELECT 'bulk-' || i || '-0', jsonb_build_object('priority', 'normal') FROM generate_series(0, 149) AS i`;
expect(await executeSchedulerPoll(infra.redis, producers, logger, infra.db)).toBe(true);
expect(await executeSchedulerPoll(infra.redis, producers, logger, infra.db)).toBe(false);
expect(await released(STREAMS.OUTBOUND_NORMAL)).toHaveLength(150);
});
it("does not delete a task's newer payload when it was re-parked while being released", async () => {
// Delivery throttles a released task and parks it again — new send time,
// same task id — sometimes before the poll that released it has finished.
const base = createPriorityProducers(infra.redis, logger, "OUTBOUND");
const retryAt = Date.now() + 5_000;
const reparking = {
...base,
normal: {
publishBatch: async (events: any[]) => {
const result = await base.normal.publishBatch(events);
for (const e of events) {
const payload = {
...e.payload,
scheduledAt: new Date(retryAt).toISOString(),
throttleAttemptCount: 1,
};
await infra.sql`
INSERT INTO scheduled_payloads (task_id, payload) VALUES (${e.payload.taskId}, ${JSON.stringify(payload)}::jsonb)
ON CONFLICT (task_id) DO UPDATE SET payload = EXCLUDED.payload`;
await infra.redis.zadd(
zkey(e.payload.taskId),
retryAt,
JSON.stringify({
taskId: e.payload.taskId,
enrichedEventId: randomUUID(),
traceId: "retry",
}),
);
}
return result;
},
},
};
await park("task-r", Date.now() - 1_000);
await executeSchedulerPoll(infra.redis, reparking, logger, infra.db);
const rows = await infra.sql`SELECT payload FROM scheduled_payloads WHERE task_id = 'task-r'`;
expect(rows).toHaveLength(1);
expect(rows[0]!.payload.throttleAttemptCount).toBe(1);
});
it("keeps releasing the other shards when one cannot be read", async () => {
const producers = createPriorityProducers(infra.redis, logger, "OUTBOUND");
await infra.redis.set("notif:scheduled:zset:3", "wrong type");
await park("task-4", Date.now() - 1_000);
await expect(executeSchedulerPoll(infra.redis, producers, logger, infra.db)).resolves.toBe(
false,
);
expect((await released(STREAMS.OUTBOUND_NORMAL)).map((e) => e.payload.taskId)).toEqual([
"task-4",
]);
});
});
/**
* A parked send was cleared by the engine when it was parked. Whatever the
* person did since — hours or days of it, for a `sendAt` or a quiet-hours
* deferral — has to stop it at release.
*/
describe("executeSchedulerPoll re-checks a parked send when releasing it", () => {
let project: string;
const users = () => new UserRepository(infra.db);
const contacts = () => new ContactRepository(infra.db);
beforeEach(async () => {
project = randomUUID();
await infra.sql`INSERT INTO projects (id, name) VALUES (${project}, 'p')`;
await new TemplateRepository(infra.db).upsertMany(project, [
{ id: "promo", channel: "email", content: { subject: "Sale" }, topics: ["marketing"] },
{ id: "receipt", channel: "email", content: { subject: "Receipt" } },
]);
await users().upsertFull(project, { userId: "u", segments: [], preferences: {} });
await contacts().upsert(project, "u", "email", "U@X.com");
});
const parkFor = (taskId: string, extra: Record<string, unknown> = {}) =>
park(taskId, Date.now() - 1_000, {
projectId: project,
recipientId: "u",
templateId: "promo",
destination: "U@X.com",
...extra,
});
async function poll() {
await executeSchedulerPoll(
infra.redis,
createPriorityProducers(infra.redis, logger, "OUTBOUND"),
logger,
infra.db,
);
return (await released(STREAMS.OUTBOUND_NORMAL)).map((e) => e.payload.taskId);
}
async function expectDropped(taskId: string) {
expect(await poll()).toEqual([]);
expect(
await infra.sql`SELECT 1 FROM scheduled_payloads WHERE task_id = ${taskId}`,
).toHaveLength(0);
expect(await zsize()).toBe(0);
}
it("still releases a send nothing has changed for", async () => {
await parkFor("ok-1");
expect(await poll()).toEqual(["ok-1"]);
});
it("drops a send to an address suppressed since it was parked", async () => {
await parkFor("sup-1");
// Stored normalised, while the contact kept its original casing.
await infra.sql`INSERT INTO suppressions (project_id, channel, target, reason) VALUES (${project}, 'email', 'u@x.com', 'bounced')`;
await expectDropped("sup-1");
});
it("does not let a suppression on another channel or project stop it", async () => {
await parkFor("sup-2");
await infra.sql`INSERT INTO suppressions (project_id, channel, target, reason) VALUES (${project}, 'sms', 'u@x.com', 'manual')`;
await infra.sql`INSERT INTO suppressions (project_id, channel, target, reason) VALUES (${randomUUID()}, 'email', 'u@x.com', 'manual')`;
expect(await poll()).toEqual(["sup-2"]);
});
it("drops a send for a topic the user opted out of, and keeps their transactional mail", async () => {
await parkFor("topic-1");
await parkFor("topic-2", { templateId: "receipt" });
await users().upsertFull(project, {
userId: "u",
segments: [],
preferences: { topics: { marketing: false } },
});
expect(await poll()).toEqual(["topic-2"]);
});
it("drops a send on a channel the user switched off", async () => {
await parkFor("chan-1", { templateId: "receipt" });
await users().upsertFull(project, {
userId: "u",
segments: [],
preferences: { channels: { email: false } },
});
await expectDropped("chan-1");
});
it("drops a send to an address that was deactivated, or a user that was deleted", async () => {
await parkFor("gone-1");
await contacts().deactivate(project, "u", "email", "U@X.com");
await expectDropped("gone-1");
await parkFor("gone-2", { recipientId: "nobody" });
await expectDropped("gone-2");
});
});
describe("rebuildScheduledQueue", () => {
it("re-queues every parked payload at its send time, page by page, and marks the queue intact", async () => {
const at = Date.now() + 3600_000;
for (let i = 0; i < 7; i++) await park(`rb-${i}`, at + i);
await infra.sql`INSERT INTO scheduled_payloads (task_id, payload) VALUES ('legacy-1', '{"priority":"normal"}')`;
await infra.redis.flushdb();
expect(await rebuildScheduledQueue(infra.redis, infra.db, logger, 3)).toBe(8);
expect(await zsize()).toBe(8);
const score = await infra.redis.zscore(
zkey("rb-4"),
JSON.stringify({ taskId: "rb-4", enrichedEventId: undefined }),
);
expect(Number(score)).toBe(at + 4);
// A payload with no send time is due now.
const legacy = await infra.redis.zrange(zkey("legacy-1"), "0", "-1", "WITHSCORES");
expect(Number(legacy[1]!)).toBeLessThanOrEqual(Date.now());
expect(await infra.redis.get(SCHEDULER_SENTINEL_KEY)).toBe("1");
});
it("does not duplicate a member that survived", async () => {
await park("keep-1", Date.now() + 60_000);
await infra.redis.del(zkey("keep-1"));
await rebuildScheduledQueue(infra.redis, infra.db, logger);
await rebuildScheduledQueue(infra.redis, infra.db, logger);
expect(await zsize()).toBe(1);
});
it("marks an empty queue intact too", async () => {
expect(await rebuildScheduledQueue(infra.redis, infra.db, logger)).toBe(0);
expect(await infra.redis.exists(SCHEDULER_SENTINEL_KEY)).toBe(1);
});
it("lets the poll send a task rebuilt next to a surviving copy only once", async () => {
const producers = createPriorityProducers(infra.redis, logger, "OUTBOUND");
await park("dup-5", Date.now() - 1_000);
await rebuildScheduledQueue(infra.redis, infra.db, logger); // adds a trace-less copy
expect(await infra.redis.zcard(zkey("dup-5"))).toBe(2);
await executeSchedulerPoll(infra.redis, producers, logger, infra.db);
expect(await released(STREAMS.OUTBOUND_NORMAL)).toHaveLength(1);
expect(await zsize()).toBe(0);
});
});
describe("SchedulerWorker", () => {
let worker: SchedulerWorker | undefined;
afterEach(async () => {
await worker?.stop();
worker = undefined;
});
it("parks each scheduled task in its shard at its send time", async () => {
const consumer = new StreamConsumer({
redis: infra.redis,
stream: STREAMS.SCHEDULED,
group: CONSUMER_GROUPS.SCHEDULER,
consumer: "s1",
blockMs: 100,
logger,
});
const pendingScanner = new PendingMessageScanner({
redis: infra.redis,
stream: STREAMS.SCHEDULED,
group: CONSUMER_GROUPS.SCHEDULER,
consumer: "s1",
logger,
});
worker = new SchedulerWorker({
consumer,
pendingScanner,
logger,
registry,
redis: infra.redis,
});
await worker.start();
const at = new Date(Date.now() + 60_000).toISOString();
const taskId = `${randomUUID()}:abc`;
const ev = {
...buildStreamEvent(
"notification.scheduled",
{ projectId: randomUUID(), enrichedEventId: randomUUID(), taskId, scheduledAt: at },
"engine",
"trace-9",
),
id: randomUUID(),
timestamp: new Date().toISOString(),
};
await infra.redis.xadd(STREAMS.SCHEDULED, "*", "data", JSON.stringify(ev));
// An invalid payload is skipped, not retried forever.
await infra.redis.xadd(
STREAMS.SCHEDULED,
"*",
"data",
JSON.stringify({ ...ev, id: randomUUID(), payload: { taskId: 1 } }),
);
await waitFor("parked", async () => (await infra.redis.zcard(zkey(taskId))) === 1);
const [member, score] = await infra.redis.zrange(zkey(taskId), "0", "-1", "WITHSCORES");
expect(JSON.parse(member!)).toMatchObject({ taskId, traceId: "trace-9" });
expect(Number(score)).toBe(Date.parse(at));
await waitFor(
"both acked",
async () =>
Number(
((await infra.redis.xpending(STREAMS.SCHEDULED, CONSUMER_GROUPS.SCHEDULER)) as any[])[0],
) === 0,
);
});
});
+111
View File
@@ -0,0 +1,111 @@
import http from "node:http";
import { Agent, getGlobalDispatcher, setGlobalDispatcher, type Dispatcher } from "undici";
/**
* Stands in for third-party APIs at the HTTP layer.
*
* Providers keep calling `fetch("https://api.telegram.org/...")` exactly as in
* production; requests to the hosts registered here are routed to a local
* server instead, so the real request is serialised, sent, answered with a
* real status code and body, and parsed by the provider's own code. Nothing in
* the provider is replaced.
*/
export interface RecordedRequest {
method: string;
host: string;
path: string;
headers: http.IncomingHttpHeaders;
body: string;
json(): any;
}
export type Reply =
| {
status?: number;
json?: unknown;
text?: string;
headers?: Record<string, string>;
delayMs?: number;
}
| "hang-up";
export type Handler = (req: RecordedRequest) => Reply | Promise<Reply>;
export interface FakeInternet {
requests: RecordedRequest[];
/** Answers requests to `host` with `handler`, replacing any previous one. */
on(host: string, handler: Handler): void;
close(): Promise<void>;
}
export async function startFakeInternet(): Promise<FakeInternet> {
const handlers = new Map<string, Handler>();
const requests: RecordedRequest[] = [];
const server = http.createServer((req, res) => {
let body = "";
req.setEncoding("utf8");
req.on("data", (c) => (body += c));
req.on("end", () => void respond());
const respond = async () => {
// /__host/<host><path>
const [, , host, ...rest] = (req.url ?? "/").split("/");
const path = `/${rest.join("/")}`;
const recorded: RecordedRequest = {
method: req.method ?? "GET",
host: host ?? "",
path,
headers: req.headers,
body,
json: () => JSON.parse(body),
};
requests.push(recorded);
const handler = handlers.get(recorded.host);
const reply: Reply = handler
? await handler(recorded)
: { status: 404, text: "no fake for host" };
if (reply === "hang-up") {
req.socket.destroy();
return;
}
if (reply.delayMs) await new Promise((r) => setTimeout(r, reply.delayMs));
const headers: Record<string, string> = { ...(reply.headers ?? {}) };
let out = reply.text ?? "";
if (reply.json !== undefined) {
headers["content-type"] ??= "application/json";
out = JSON.stringify(reply.json);
}
res.writeHead(reply.status ?? 200, headers);
res.end(out);
};
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const origin = `http://127.0.0.1:${(server.address() as any).port}`;
const previous = getGlobalDispatcher();
const direct = new Agent();
class Router extends Agent {
override dispatch(
opts: Dispatcher.DispatchOptions,
handler: Dispatcher.DispatchHandler,
): boolean {
const url = new URL(opts.path, opts.origin as string);
if (!handlers.has(url.host)) return (previous as any).dispatch(opts, handler);
return direct.dispatch({ ...opts, origin, path: `/__host/${url.host}${opts.path}` }, handler);
}
}
setGlobalDispatcher(new Router());
return {
requests,
on(host, handler) {
handlers.set(host, handler);
},
async close() {
setGlobalDispatcher(previous);
await direct.close();
await new Promise<void>((r) => server.close(() => r()));
},
};
}
+76
View File
@@ -0,0 +1,76 @@
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { RedisContainer, type StartedRedisContainer } from "@testcontainers/redis";
import postgres from "postgres";
import type { GlobalSetupContext } from "vitest/node";
import { createDatabase, runMigrations } from "../../../src/db/index.js";
/**
* One Postgres and one Redis for the whole run.
*
* Every integration test file gets its own database, cloned from a migrated
* template (see `useInfra`), and its own Redis logical database, so files run
* in parallel without seeing each other's rows or keys. Starting a container
* per file cost several seconds each and was the main reason real-infra tests
* were rationed.
*/
export const TEMPLATE_DB = "notifkit_template";
declare module "vitest" {
export interface ProvidedContext {
pgAdminUrl: string;
redisBaseUrl: string;
/** Why the containers could not start, or "" when they did. */
infraError: string;
}
}
let pg: StartedPostgreSqlContainer | undefined;
let redis: StartedRedisContainer | undefined;
export default async function setup({ provide }: GlobalSetupContext) {
try {
await startContainers(provide);
} catch (err) {
// Without Docker the unit tests can still run. Each integration file then
// fails on its own with this reason, rather than every file in the run
// failing here — and rather than integration tests being skipped quietly.
if (process.env.CI) throw err;
provide("pgAdminUrl", "");
provide("redisBaseUrl", "");
provide("infraError", err instanceof Error ? err.message : String(err));
}
return async () => {
await Promise.all([pg?.stop(), redis?.stop()]);
};
}
async function startContainers(provide: GlobalSetupContext["provide"]) {
[pg, redis] = await Promise.all([
new PostgreSqlContainer("postgres:16-alpine").start(),
new RedisContainer("redis:7-alpine")
.withCommand(["redis-server", "--databases", "128"])
.start(),
]);
const adminUrl = pg!.getConnectionUri();
const admin = postgres(adminUrl, { max: 1, onnotice: () => {} });
await admin.unsafe(`CREATE DATABASE ${TEMPLATE_DB}`);
await admin.end();
const templateUrl = withDatabase(adminUrl, TEMPLATE_DB);
const { db, sql } = createDatabase({ url: templateUrl, maxConnections: 1 });
await runMigrations(db);
await sql.end();
provide("pgAdminUrl", adminUrl);
provide("redisBaseUrl", redis!.getConnectionUrl());
provide("infraError", "");
}
export function withDatabase(url: string, database: string): string {
const parsed = new URL(url);
parsed.pathname = `/${database}`;
return parsed.toString();
}
+136
View File
@@ -0,0 +1,136 @@
import { afterAll, beforeAll, inject } from "vitest";
import { randomBytes } from "node:crypto";
import postgres from "postgres";
import { Redis } from "ioredis";
import { drizzle } from "drizzle-orm/postgres-js";
import type { Db, Sql } from "@/db/index.js";
import * as schema from "@/db/schema.js";
import { registerCustomCommands } from "@/redis/index.js";
import { TEMPLATE_DB, withDatabase } from "./global-setup.js";
export interface Infra {
/** Connection string for this file's private database. */
dbUrl: string;
/** Connection string for this file's private Redis logical database. */
redisUrl: string;
sql: Sql;
db: Db;
/** A plain client with the Lua commands registered. */
redis: Redis;
/** Empties every table and the Redis database. */
reset(): Promise<void>;
}
/**
* Registers hooks that give the calling file a real, empty Postgres database
* (migrated) and a real, empty Redis database. The returned object is filled
* in by `beforeAll`, so read its fields inside hooks and tests, not at import.
*/
export function useInfra(): Infra {
const infra = {} as Infra;
let dbName = "";
let adminUrl = "";
beforeAll(async () => {
const infraError = inject("infraError");
if (infraError) {
throw new Error(
`Integration tests need Docker for Postgres and Redis, and it is not available: ${infraError}`,
);
}
adminUrl = inject("pgAdminUrl");
dbName = `t_${process.env.VITEST_POOL_ID ?? "0"}_${randomBytes(6).toString("hex")}`;
await withAdmin(adminUrl, async (admin) => {
// Two files cloning the template at the same moment can collide on its
// lock; a short retry absorbs that.
for (let attempt = 0; ; attempt++) {
try {
await admin.unsafe(`CREATE DATABASE ${dbName} TEMPLATE ${TEMPLATE_DB}`);
break;
} catch (err: any) {
if (attempt >= 20 || !String(err?.message).includes("being accessed")) throw err;
await new Promise((r) => setTimeout(r, 100 + Math.random() * 200));
}
}
});
infra.dbUrl = withDatabase(adminUrl, dbName);
// The production pool's 10s statement timeout exists to shed load; on a
// test machine running the chaos suites alongside, a trivial query can wait
// that long for CPU. The code under test still gets its own pools.
const sql = postgres(infra.dbUrl, {
max: 5,
onnotice: () => {},
connection: { statement_timeout: 60_000 as any },
});
const db = drizzle(sql, { schema });
infra.sql = sql;
infra.db = db;
const index = (Number(process.env.VITEST_POOL_ID ?? "1") % 127) + 1;
infra.redisUrl = `${inject("redisBaseUrl")}/${index}`;
infra.redis = new Redis(infra.redisUrl, { maxRetriesPerRequest: null });
registerCustomCommands(infra.redis);
await infra.redis.flushdb();
infra.reset = async () => {
const tables = await sql<{ tablename: string }[]>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public'`;
if (tables.length > 0) {
await sql.unsafe(
`TRUNCATE ${tables.map((t) => `"${t.tablename}"`).join(", ")} RESTART IDENTITY CASCADE`,
);
}
await infra.redis.flushdb();
};
}, 120_000);
afterAll(async () => {
infra.redis?.disconnect();
await infra.sql?.end({ timeout: 5 }).catch(() => {});
if (dbName) {
await withAdmin(adminUrl, (admin) =>
admin.unsafe(`DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`),
).catch(() => {});
}
}, 60_000);
return infra;
}
async function withAdmin<T>(url: string, fn: (admin: postgres.Sql) => Promise<T>): Promise<T> {
const admin = postgres(url, { max: 1, onnotice: () => {} });
try {
return await fn(admin);
} finally {
await admin.end({ timeout: 5 });
}
}
/** Polls `condition` until it holds, failing with `what` on timeout. */
export async function waitFor(
what: string,
condition: () => Promise<boolean> | boolean,
timeoutMs = 20_000,
intervalMs = 50,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastErr: unknown;
while (Date.now() < deadline) {
try {
if (await condition()) return;
} catch (err) {
lastErr = err;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(
`timed out after ${timeoutMs}ms waiting for: ${what}` +
(lastErr ? ` (last error: ${(lastErr as Error).message})` : ""),
);
}
/** Waits `ms`, for asserting that something does *not* happen. */
export function settle(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
+794
View File
@@ -0,0 +1,794 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { randomUUID } from "node:crypto";
import { STREAMS, buildStreamEvent } from "@/contracts/index.js";
import { globalEmitter } from "@/shared/index.js";
import { useInfra, waitFor, settle } from "./infra.js";
import {
RecordingTransport,
startNotifkit,
apiClient,
type Running,
type Api,
} from "./pipeline.js";
/**
* The delivery guarantees notifkit makes, checked end to end: a request goes
* in through the HTTP API, and the assertion is on what the provider was
* actually asked to send — never on which internal function was called.
*
* Run twice: once with every stage hopping through Redis streams, and once
* with the fused in-process pipeline, since the two take different code paths.
*/
export function definePipelineSuite(mode: { fused: boolean }) {
const infra = useInfra();
let app: Running;
// "bounce…" addresses hard-bounce: permanent, so delivery fails them at once
// rather than retrying.
const email = new RecordingTransport("email", {
behaviour: (task) =>
task.destination?.startsWith("bounce")
? { success: false, error: "mailbox unavailable", retryable: false }
: { success: true, providerMessageId: `pm-${task.taskId}` },
});
const sms = new RecordingTransport("sms");
const push = new RecordingTransport("push", {
behaviour: (task) =>
task.destination === "dead-token"
? { success: false, invalidToken: true }
: { success: true, providerMessageId: `push-${task.taskId}` },
});
// A provider that only takes one message every two seconds.
const whatsapp = new RecordingTransport("whatsapp", { limits: { limit: 1, windowSeconds: 2 } });
beforeAll(async () => {
app = await startNotifkit(infra, {
fusedPipeline: mode.fused,
providers: [email, sms, push, whatsapp],
env: {
RATE_LIMIT_PER_HOUR: "100000",
SEGMENT_MAX_USERS: "5",
UNSUBSCRIBE_SECRET: "an-unsubscribe-secret-for-tests",
PUBLIC_URL: "https://notify.example.com",
},
});
}, 120_000);
afterAll(async () => {
await app?.stop();
}, 60_000);
/** A fresh project with the usual templates, so tests never see each other's data. */
async function project() {
const p = await app.project();
const res = await p.api("PUT", "/v1/templates", {
templates: [
{
id: "receipt",
channel: "email",
content: {
subject: "Receipt for {{name}}",
html: "<p>Hi {{name}}</p>",
text: "Hi {{name}}, raw: {{{name}}}",
},
},
{
id: "news",
channel: "email",
topic: "newsletter",
content: { subject: "News", text: "Hello" },
},
{ id: "otp", channel: "sms", content: { text: "Code {{code}}" } },
],
});
expect(res.status).toBe(200);
return p;
}
async function addUser(api: Api, body: Record<string, unknown>) {
const res = await api("POST", "/v1/users", body);
expect(res.status).toBe(201);
}
async function notify(api: Api, body: Record<string, unknown>, headers?: Record<string, string>) {
const res = await api("POST", "/v1/notify", body, headers);
expect(res.status).toBe(202);
return res.body;
}
const allSent = (projectId: string) => [
...email.for(projectId),
...sms.for(projectId),
...push.for(projectId),
...whatsapp.for(projectId),
];
/**
* Nothing delivered after the pipeline has had time to act. A marker
* notification sent afterwards to a control user proves the pipeline is
* running, so an empty result is not just a slow one.
*/
async function expectNothingSentFor(
p: { id: string; api: Api },
filter: (t: any) => boolean = () => true,
) {
const control = `control-${randomUUID().slice(0, 6)}`;
await addUser(p.api, { id: control, email: `${control}@x.com` });
await notify(p.api, { user: control, template: "receipt", data: { name: "c" } });
await waitFor("control delivered", () =>
email.for(p.id).some((t) => t.recipientId === control),
);
await settle(300);
expect(
allSent(p.id)
.filter((t) => t.recipientId !== control)
.filter(filter),
).toEqual([]);
}
describe(`pipeline (${mode.fused ? "fused" : "streamed"})`, () => {
it("delivers one rendered email per notify, and records it", async () => {
const p = await project();
await addUser(p.api, { id: "u1", email: "u1@x.com" });
await notify(p.api, { user: "u1", template: "receipt", data: { name: "<Al>\r\nBcc: x" } });
await waitFor("delivered", () => email.for(p.id).length === 1);
const [task] = email.for(p.id);
expect(task!.destination).toBe("u1@x.com");
expect(task!.renderedContent.content).toEqual({
subject: "Receipt for <Al> Bcc: x",
html: "<p>Hi &lt;Al&gt;\r\nBcc: x</p>",
text: "Hi <Al>\r\nBcc: x, raw: <Al>\r\nBcc: x",
});
await waitFor("logged", async () => {
const rows =
await infra.sql`SELECT kind, status FROM message_logs WHERE task_id = ${task!.taskId} ORDER BY kind`;
return rows.length === 2;
});
const rows =
await infra.sql`SELECT kind, status, provider_message_id FROM message_logs WHERE task_id = ${task!.taskId} ORDER BY kind`;
expect(rows.map((r) => [r.kind, r.status])).toEqual([
["attempt", "delivered"],
["dispatched", "dispatched"],
]);
expect(rows[0]!.provider_message_id).toBe(`pm-${task!.taskId}`);
const status = await p.api("GET", `/v1/notifications/${encodeURIComponent(task!.taskId)}`);
expect(status.status).toBe(200);
expect(status.body.status).toBe("delivered");
const [outbox] =
await infra.sql`SELECT provider_message_id FROM delivery_outbox WHERE task_id = ${task!.taskId}`;
expect(outbox!.provider_message_id).toBe(`pm-${task!.taskId}`);
await settle(300);
expect(email.for(p.id)).toHaveLength(1);
});
it("sends once per requested channel and once per address", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: ["a@x.com", "b@x.com"], phone: "+15550001" });
await notify(p.api, { user: "u", template: "receipt", channels: ["email", "sms"] });
await waitFor("three sends", () => allSent(p.id).length === 3);
expect(
email
.for(p.id)
.map((t) => t.destination)
.sort(),
).toEqual(["a@x.com", "b@x.com"]);
expect(sms.for(p.id).map((t) => t.destination)).toEqual(["+15550001"]);
await settle(300);
expect(allSent(p.id)).toHaveLength(3);
});
it("creates inline users on the fly", async () => {
const p = await project();
await notify(p.api, { user: { id: "inline", email: "inline@x.com" }, template: "receipt" });
await waitFor("delivered", () => email.for(p.id).length === 1);
expect(email.for(p.id)[0]!.destination).toBe("inline@x.com");
});
it("reaches every member of a segment exactly once, and only in this project", async () => {
const p = await project();
const other = await project();
for (const id of ["s1", "s2", "s3"])
await addUser(p.api, { id, email: `${id}@x.com`, segments: ["vip"] });
await addUser(p.api, { id: "outsider", email: "out@x.com" });
await addUser(other.api, { id: "foreign", email: "foreign@x.com", segments: ["vip"] });
await notify(p.api, { segment: "vip", template: "receipt" });
await waitFor("3 delivered", () => email.for(p.id).length === 3);
await settle(500);
expect(
email
.for(p.id)
.map((t) => t.recipientId)
.sort(),
).toEqual(["s1", "s2", "s3"]);
expect(email.for(other.id)).toEqual([]);
});
it("reaches users subscribed to a topic", async () => {
const p = await project();
await addUser(p.api, {
id: "fan",
email: "fan@x.com",
preferences: { topics: { launches: true } },
});
await addUser(p.api, {
id: "meh",
email: "meh@x.com",
preferences: { topics: { launches: false } },
});
await notify(p.api, { topic: "launches", template: "receipt" });
await waitFor("delivered", () => email.for(p.id).length === 1);
expect(email.for(p.id)[0]!.recipientId).toBe("fan");
});
it("refuses a segment larger than the fan-out limit, and says so", async () => {
const p = await project();
for (let i = 0; i < 6; i++)
await addUser(p.api, { id: `big${i}`, email: `big${i}@x.com`, segments: ["huge"] });
const failures: unknown[][] = [];
const onFailed = (...args: unknown[]) => failures.push(args);
app.server.on("notification:failed", onFailed);
try {
await notify(p.api, { segment: "huge", template: "receipt" });
await waitFor("failure reported", () => failures.length > 0);
expect(String(failures[0]![1])).toMatch(/exceeds limit/);
await expectNothingSentFor(p);
} finally {
app.server.off("notification:failed", onFailed);
}
});
it("sends nothing to an unknown user", async () => {
const p = await project();
await notify(p.api, { user: "ghost", template: "receipt" });
await expectNothingSentFor(p);
});
it("sends nothing for a template that does not exist", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "u@x.com" });
await notify(p.api, { user: "u", template: "missing" });
await expectNothingSentFor(p);
});
it("sends once for repeated requests carrying the same idempotency key", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "u@x.com" });
const key = `order-${randomUUID()}`;
await Promise.all([
notify(p.api, { user: "u", template: "receipt" }, { "x-idempotency-key": key }),
notify(p.api, { user: "u", template: "receipt" }, { "x-idempotency-key": key }),
notify(p.api, { user: "u", template: "receipt" }, { "x-idempotency-key": key }),
]);
await waitFor("delivered", () => email.for(p.id).length >= 1);
await settle(1_000);
expect(email.for(p.id)).toHaveLength(1);
});
it("sends once when the same inbound event is read twice", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "u@x.com" });
const event = {
...buildStreamEvent(
"notification.requested",
{ projectId: p.id, target: { type: "user", userId: "u" }, templateId: "receipt" },
"test",
),
id: randomUUID(),
timestamp: new Date().toISOString(),
};
await infra.redis.xadd(STREAMS.INBOUND_NORMAL, "*", "data", JSON.stringify(event));
await infra.redis.xadd(STREAMS.INBOUND_NORMAL, "*", "data", JSON.stringify(event));
await waitFor("delivered", () => email.for(p.id).length >= 1);
await settle(1_000);
expect(email.for(p.id)).toHaveLength(1);
});
describe("legacy notification.created events", () => {
async function legacy(projectId: string, recipientId: string, templateId: string) {
const event = {
...buildStreamEvent(
"notification.created",
{ projectId, recipientId, channel: "email", templateId, payload: { name: "Legacy" } },
"test",
),
id: randomUUID(),
timestamp: new Date().toISOString(),
};
await infra.redis.xadd(STREAMS.INBOUND_NORMAL, "*", "data", JSON.stringify(event));
}
it("are still delivered", async () => {
const p = await project();
await addUser(p.api, { id: "old", email: "old@x.com" });
await legacy(p.id, "old", "receipt");
await waitFor("delivered", () => email.for(p.id).length === 1);
expect(email.for(p.id)[0]!.renderedContent.content).toMatchObject({
subject: "Receipt for Legacy",
});
});
it("respect the user's topic opt-outs", async () => {
const p = await project();
await addUser(p.api, {
id: "old",
email: "old@x.com",
preferences: { topics: { newsletter: false } },
});
await legacy(p.id, "old", "news");
await expectNothingSentFor(p);
});
});
describe("consent", () => {
it("does not send a topic's mail to a user who opted out of that topic", async () => {
const p = await project();
await addUser(p.api, {
id: "u",
email: "u@x.com",
preferences: { topics: { newsletter: false } },
});
await notify(p.api, { user: "u", template: "news" });
await expectNothingSentFor(p);
});
it("still sends transactional mail to that user", async () => {
const p = await project();
await addUser(p.api, {
id: "u",
email: "u@x.com",
preferences: { topics: { newsletter: false } },
});
await notify(p.api, { user: "u", template: "receipt" });
await waitFor("delivered", () => email.for(p.id).length === 1);
});
it("skips only the address that opted out of the topic", async () => {
const p = await project();
await addUser(p.api, {
id: "u",
contacts: [
{
channel: "email",
target: "work@x.com",
preferences: { topics: { newsletter: false } },
},
{ channel: "email", target: "home@x.com" },
],
});
await notify(p.api, { user: "u", template: "news" });
await waitFor("delivered", () => email.for(p.id).length >= 1);
await settle(500);
expect(email.for(p.id).map((t) => t.destination)).toEqual(["home@x.com"]);
});
it("does not use a channel the user switched off", async () => {
const p = await project();
await addUser(p.api, {
id: "u",
email: "u@x.com",
phone: "+1555",
preferences: { channels: { sms: false } },
});
await notify(p.api, { user: "u", template: "receipt", channels: ["email", "sms"] });
await waitFor("email delivered", () => email.for(p.id).length === 1);
await settle(500);
expect(sms.for(p.id)).toEqual([]);
});
it("never sends to a suppressed address, whatever its case, even when critical", async () => {
const p = await project();
const res = await p.api("POST", "/v1/suppressions", {
channel: "email",
target: "Blocked@X.com",
reason: "complained",
});
expect(res.status).toBe(201);
await addUser(p.api, { id: "u", email: "blocked@x.com" });
await notify(p.api, { user: "u", template: "receipt", priority: "critical" });
await expectNothingSentFor(p, (t) => t.recipientId === "u");
});
it("puts a working one-click unsubscribe on topic mail, and honours it", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "u@x.com" });
await notify(p.api, { user: "u", template: "news" });
await waitFor("delivered", () => email.for(p.id).length === 1);
const headers = email.for(p.id)[0]!.deliveryOptions.headers!;
expect(headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click");
const url = new URL(headers["List-Unsubscribe"]!.slice(1, -1));
expect(url.origin).toBe("https://notify.example.com");
// What a mail client does when the recipient presses the button.
const oneClick = await apiClient(app.baseUrl)(
"POST",
`${url.pathname}${url.search}`,
"List-Unsubscribe=One-Click",
{
"content-type": "application/x-www-form-urlencoded",
},
);
expect(oneClick.status).toBe(200);
const prefs = await p.api("GET", "/v1/users/u/preferences");
expect(prefs.body.topics).toEqual({ newsletter: false });
});
it("puts no unsubscribe link on transactional mail", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "u@x.com" });
await notify(p.api, { user: "u", template: "receipt" });
await waitFor("delivered", () => email.for(p.id).length === 1);
expect(email.for(p.id)[0]!.deliveryOptions.headers).toBeUndefined();
});
});
describe("throttling", () => {
it("caps non-critical sends per user at the project's limit", async () => {
const p = await project();
const patched = await app.admin("PATCH", `/v1/projects/${p.id}`, { throttleLimit: 2 });
expect(patched.status).toBe(200);
await addUser(p.api, { id: "u", email: "u@x.com" });
for (let i = 0; i < 4; i++) await notify(p.api, { user: "u", template: "receipt" });
await waitFor("two delivered", () => email.for(p.id).length === 2);
await settle(1_000);
expect(email.for(p.id)).toHaveLength(2);
});
it("lets critical sends through the per-user cap", async () => {
const p = await project();
await app.admin("PATCH", `/v1/projects/${p.id}`, { throttleLimit: 1 });
await addUser(p.api, { id: "u", email: "u@x.com" });
for (let i = 0; i < 3; i++)
await notify(p.api, { user: "u", template: "receipt", priority: "critical" });
await waitFor("three delivered", () => email.for(p.id).length === 3);
});
it("defers sends over a provider's rate limit instead of dropping them", async () => {
const p = await project();
await p.api("PUT", "/v1/templates", {
templates: [{ id: "wa", channel: "whatsapp", content: { text: "hi" } }],
});
await addUser(p.api, { id: "u", contacts: [{ channel: "whatsapp", target: "+1777" }] });
for (let i = 0; i < 3; i++)
await notify(p.api, { user: "u", template: "wa", channels: ["whatsapp"] });
await waitFor(
"all three eventually delivered",
() => whatsapp.for(p.id).length === 3,
60_000,
);
}, 90_000);
});
describe("fallback", () => {
it("moves to the next channel when the first fails", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "bounce@x.com", phone: "+1555" });
await notify(p.api, {
user: "u",
template: "receipt",
channels: ["email", "sms"],
fallback: true,
});
await waitFor("sms fallback delivered", () => sms.for(p.id).length === 1);
expect(email.for(p.id)).toEqual([]);
});
it("counts a notification once against the user's cap, however many channels it falls back through", async () => {
const p = await project();
await app.admin("PATCH", `/v1/projects/${p.id}`, { throttleLimit: 1 });
await addUser(p.api, { id: "u", email: "bounce-cap@x.com", phone: "+1555" });
await notify(p.api, {
user: "u",
template: "receipt",
channels: ["email", "sms"],
fallback: true,
});
await waitFor("sms fallback delivered", () => sms.for(p.id).length === 1);
// The cap itself still holds: a second notification is over it.
await notify(p.api, { user: "u", template: "receipt", channels: ["sms"] });
await settle(1_500);
expect(sms.for(p.id)).toHaveLength(1);
});
it("moves to the next channel when the user has no address on the first", async () => {
const p = await project();
await addUser(p.api, { id: "u", phone: "+1555" });
await notify(p.api, {
user: "u",
template: "receipt",
channels: ["email", "sms"],
fallback: true,
});
await waitFor("sms delivered", () => sms.for(p.id).length === 1, 10_000);
});
it("does not use the fallback when the first channel works", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "ok@x.com", phone: "+1555" });
await notify(p.api, {
user: "u",
template: "receipt",
channels: ["email", "sms"],
fallback: true,
});
await waitFor("email delivered", () => email.for(p.id).length === 1);
await settle(500);
expect(sms.for(p.id)).toEqual([]);
});
});
describe("failures", () => {
it("records a failed send and dead-letters it where the project can see and replay it", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "bounce-dlq@x.com" });
await notify(p.api, { user: "u", template: "receipt" });
await waitFor("failure logged", async () => {
const rows =
await infra.sql`SELECT status FROM message_logs WHERE project_id = ${p.id} AND status = 'failed'`;
return rows.length === 1;
});
let dlq: any;
await waitFor("visible in the project's DLQ", async () => {
dlq = await p.api("GET", "/v1/dlq");
return dlq.body.messages?.length === 1;
});
const entry = dlq.body.messages[0];
expect(entry.eventType).toBe("notification.dispatched");
expect(entry.payload.projectId).toBe(p.id);
// The provider recovers; replaying the entry now delivers it.
email.behaviour = (task) => ({ success: true, providerMessageId: `pm-${task.taskId}` });
try {
const replay = await p.api("POST", "/v1/dlq/replay", { id: entry.id });
expect(replay.status).toBe(200);
await waitFor("replayed and delivered", () => email.for(p.id).length === 1);
expect((await p.api("GET", "/v1/dlq")).body.messages).toEqual([]);
} finally {
email.behaviour = (task) =>
task.destination?.startsWith("bounce")
? { success: false, error: "mailbox unavailable", retryable: false }
: { success: true, providerMessageId: `pm-${task.taskId}` };
}
});
it("deactivates a push token the provider says is dead", async () => {
const p = await project();
await addUser(p.api, { id: "u", pushToken: ["dead-token", "live-token"] });
await notify(p.api, { user: "u", template: "receipt", channels: ["push"] });
await waitFor("live token delivered", () => push.for(p.id).length === 1);
expect(push.for(p.id)[0]!.destination).toBe("live-token");
await waitFor("dead token deactivated", async () => {
const contacts = await p.api("GET", "/v1/users/u/contacts");
return (
contacts.body.contacts.find((c: any) => c.target === "dead-token")?.active === false
);
});
});
});
describe("scheduling", () => {
it("holds a scheduled send until its time, then sends it once", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "u@x.com" });
const sendAt = new Date(Date.now() + 3_000);
await notify(p.api, { user: "u", template: "receipt", sendAt: sendAt.toISOString() });
await waitFor(
"parked",
async () =>
(await p.api("GET", "/v1/notifications/scheduled")).body.scheduled?.length === 1,
);
expect(email.for(p.id)).toEqual([]);
await waitFor("sent", () => email.for(p.id).length === 1, 20_000);
expect(Date.now()).toBeGreaterThanOrEqual(sendAt.getTime());
await waitFor(
"unparked",
async () =>
(await p.api("GET", "/v1/notifications/scheduled")).body.scheduled?.length === 0,
);
await settle(500);
expect(email.for(p.id)).toHaveLength(1);
}, 30_000);
it("does not send a scheduled notification that was canceled", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "u@x.com" });
await notify(p.api, {
user: "u",
template: "receipt",
sendAt: new Date(Date.now() + 3_000).toISOString(),
});
let scheduled: any[] = [];
await waitFor("parked", async () => {
scheduled = (await p.api("GET", "/v1/notifications/scheduled")).body.scheduled ?? [];
return scheduled.length === 1;
});
const cancel = await p.api(
"DELETE",
`/v1/notifications/${encodeURIComponent(scheduled[0].taskId)}`,
);
expect(cancel.status).toBe(200);
await settle(8_000);
expect(email.for(p.id)).toEqual([]);
}, 30_000);
describe("quiet hours", () => {
const hhmm = (d: Date) => d.toISOString().slice(11, 16);
const minutes = (n: number) => new Date(Date.now() + n * 60_000);
async function parkedSendTime(p: { api: Api }): Promise<number> {
let scheduled: any[] = [];
await waitFor("parked", async () => {
scheduled = (await p.api("GET", "/v1/notifications/scheduled")).body.scheduled ?? [];
return scheduled.length === 1;
});
return Date.parse(scheduled[0].payload.scheduledAt);
}
it("defers a send during the user's quiet hours until they end", async () => {
const p = await project();
const end = minutes(60);
await addUser(p.api, {
id: "u",
email: "u@x.com",
timezone: "UTC",
preferences: { quietHours: [{ start: hhmm(minutes(-60)), end: hhmm(end) }] },
});
await notify(p.api, { user: "u", template: "receipt" });
const at = await parkedSendTime(p);
expect(Math.abs(at - end.getTime())).toBeLessThan(61_000);
expect(email.for(p.id)).toEqual([]);
});
it("sends critical notifications during quiet hours", async () => {
const p = await project();
await addUser(p.api, {
id: "u",
email: "u@x.com",
timezone: "UTC",
preferences: { quietHours: [{ start: hhmm(minutes(-60)), end: hhmm(minutes(60)) }] },
});
await notify(p.api, { user: "u", template: "receipt", priority: "critical" });
await waitFor("delivered", () => email.for(p.id).length === 1);
});
it("keeps a send scheduled for next week on next week, even if it is quiet hours now", async () => {
const p = await project();
const nextWeek = new Date(Date.now() + 7 * 24 * 3600_000 + 3 * 3600_000);
await addUser(p.api, {
id: "u",
email: "u@x.com",
timezone: "UTC",
preferences: { quietHours: [{ start: hhmm(minutes(-60)), end: hhmm(minutes(60)) }] },
});
await notify(p.api, { user: "u", template: "receipt", sendAt: nextWeek.toISOString() });
expect(await parkedSendTime(p)).toBe(nextWeek.getTime());
});
it("defers a scheduled send that would land inside quiet hours", async () => {
const p = await project();
const quietStart = minutes(30);
const quietEnd = minutes(120);
await addUser(p.api, {
id: "u",
email: "u@x.com",
timezone: "UTC",
preferences: { quietHours: [{ start: hhmm(quietStart), end: hhmm(quietEnd) }] },
});
await notify(p.api, {
user: "u",
template: "receipt",
sendAt: minutes(60).toISOString(),
});
const at = await parkedSendTime(p);
expect(Math.abs(at - quietEnd.getTime())).toBeLessThan(61_000);
});
});
});
describe("reporting", () => {
it("attributes every message to its campaign", async () => {
const p = await project();
for (const id of ["c1", "c2"])
await addUser(p.api, { id, email: `${id}@x.com`, segments: ["camp"] });
await notify(p.api, { segment: "camp", template: "receipt", campaign: "spring-sale" });
await waitFor("two delivered", () => email.for(p.id).length === 2);
let stats: any;
await waitFor("stats", async () => {
stats = await p.api("GET", "/v1/campaigns/spring-sale/stats");
return stats.status === 200 && stats.body.totals.delivered === 2;
});
expect(stats.body.totals).toMatchObject({
sent: 2,
delivered: 2,
failed: 0,
deliveryRate: 100,
});
const list = await p.api("GET", "/v1/campaigns");
expect(list.body.campaigns).toEqual([
expect.objectContaining({ campaign: "spring-sale", messages: 2 }),
]);
});
it("pages through the delivery log without skipping or repeating a message", async () => {
const p = await project();
for (let i = 0; i < 12; i++)
await addUser(p.api, { id: `l${i}`, email: `l${i}@x.com`, segments: ["logs"] });
// SEGMENT_MAX_USERS is 5 in this file; send in batches of users instead.
for (let i = 0; i < 12; i++) await notify(p.api, { user: `l${i}`, template: "receipt" });
await waitFor("12 delivered", () => email.for(p.id).length === 12);
await waitFor("12 logged", async () => {
const { n } = (
await infra.sql`SELECT count(*)::int AS n FROM message_logs WHERE project_id = ${p.id} AND status = 'delivered'`
)[0]!;
return n === 12;
});
const seen: string[] = [];
let cursor: string | null = null;
for (let page = 0; page < 20; page++) {
const res: any = await p.api(
"GET",
`/v1/notifications/logs?limit=5${cursor ? `&cursor=${cursor}` : ""}`,
);
seen.push(...res.body.logs.map((l: any) => l.taskId));
cursor = res.body.nextCursor;
if (!cursor) break;
}
expect(new Set(seen).size).toBe(12);
expect(seen).toHaveLength(12);
});
it("emits delivery events on the server", async () => {
const p = await project();
await addUser(p.api, { id: "u", email: "u@x.com" });
const delivered: unknown[][] = [];
const on = (...args: unknown[]) => delivered.push(args);
app.server.on("delivery:delivered", on);
try {
await notify(p.api, { user: "u", template: "receipt" });
await waitFor("event", () => delivered.some((a) => a[3] === p.id));
const [taskId, providerMessageId, channel] = delivered.find((a) => a[3] === p.id)!;
expect(channel).toBe("email");
expect(providerMessageId).toBe(`pm-${taskId}`);
} finally {
app.server.off("delivery:delivered", on);
}
});
});
});
return { infra, app: () => app, globalEmitter };
}
+158
View File
@@ -0,0 +1,158 @@
// The package entry point first, as in a real install. The services import
// through this barrel while some modules also import each other directly;
// vitest's module runner snapshots `export *` at evaluation time, so loading a
// leaf module first (the API loads the queue directly) leaves the barrel
// missing exports. Node's own ESM loader binds them lazily and is unaffected.
import "@/index.js";
import { createServer } from "node:net";
import { NotifkitServer, type NotifkitOptions } from "@/server.js";
import type { DeliveryResult, Transport } from "@/transport/index.js";
import type { NotificationChannel, NotificationDispatchedPayload } from "@/contracts/index.js";
import type { Infra } from "./infra.js";
import { waitFor } from "./infra.js";
export const ADMIN_KEY = "integration-admin-key-0123456789";
type Behaviour = (task: NotificationDispatchedPayload) => Promise<DeliveryResult> | DeliveryResult;
/**
* A provider that records what it was asked to send. By default every send
* succeeds; `behaviour` can fail, time out or reject specific destinations.
*/
export class RecordingTransport implements Transport {
readonly sent: NotificationDispatchedPayload[] = [];
behaviour?: Behaviour;
constructor(
readonly channel: NotificationChannel,
options: { behaviour?: Behaviour; limits?: { limit: number; windowSeconds: number } } = {},
) {
this.behaviour = options.behaviour;
if (options.limits) (this as any).limits = options.limits;
}
async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {
const { signal: _signal, ...copy } = task as any;
const result = this.behaviour
? await this.behaviour(task)
: { success: true, providerMessageId: `pm-${task.taskId}` };
if (result.success) this.sent.push(structuredClone(copy));
return result;
}
for(projectId: string): NotificationDispatchedPayload[] {
return this.sent.filter((t) => t.projectId === projectId);
}
}
export async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = createServer();
srv.once("error", reject);
srv.listen(0, "127.0.0.1", () => {
const port = (srv.address() as any).port as number;
srv.close(() => resolve(port));
});
});
}
export interface Api {
(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>,
): Promise<{
status: number;
body: any;
headers: Headers;
}>;
}
export function apiClient(
baseUrl: string,
token?: string,
defaultHeaders: Record<string, string> = {},
): Api {
return async (method, path, body, headers = {}) => {
const res = await fetch(baseUrl + path, {
method,
headers: {
...(token ? { authorization: `Bearer ${token}` } : {}),
...(body !== undefined ? { "content-type": "application/json" } : {}),
...defaultHeaders,
...headers,
},
body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
});
const text = await res.text();
let parsed: any = text;
try {
parsed = text ? JSON.parse(text) : undefined;
} catch {
// not JSON — keep the text
}
return { status: res.status, body: parsed, headers: res.headers };
};
}
export interface Running {
server: NotifkitServer;
baseUrl: string;
admin: Api;
/** Creates a project through the API and returns a client authenticated as it. */
project(name?: string): Promise<{ id: string; apiKey: string; api: Api }>;
stop(): Promise<void>;
}
/**
* Starts a real NotifkitServer — API and workers in this process — against the
* file's private Postgres and Redis. Environment overrides are applied before
* start, since the services read their configuration once at boot.
*/
export async function startNotifkit(
infra: Infra,
options: Partial<NotifkitOptions> & { env?: Record<string, string> } = {},
): Promise<Running> {
const port = await freePort();
Object.assign(process.env, {
ADMIN_API_KEY: ADMIN_KEY,
HOST: "127.0.0.1",
LOG_FLUSH_INTERVAL_MS: "50",
...options.env,
});
const { env: _env, ...serverOptions } = options;
const server = new NotifkitServer({
services: ["api", "enricher", "engine", "scheduler", "delivery", "events"],
redisUrl: infra.redisUrl,
databaseUrl: infra.dbUrl,
logLevel: (process.env.TEST_LOG_LEVEL as any) ?? "silent",
autoMigrate: false,
port,
...serverOptions,
});
await server.start();
const baseUrl = `http://127.0.0.1:${port}`;
const services = serverOptions.services ?? ["api"];
if (services.includes("api") || services.includes("all")) {
await waitFor("api listening", async () => (await fetch(`${baseUrl}/live`)).ok, 10_000);
}
const admin = apiClient(baseUrl, ADMIN_KEY);
return {
server,
baseUrl,
admin,
async project(name = "test") {
const res = await admin("POST", "/v1/projects", { name });
if (res.status !== 201)
throw new Error(`create project failed: ${res.status} ${JSON.stringify(res.body)}`);
return { id: res.body.id, apiKey: res.body.apiKey, api: apiClient(baseUrl, res.body.apiKey) };
},
async stop() {
await server.stop();
},
};
}
+666
View File
@@ -0,0 +1,666 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { randomUUID } from "node:crypto";
import { IdempotencyGuard } from "@/idempotency/index.js";
import {
ApiRateLimiter,
UserThrottle,
ProjectSettingsCache,
calculateTokenBatchSize,
} from "@/rate-limiter/index.js";
import { ProviderThrottle } from "@/services/delivery/throttle.js";
import { RedisClient } from "@/redis/index.js";
import {
BaseWorker,
NonRetryableError,
LockHeldError,
startHealthReporter,
type ProcessResult,
} from "@/workers/index.js";
import {
createPriorityProducers,
createStreamConsumer,
shutdownWorker,
uniqueConsumerId,
} from "@/workers/bootstrap.js";
import { StreamConsumer, PendingMessageScanner, type StreamMessage } from "@/queue/index.js";
import { STREAMS, CONSUMER_GROUPS, buildStreamEvent } from "@/contracts/index.js";
import { ProjectRepository } from "@/repositories/index.js";
import { createLogger } from "@/logger/index.js";
import { globalEmitter } from "@/shared/index.js";
import { useInfra, waitFor, settle } from "./support/infra.js";
const infra = useInfra();
const logger = createLogger({ name: "test", level: "silent" });
beforeEach(async () => {
await infra.reset();
});
// ─── IdempotencyGuard ────────────────────────────────────────────────────────
describe("IdempotencyGuard", () => {
const guard = () =>
new IdempotencyGuard({ redis: infra.redis, keyPrefix: "t:idem", ttlSeconds: 60 });
it("grants a lease once, reports it locked to others, and completed after marking", async () => {
const g = guard();
expect(await g.acquireLease("m1", 30)).toBe("acquired");
expect(await g.acquireLease("m1", 30)).toBe("locked");
await g.markProcessed("m1");
expect(await g.acquireLease("m1", 30)).toBe("completed");
expect(await infra.redis.ttl("t:idem:m1")).toBeGreaterThan(30);
});
it("gives exactly one of many concurrent callers the lease", async () => {
const g = guard();
const results = await Promise.all(Array.from({ length: 50 }, () => g.acquireLease("race", 30)));
expect(results.filter((r) => r === "acquired")).toHaveLength(1);
expect(results.filter((r) => r === "locked")).toHaveLength(49);
});
it("lets a new holder in once an abandoned lease expires", async () => {
const g = guard();
expect(await g.acquireLease("crash", 1)).toBe("acquired");
await settle(1_100);
expect(await g.acquireLease("crash", 1)).toBe("acquired");
});
it("checkAndMark is true exactly once", async () => {
const g = guard();
const results = await Promise.all(Array.from({ length: 20 }, () => g.checkAndMark("once")));
expect(results.filter(Boolean)).toHaveLength(1);
expect(await g.isProcessed("once")).toBe(true);
expect(await g.isProcessed("never")).toBe(false);
});
it("unmark clears the marker so the id can be processed again", async () => {
const g = guard();
await g.acquireLease("again", 30);
await g.markProcessed("again");
await g.unmark("again");
expect(await g.isProcessed("again")).toBe(false);
expect(await g.acquireLease("again", 30)).toBe("acquired");
});
it("honours a custom TTL", async () => {
const g = guard();
await g.checkAndMark("ttl", 5);
const ttl = await infra.redis.ttl("t:idem:ttl");
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(5);
});
});
// ─── Rate limiting ──────────────────────────────────────────────────────────
describe("calculateTokenBatchSize", () => {
it("scales with the limit and stays within bounds", () => {
expect(calculateTokenBatchSize(0)).toBe(0);
expect(calculateTokenBatchSize(-5)).toBe(0);
expect(calculateTokenBatchSize(10)).toBe(1);
expect(calculateTokenBatchSize(60)).toBe(5);
expect(calculateTokenBatchSize(61)).toBe(10);
expect(calculateTokenBatchSize(6_000)).toBe(500);
expect(calculateTokenBatchSize(600_000)).toBe(500);
expect(calculateTokenBatchSize(600_000, 50)).toBe(50);
});
});
describe("ApiRateLimiter", () => {
it("admits exactly the limit in one window, even when requests arrive all at once", async () => {
const limiter = new ApiRateLimiter({ redis: infra.redis, windowMs: 60_000 });
const results = await Promise.all(Array.from({ length: 200 }, () => limiter.check("p1", 50)));
expect(results.filter(Boolean)).toHaveLength(50);
expect(await limiter.check("p1", 50)).toBe(false);
});
it("denies everything for a non-positive limit", async () => {
const limiter = new ApiRateLimiter({ redis: infra.redis });
expect(await limiter.check("p", 0)).toBe(false);
expect(await limiter.check("p", -1)).toBe(false);
});
it("keeps projects independent", async () => {
const limiter = new ApiRateLimiter({ redis: infra.redis });
for (let i = 0; i < 5; i++) await limiter.check("a", 5);
expect(await limiter.check("a", 5)).toBe(false);
expect(await limiter.check("b", 5)).toBe(true);
});
it("admits again once the window has fully passed", async () => {
const limiter = new ApiRateLimiter({ redis: infra.redis, windowMs: 500 });
// Align to the start of a bucket so the test does not straddle a boundary.
await settle(500 - (Date.now() % 500) + 5);
for (let i = 0; i < 3; i++) expect(await limiter.check("w", 3)).toBe(true);
expect(await limiter.check("w", 3)).toBe(false);
// Two windows later the previous bucket no longer weighs in.
await settle(1_000);
expect(await limiter.check("w", 3)).toBe(true);
});
it("fails open when Redis errors", async () => {
const broken = new RedisClient({ url: infra.redisUrl });
await broken.disconnect();
const limiter = new ApiRateLimiter({ redis: broken.native, logger });
expect(await limiter.check("p", 10)).toBe(true);
});
it("invalidate and clear drop local leases so the next check re-reads Redis", async () => {
const limiter = new ApiRateLimiter({ redis: infra.redis });
expect(await limiter.check("p", 600)).toBe(true); // leases a chunk locally
await infra.redis.flushdb();
limiter.invalidate("p");
limiter.clear();
expect(await limiter.check("p", 600)).toBe(true);
});
});
describe("ProviderThrottle", () => {
it("allows up to the provider's limit per window and says when to retry", async () => {
const throttle = new ProviderThrottle(infra.redis, logger);
const cfg = { limit: 5, windowSeconds: 60 };
const results = await Promise.all(Array.from({ length: 8 }, () => throttle.check("sms", cfg)));
const allowed = results.filter((r) => r.allowed);
const denied = results.filter((r) => !r.allowed);
expect(allowed).toHaveLength(5);
expect(denied).toHaveLength(3);
for (const d of denied) {
expect(d.retryAfterMs).toBeGreaterThan(0);
expect(d.retryAfterMs).toBeLessThanOrEqual(60_000);
}
});
it("counts channels separately", async () => {
const throttle = new ProviderThrottle(infra.redis);
const cfg = { limit: 1, windowSeconds: 60 };
expect((await throttle.check("sms", cfg)).allowed).toBe(true);
expect((await throttle.check("sms", cfg)).allowed).toBe(false);
expect((await throttle.check("email", cfg)).allowed).toBe(true);
});
});
describe("UserThrottle", () => {
it("never throttles critical sends", async () => {
const t = new UserThrottle({ redis: infra.redis, maxPerHour: 1 });
for (let i = 0; i < 5; i++) {
expect((await t.check("p", "u", "critical")).allowed).toBe(true);
}
expect(await infra.redis.exists("throttle:p:user:u")).toBe(0);
});
it("a project limit of 0 blocks every non-critical send", async () => {
const t = new UserThrottle({ redis: infra.redis, maxPerHour: 100 });
expect((await t.check("p", "u", "normal", { limit: 0 })).allowed).toBe(false);
expect((await t.check("p", "u", "critical", { limit: 0 })).allowed).toBe(true);
});
it("uses the project's window instead of the default", async () => {
const t = new UserThrottle({ redis: infra.redis, maxPerHour: 1, windowHours: 1 });
expect((await t.check("p", "u", "normal", { windowHours: 24 })).allowed).toBe(true);
const ttl = await infra.redis.ttl("throttle:p:user:u");
expect(ttl).toBeGreaterThan(3600);
});
it("ignores nonsensical overrides and falls back to the defaults", async () => {
const t = new UserThrottle({ redis: infra.redis, maxPerHour: 2 });
const r = await t.check("p", "u", "normal", { limit: -3, windowHours: 0 });
expect(r.limit).toBe(2);
expect(r.allowed).toBe(true);
});
});
describe("ProjectSettingsCache (backed by the real projects table)", () => {
async function project(throttleLimit: number | null) {
const id = randomUUID();
await infra.sql`INSERT INTO projects (id, name, throttle_limit) VALUES (${id}, 'p', ${throttleLimit})`;
return id;
}
it("loads settings, caches them, and picks up a change after invalidate", async () => {
const repo = new ProjectRepository(infra.db);
let loads = 0;
const cache = new ProjectSettingsCache((id) => {
loads++;
return repo.findThrottleSettings(id);
});
const id = await project(7);
expect((await cache.get(id)).throttleLimit).toBe(7);
await repo.updateSettings(id, { throttleLimit: 9 });
expect((await cache.get(id)).throttleLimit).toBe(7); // cached
cache.invalidate(id);
expect((await cache.get(id)).throttleLimit).toBe(9);
expect(loads).toBe(2);
});
it("runs one query for a burst of lookups on a cold project", async () => {
const repo = new ProjectRepository(infra.db);
let loads = 0;
const cache = new ProjectSettingsCache((id) => {
loads++;
return repo.findThrottleSettings(id);
});
const id = await project(3);
const results = await Promise.all(Array.from({ length: 25 }, () => cache.get(id)));
expect(results.every((r) => r.throttleLimit === 3)).toBe(true);
expect(loads).toBe(1);
});
it("treats an unknown project as having no overrides", async () => {
const repo = new ProjectRepository(infra.db);
const cache = new ProjectSettingsCache((id) => repo.findThrottleSettings(id));
expect(await cache.get(randomUUID())).toEqual({
throttleLimit: null,
throttleWindowHours: null,
});
});
it("does not cache a failed lookup", async () => {
let fail = true;
const cache = new ProjectSettingsCache(async () => {
if (fail) throw new Error("db down");
return { throttleLimit: 1, throttleWindowHours: null };
});
await expect(cache.get("p")).rejects.toThrow("db down");
fail = false;
expect((await cache.get("p")).throttleLimit).toBe(1);
});
it("does not let a lookup that raced an invalidate write stale settings", async () => {
let release!: (v: any) => void;
let calls = 0;
const cache = new ProjectSettingsCache(() => {
calls++;
if (calls === 1) return new Promise((r) => (release = r));
return Promise.resolve({ throttleLimit: 2, throttleWindowHours: null });
});
const stale = cache.get("p");
cache.invalidate("p");
release({ throttleLimit: 1, throttleWindowHours: null });
expect((await stale).throttleLimit).toBe(1);
expect((await cache.get("p")).throttleLimit).toBe(2);
cache.clear();
});
});
// ─── RedisClient ────────────────────────────────────────────────────────────
describe("RedisClient", () => {
it("reports health and stops reporting it after disconnect", async () => {
const client = new RedisClient({ url: infra.redisUrl, logger });
expect(await client.healthCheck()).toBe(true);
await client.disconnect();
await client.disconnect(); // idempotent
expect(await client.healthCheck()).toBe(false);
});
it("registers the Lua commands", async () => {
const client = new RedisClient({ url: infra.redisUrl });
await client.native.set("lock", "me");
expect(await client.native.releaseLock("lock", "someone-else")).toBe(0);
expect(await client.native.releaseLock("lock", "me")).toBe(1);
await client.disconnect();
});
it("shares one connection per URL and closes it with the last holder", async () => {
const url = `${infra.redisUrl}?shared=${randomUUID()}`;
const a = RedisClient.shared({ url });
const b = RedisClient.shared({ url });
expect(a.native).toBe(b.native);
await a.disconnect();
await a.disconnect(); // a second release must not steal b's reference
expect(await b.healthCheck()).toBe(true);
await b.disconnect();
expect(await b.healthCheck()).toBe(false);
const c = RedisClient.shared({ url });
expect(await c.healthCheck()).toBe(true);
expect(c.native).not.toBe(a.native);
await c.disconnect();
});
});
// ─── BaseWorker ─────────────────────────────────────────────────────────────
type Behaviour = (msg: StreamMessage, attempt?: number) => Promise<ProcessResult>;
class ScriptedWorker extends BaseWorker {
readonly seen: { n: number; attempt: number }[] = [];
inFlight = 0;
maxInFlight = 0;
constructor(
opts: ConstructorParameters<typeof BaseWorker>[0],
public behaviour: Behaviour,
) {
super(opts);
}
protected async process(msg: StreamMessage, attempt = 1): Promise<ProcessResult> {
this.inFlight++;
this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
try {
this.seen.push({ n: (msg.event.payload as any).n, attempt });
return await this.behaviour(msg, attempt);
} finally {
this.inFlight--;
}
}
}
describe("BaseWorker", () => {
const STREAM = STREAMS.ENRICHED_NORMAL;
const GROUP = CONSUMER_GROUPS.ENGINE;
const workers: BaseWorker[] = [];
function worker(
behaviour: Behaviour,
opts: { concurrency?: number; recoveryIntervalMs?: number; maxRetriesBeforeDlq?: number } = {},
) {
const consumer = new StreamConsumer({
redis: infra.redis,
stream: STREAM,
group: GROUP,
consumer: "w1",
dlqStream: STREAMS.DEAD_LETTER,
blockMs: 100,
batchSize: 20,
logger,
});
const pendingScanner = new PendingMessageScanner({
redis: infra.redis,
stream: STREAM,
group: GROUP,
consumer: "w1",
logger,
});
const w = new ScriptedWorker(
{ consumer, pendingScanner, logger, recoveryIntervalMs: 60_000, ...opts },
behaviour,
);
workers.push(w);
return w;
}
async function publish(n: number) {
const ev = {
...buildStreamEvent("t", { n }, "test"),
id: randomUUID(),
timestamp: new Date().toISOString(),
};
await infra.redis.xadd(STREAM, "*", "data", JSON.stringify(ev));
return ev;
}
const pending = async () => Number(((await infra.redis.xpending(STREAM, GROUP)) as any[])[0]);
const dlq = async () =>
(await infra.redis.xrange(STREAMS.DEAD_LETTER, "-", "+")).map((e) => JSON.parse(e[1][1]!));
afterEach(async () => {
await Promise.all(workers.splice(0).map((w) => w.stop()));
});
it("processes and acks every message, and counts them", async () => {
const w = worker(async () => {});
await w.start();
for (let n = 0; n < 10; n++) await publish(n);
await waitFor("10 processed", () => w.health().processedCount === 10);
expect(await pending()).toBe(0);
const h = w.health();
expect(h.state).toBe("running");
expect(h.errorCount).toBe(0);
expect(h.lastProcessedAt).not.toBeNull();
});
it("refuses to start twice", async () => {
const w = worker(async () => {});
await w.start();
await expect(w.start()).rejects.toThrow(/cannot start/);
});
it("never runs more than `concurrency` messages at once", async () => {
const w = worker(() => settle(30), { concurrency: 3 });
await w.start();
for (let n = 0; n < 15; n++) await publish(n);
await waitFor("15 processed", () => w.health().processedCount === 15);
expect(w.maxInFlight).toBeLessThanOrEqual(3);
expect(w.maxInFlight).toBeGreaterThan(1);
});
it("leaves a failed message pending and retries it through recovery", async () => {
let calls = 0;
const w = worker(
async () => {
if (++calls === 1) throw new Error("transient");
},
{ recoveryIntervalMs: 100 },
);
await w.start();
await publish(1);
await waitFor("retried and processed", () => w.health().processedCount === 1, 10_000);
expect(w.seen.map((s) => s.attempt)).toEqual([1, 2]);
expect(w.health().errorCount).toBe(1);
expect(await pending()).toBe(0);
});
it("dead-letters a message that keeps failing, once, and reports it", async () => {
const failed: string[] = [];
const onFailed = (id: string) => failed.push(id);
globalEmitter.on("notification:failed", onFailed);
try {
const w = worker(
async () => {
throw new Error("always");
},
{ recoveryIntervalMs: 50, maxRetriesBeforeDlq: 2 },
);
await w.start();
const ev = await publish(1);
await waitFor("dead-lettered", async () => (await dlq()).length === 1, 15_000);
await settle(300);
expect((await dlq()).map((d) => d.id)).toEqual([ev.id]);
expect(await pending()).toBe(0);
expect(w.seen.map((s) => s.attempt)).toEqual([1, 2]);
expect(failed).toHaveLength(1);
} finally {
globalEmitter.off("notification:failed", onFailed);
}
});
it("dead-letters a NonRetryableError immediately", async () => {
const w = worker(async () => {
throw new NonRetryableError("bad input");
});
await w.start();
const ev = await publish(1);
await waitFor("dead-lettered", async () => (await dlq()).length === 1);
expect((await dlq())[0].id).toBe(ev.id);
expect(await pending()).toBe(0);
expect(w.seen).toHaveLength(1);
});
it("leaves a message pending, without counting an error, when the lock is held", async () => {
const w = worker(async () => {
throw new LockHeldError();
});
await w.start();
await publish(1);
await waitFor("seen", () => w.seen.length === 1);
await settle(100);
expect(await pending()).toBe(1);
expect(w.health().errorCount).toBe(0);
});
it("leaves a message pending when process asks not to ack", async () => {
const w = worker(async () => ({ ack: false }));
await w.start();
await publish(1);
await waitFor("seen", () => w.seen.length === 1);
await settle(100);
expect(await pending()).toBe(1);
expect(w.health().processedCount).toBe(0);
});
it("stop() lets in-flight messages finish and ack", async () => {
const w = worker(() => settle(300));
await w.start();
await publish(1);
await waitFor("started", () => w.inFlight === 1);
await w.stop();
expect(w.health().state).toBe("stopped");
expect(w.health().processedCount).toBe(1);
expect(await pending()).toBe(0);
});
it("recover() reports the pending count through health()", async () => {
const w = worker(async () => {});
expect(w.health().pendingCount).toBeNull();
await infra.redis.xgroup("CREATE", STREAM, GROUP, "0", "MKSTREAM");
await w.recover();
expect(w.health().pendingCount).toBe(0);
});
describe("processInline", () => {
const ev = () => ({
...buildStreamEvent("t", { n: 1 }, "test"),
id: randomUUID(),
timestamp: new Date().toISOString(),
});
it("processes and counts a handed-over event", async () => {
const w = worker(async () => {});
await w.processInline(ev());
expect(w.health().processedCount).toBe(1);
expect(w.seen[0]!.attempt).toBe(1);
});
it("dead-letters a non-retryable failure instead of throwing it upstream", async () => {
const w = worker(async () => {
throw new NonRetryableError("nope");
});
const e = ev();
await w.processInline(e);
expect((await dlq()).map((d) => d.id)).toEqual([e.id]);
expect(w.health().errorCount).toBe(1);
});
it("swallows a held lock", async () => {
const w = worker(async () => {
throw new LockHeldError();
});
await expect(w.processInline(ev())).resolves.toBeUndefined();
expect(w.health().errorCount).toBe(0);
});
it("rethrows a retryable failure so the upstream entry stays pending", async () => {
const w = worker(async () => {
throw new Error("boom");
});
await expect(w.processInline(ev())).rejects.toThrow("boom");
});
});
});
// ─── Bootstrap helpers ──────────────────────────────────────────────────────
describe("bootstrap helpers", () => {
it("uniqueConsumerId never repeats", () => {
const ids = new Set(Array.from({ length: 1000 }, () => uniqueConsumerId("x")));
expect(ids.size).toBe(1000);
for (const id of ids) expect(id.startsWith("x-")).toBe(true);
});
it("createStreamConsumer pairs a consumer and scanner that agree", async () => {
const redis = new RedisClient({ url: infra.redisUrl });
const { consumer, pendingScanner } = createStreamConsumer(
{ redis, logger },
{
stream: [STREAMS.OUTBOUND_NORMAL, STREAMS.OUTBOUND_LOW],
group: CONSUMER_GROUPS.DELIVERY,
consumer: "d1",
batchSize: 5,
},
);
await consumer.ensureGroup();
await infra.redis.xadd(
STREAMS.OUTBOUND_LOW,
"*",
"data",
JSON.stringify({
...buildStreamEvent("t", {}, "s"),
id: randomUUID(),
timestamp: new Date().toISOString(),
}),
);
const it = consumer.readBatch();
const batch = (await it.next()).value!;
expect(batch[0]!.stream).toBe(STREAMS.OUTBOUND_LOW);
expect(await pendingScanner.getPendingCount()).toBe(1);
await consumer.nack(batch[0]!.id, batch[0]!.event, batch[0]!.stream);
expect(await infra.redis.xlen(STREAMS.DEAD_LETTER)).toBe(1);
await consumer.stop();
await redis.disconnect();
});
it("createPriorityProducers writes each lane to its own stream", async () => {
const producers = createPriorityProducers(infra.redis, logger, "OUTBOUND");
await producers.critical.publish(buildStreamEvent("t", {}, "s"));
await producers.normal.publish(buildStreamEvent("t", {}, "s"));
await producers.low.publish(buildStreamEvent("t", {}, "s"));
expect(await infra.redis.xlen(STREAMS.OUTBOUND_CRITICAL)).toBe(1);
expect(await infra.redis.xlen(STREAMS.OUTBOUND_NORMAL)).toBe(1);
expect(await infra.redis.xlen(STREAMS.OUTBOUND_LOW)).toBe(1);
});
it("shutdownWorker stops timers, the worker, and closes both connections", async () => {
const redis = new RedisClient({ url: infra.redisUrl });
const { createDatabase } = await import("@/db/index.js");
const { sql } = createDatabase({ url: infra.dbUrl, maxConnections: 1 });
await sql`SELECT 1`;
let ticks = 0;
const timer = setInterval(() => ticks++, 5);
let disconnected = false;
await shutdownWorker("test", {
logger,
timers: [timer, null],
subscriber: { disconnect: () => (disconnected = true) },
sql,
redis,
});
const after = ticks;
await settle(30);
expect(ticks).toBe(after);
expect(disconnected).toBe(true);
expect(await redis.healthCheck()).toBe(false);
await expect(sql`SELECT 1`).rejects.toThrow();
});
});
describe("startHealthReporter", () => {
it("publishes the worker's health to Redis with a short TTL", async () => {
const redis = new RedisClient({ url: infra.redisUrl });
const w = { health: () => ({ state: "running", processedCount: 3 }) } as any;
const timer = startHealthReporter("svc", w, redis, logger, 20);
try {
await waitFor("health key", async () => (await infra.redis.exists("notif:health:svc")) === 1);
const body = JSON.parse((await infra.redis.get("notif:health:svc"))!);
expect(body).toMatchObject({
service: "svc",
redis: true,
state: "running",
processedCount: 3,
});
const ttl = await infra.redis.ttl("notif:health:svc");
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(15);
} finally {
clearInterval(timer);
await redis.disconnect();
}
});
});
+356
View File
@@ -0,0 +1,356 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { randomUUID } from "node:crypto";
import { workflow } from "@/workflows/index.js";
import { STREAMS, buildStreamEvent } from "@/contracts/index.js";
import { useInfra, waitFor, settle } from "./support/infra.js";
import { RecordingTransport, startNotifkit, type Running, type Api } from "./support/pipeline.js";
/**
* Workflows end to end: definitions and triggers go in through the API, steps
* run in the workflow worker, events come back through the events worker, and
* the assertions are on what was delivered and on the instance's recorded state.
*/
const infra = useInfra();
let app: Running;
const email = new RecordingTransport("email");
// Code-defined workflows, registered before the server starts.
const runs: Record<string, number> = {};
workflow("onboarding", async ({ step, event }) => {
await step.notify({ template: "hello", data: { name: event.user.id } });
const paid = await step.waitForEvent("order.paid", {
timeout: "1h",
match: { userId: event.user.id },
});
await step.run("record", () => ({ paidAmount: paid?.amount ?? null }));
await step.notify({ template: "thanks" });
});
workflow("sleepy", async ({ step, event }) => {
await step.run("count", () => {
runs[event.tag] = (runs[event.tag] ?? 0) + 1;
return runs[event.tag];
});
await step.wait("2s");
await step.notify({ template: "hello" });
});
workflow("impatient", async ({ step }) => {
const got = await step.waitForEvent("never.happens", { timeout: "1s" });
await step.run("outcome", () => ({ timedOut: got === null }));
});
workflow("waits-an-hour", async ({ step }) => {
await step.wait("1h");
await step.notify({ template: "hello" });
});
workflow("broken", async ({ step }) => {
await step.run("explode", () => {
throw new Error("handler bug");
});
});
workflow("await-go", async ({ step }) => {
const r = await step.waitForEvent("go", { timeout: "1h" });
await step.run("after", () => ({ got: r }));
});
beforeAll(async () => {
app = await startNotifkit(infra, {
services: ["api", "enricher", "engine", "delivery", "scheduler", "events", "workflow"],
providers: [email],
env: { RATE_LIMIT_PER_HOUR: "100000" },
});
}, 120_000);
afterAll(async () => {
await app?.stop();
}, 60_000);
async function project() {
const p = await app.project();
await p.api("PUT", "/v1/templates", {
templates: [
{ id: "hello", channel: "email", content: { subject: "Hello {{name}}" } },
{ id: "thanks", channel: "email", content: { subject: "Thanks" } },
],
});
for (const id of ["alice", "bob"]) {
await p.api("POST", "/v1/users", { id, email: `${id}@x.com` });
}
return p;
}
async function trigger(api: Api, body: Record<string, unknown>) {
const res = await api("POST", "/v1/workflows/trigger", body);
expect(res.status).toBe(202);
return res.body.instanceId as string;
}
async function instance(api: Api, id: string) {
const res = await api("GET", `/v1/workflows/instances/${id}`);
return res.status === 200 ? res.body : null;
}
async function waitForStatus(api: Api, id: string, status: string, timeoutMs = 20_000) {
await waitFor(
`instance ${status}`,
async () => (await instance(api, id))?.status === status,
timeoutMs,
);
}
async function sendEvent(api: Api, name: string, properties: Record<string, unknown>) {
const res = await api("POST", "/v1/events", { name, properties });
expect(res.status).toBe(202);
}
describe("code-defined workflows", () => {
it("notifies, sleeps on an event that matches its user, then carries on", async () => {
const p = await project();
const id = await trigger(p.api, { name: "onboarding", user: "alice" });
await waitFor("welcome sent", () => email.for(p.id).length === 1);
expect(email.for(p.id)[0]!.renderedContent.content).toEqual({ subject: "Hello alice" });
await waitForStatus(p.api, id, "pending");
// Someone else's payment must not wake alice's workflow.
await sendEvent(p.api, "order.paid", { userId: "bob", amount: 1 });
await settle(1_500);
expect(email.for(p.id)).toHaveLength(1);
await sendEvent(p.api, "order.paid", { userId: "alice", amount: 42 });
await waitForStatus(p.api, id, "completed");
await waitFor("thanks sent", () => email.for(p.id).length === 2);
const inst = await instance(p.api, id);
expect(inst.steps.map((s: any) => s.action)).toEqual([
"notify",
"waitForEvent",
"run",
"notify",
]);
expect(inst.steps[2].output).toEqual({ paidAmount: 42 });
expect(inst.waiters).toEqual([]);
});
it("records a notify step's ids as the notification that was actually sent", async () => {
const p = await project();
const id = await trigger(p.api, { name: "onboarding", user: "alice" });
await waitFor("sent", () => email.for(p.id).length === 1);
await waitFor("step recorded", async () => (await instance(p.api, id))?.steps.length >= 1);
const notifyStep = (await instance(p.api, id)).steps[0];
const task = email.for(p.id)[0]!;
// The engine derives task ids from the id of the request event.
expect(task.taskId.startsWith(`${notifyStep.output.notificationId}:`)).toBe(true);
});
it("files a step's delivery under its workflow instance, so the log can be filtered by it", async () => {
const p = await project();
const id = await trigger(p.api, { name: "onboarding", user: "alice" });
await waitFor("sent", () => email.for(p.id).length === 1);
const byInstance = async () =>
(await p.api("GET", `/v1/notifications/logs?workflowInstanceId=${id}`)).body.logs as any[];
await waitFor("logged under the instance", async () => (await byInstance()).length === 1);
expect((await byInstance())[0]).toMatchObject({
taskId: email.for(p.id)[0]!.taskId,
status: "delivered",
});
// Another instance's filter must not pick it up.
const other = (await p.api("GET", `/v1/notifications/logs?workflowInstanceId=${randomUUID()}`))
.body.logs;
expect(other).toEqual([]);
});
it("wakes after a wait, without re-running completed steps", async () => {
const p = await project();
const tag = randomUUID();
const id = await trigger(p.api, { name: "sleepy", user: "alice", input: { tag } });
await waitForStatus(p.api, id, "pending");
expect(email.for(p.id)).toEqual([]);
await waitForStatus(p.api, id, "completed", 30_000);
await waitFor("sent after waking", () => email.for(p.id).length === 1);
expect(runs[tag]).toBe(1);
}, 40_000);
it("resumes with null when an awaited event times out", async () => {
const p = await project();
const id = await trigger(p.api, { name: "impatient", user: "alice" });
await waitForStatus(p.api, id, "completed", 30_000);
const inst = await instance(p.api, id);
expect(inst.steps.find((s: any) => s.action === "run").output).toEqual({ timedOut: true });
}, 40_000);
it("does not cut a wait short when a stray resume arrives", async () => {
const p = await project();
const id = await trigger(p.api, { name: "waits-an-hour", user: "alice" });
await waitForStatus(p.api, id, "pending");
await infra.redis.xadd(
STREAMS.WORKFLOW_INBOUND,
"*",
"data",
JSON.stringify({
...buildStreamEvent(
"workflow.resumed",
{
projectId: p.id,
instanceId: id,
name: "waits-an-hour",
input: { user: { id: "alice" } },
},
"test",
),
id: randomUUID(),
timestamp: new Date().toISOString(),
}),
);
await settle(2_000);
expect((await instance(p.api, id)).status).toBe("pending");
expect(email.for(p.id)).toEqual([]);
});
it("marks an instance failed when its handler throws", async () => {
const p = await project();
const id = await trigger(p.api, { name: "broken", user: "alice" });
await waitForStatus(p.api, id, "failed");
});
it("does not lose a resume that arrives while the instance is still suspending", async () => {
const p = await project();
// The interleaving under test: another process holds the instance (it is
// mid-way through suspending on waitForEvent) when the events worker has
// already matched the event, filled the step in, and published the resume.
const [row] = await infra.sql`
INSERT INTO workflow_instances (project_id, name, status, input)
VALUES (${p.id}, 'await-go', 'running', ${JSON.stringify({ user: { id: "alice" } })}::jsonb) RETURNING id`;
const id = row!.id as string;
await infra.sql`
INSERT INTO workflow_steps (project_id, instance_id, step_index, action, output)
VALUES (${p.id}, ${id}, '0', 'waitForEvent', ${JSON.stringify({ orderId: "o1" })}::jsonb)`;
await infra.redis.set(`lock:workflow:${id}`, "other-process", "EX", 60);
await infra.redis.xadd(
STREAMS.WORKFLOW_INBOUND,
"*",
"data",
JSON.stringify({
...buildStreamEvent(
"workflow.resumed",
{ projectId: p.id, instanceId: id, name: "await-go", input: { user: { id: "alice" } } },
"events",
),
id: randomUUID(),
timestamp: new Date().toISOString(),
}),
);
await settle(500);
// The other process finishes suspending and lets go.
await infra.sql`UPDATE workflow_instances SET status = 'pending' WHERE id = ${id}`;
await infra.redis.del(`lock:workflow:${id}`);
await waitForStatus(p.api, id, "completed", 20_000);
const inst = await instance(p.api, id);
expect(inst.steps.find((s: any) => s.action === "run").output).toEqual({
got: { orderId: "o1" },
});
}, 30_000);
it("can be canceled while it sleeps, and then never resumes", async () => {
const p = await project();
const id = await trigger(p.api, {
name: "sleepy",
user: "alice",
input: { tag: randomUUID() },
});
await waitForStatus(p.api, id, "pending");
const res = await p.api("DELETE", `/v1/workflows/instances/${id}`);
expect(res.status).toBe(204);
expect((await p.api("DELETE", `/v1/workflows/instances/${id}`)).status).toBe(400);
await settle(8_000);
expect((await instance(p.api, id)).status).toBe("canceled");
expect(email.for(p.id)).toEqual([]);
}, 20_000);
it("does nothing for a workflow nobody defined", async () => {
const p = await project();
const id = await trigger(p.api, { name: "no-such-workflow", user: "alice" });
await settle(1_000);
expect(await instance(p.api, id)).toBeNull();
});
it("keeps instances private to their project", async () => {
const p = await project();
const other = await project();
const id = await trigger(p.api, { name: "broken", user: "alice" });
await waitForStatus(p.api, id, "failed");
expect((await other.api("GET", `/v1/workflows/instances/${id}`)).status).toBe(404);
expect((await other.api("DELETE", `/v1/workflows/instances/${id}`)).status).toBe(400);
});
});
describe("JSON-defined workflows", () => {
it("runs notify, wait and waitForEvent steps defined through the API", async () => {
const p = await project();
const created = await p.api("POST", "/v1/workflows", {
name: "json-flow",
steps: [
{ action: "notify", payload: { template: "hello", data: { name: "json" } } },
{
action: "waitForEvent",
event: "signup.done",
options: { timeout: "1h", match: { userId: "alice" } },
},
{ action: "notify", payload: { template: "thanks", user: "bob" } },
],
});
expect(created.status).toBe(201);
expect((await p.api("GET", "/v1/workflows")).body.workflows.map((w: any) => w.name)).toEqual([
"json-flow",
]);
const id = await trigger(p.api, { name: "json-flow", user: "alice" });
await waitFor("first notify", () => email.for(p.id).length === 1);
expect(email.for(p.id)[0]!.recipientId).toBe("alice");
await waitForStatus(p.api, id, "pending");
await sendEvent(p.api, "signup.done", { userId: "someone-else" });
await settle(1_500);
expect((await instance(p.api, id)).status).toBe("pending");
await sendEvent(p.api, "signup.done", { userId: "alice" });
await waitForStatus(p.api, id, "completed");
await waitFor("second notify, to the step's own target", () => email.for(p.id).length === 2);
expect(email.for(p.id)[1]!.recipientId).toBe("bob");
});
it("redefining a workflow replaces its steps", async () => {
const p = await project();
await p.api("POST", "/v1/workflows", {
name: "v",
steps: [{ action: "notify", payload: { template: "hello" } }],
});
await p.api("POST", "/v1/workflows", {
name: "v",
steps: [{ action: "notify", payload: { template: "thanks" } }],
});
const id = await trigger(p.api, { name: "v", user: "alice" });
await waitForStatus(p.api, id, "completed");
await waitFor("sent", () => email.for(p.id).length === 1);
expect(email.for(p.id)[0]!.templateId).toBe("thanks");
});
it("rejects an invalid definition", async () => {
const p = await project();
const res = await p.api("POST", "/v1/workflows", {
name: "bad",
steps: [{ action: "teleport" }],
});
expect(res.status).toBe(400);
});
});
+15 -10
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { globalEmitter } from "@/shared/index.js";
import { EnricherWorker } from "@/services/enricher/main.js";
import { CircuitBreaker } from "@/shared/index.js";
import { StreamConsumer } from "@/queue/index.js";
@@ -51,17 +52,21 @@ describe("Phase 3 Reliability Features", () => {
},
};
await worker.process(message as any);
const failures: unknown[][] = [];
const onFailed = (...args: unknown[]) => failures.push(args);
globalEmitter.on("notification:failed", onFailed);
try {
await worker.process(message as any);
} finally {
globalEmitter.off("notification:failed", onFailed);
}
// Verify it emitted a failure event instead of processing
expect(mockProducer.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: "notification.failed",
payload: expect.objectContaining({
error: expect.stringContaining("exceeds limit"),
}),
}),
);
// Reported as a failure, and nothing fanned out. (A `notification.failed`
// on the enriched stream is not something the engine can read.)
expect(failures).toHaveLength(1);
expect(String(failures[0]![1])).toContain("exceeds limit");
expect(mockProducer.publish).not.toHaveBeenCalled();
expect(mockProducer.publishBatch).not.toHaveBeenCalled();
});
});
+4 -2
View File
@@ -295,6 +295,7 @@ describe("Repository mapping and guards", () => {
mockDb.queueDelete([]); // suppressions
mockDb.queueDelete([]); // messageLogs
mockDb.queueDelete([]); // workflowInstances
mockDb.queueDelete([]); // workflowDefinitions
mockDb.queueDelete([{ id: "proj-1" }]); // projects
await expect(repo.delete("proj-1")).resolves.toBe(true);
@@ -302,6 +303,7 @@ describe("Repository mapping and guards", () => {
mockDb.queueDelete([]); // suppressions
mockDb.queueDelete([]); // messageLogs
mockDb.queueDelete([]); // workflowInstances
mockDb.queueDelete([]); // workflowDefinitions
mockDb.queueDelete([]); // projects
await expect(repo.delete("ghost")).resolves.toBe(false);
});
@@ -313,8 +315,8 @@ describe("Repository mapping and guards", () => {
mockDb.queueSelect([{ id: "u-1" }, { id: "u-2" }]);
// 2. Child user tables deletes (6 deletes)
for (let i = 0; i < 6; i++) mockDb.queueDelete([]);
// 3. Project child tables deletes (3 deletes)
for (let i = 0; i < 3; i++) mockDb.queueDelete([]);
// 3. Project child tables deletes (4 deletes)
for (let i = 0; i < 4; i++) mockDb.queueDelete([]);
// 4. Final delete of project returns deleted row
mockDb.queueDelete([{ id: "proj-1" }]);
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type * as Bootstrap from "@/workers/bootstrap.js";
/**
* Every replica of a worker must join its consumer group under its own name.
* In containers each replica is PID 1, so a name built from the PID alone makes
* every replica the same group member. Starting a worker twice in one process
* reproduces that exactly: same PID, two starts.
*
* Startup is cut off right after the consumer is named, so nothing connects.
*/
const seen = vi.hoisted(() => [] as string[]);
vi.mock("@/workers/bootstrap.js", async (importOriginal) => {
const actual = await importOriginal<typeof Bootstrap>();
const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
return {
...actual,
createWorkerRuntime: () => ({ logger, redis: { native: {} }, sql: {}, db: {} }),
createStreamConsumer: (_runtime: unknown, options: { consumer: string }) => {
seen.push(options.consumer);
throw new Error("stop after naming the consumer");
},
};
});
import { startAiWorker } from "@/services/ai/main.js";
import { startEventWorker } from "@/services/events/main.js";
import { startSchedulerWorker } from "@/services/scheduler/main.js";
import { startWorkflowWorker } from "@/services/workflow/main.js";
beforeEach(() => {
seen.length = 0;
});
describe.each([
["ai", startAiWorker],
["events", startEventWorker],
["scheduler", startSchedulerWorker],
["workflow", startWorkflowWorker],
] as const)("%s worker", (name, start) => {
it("joins its consumer group under a name no other replica shares", async () => {
await expect(start()).rejects.toThrow("stop after naming the consumer");
await expect(start()).rejects.toThrow("stop after naming the consumer");
expect(seen).toHaveLength(2);
expect(seen[0]).not.toBe(seen[1]);
for (const id of seen) expect(id.startsWith(`${name}-`)).toBe(true);
});
});
+5 -1
View File
@@ -28,7 +28,11 @@ describe("AiWorker Edge Cases", () => {
};
mockGenerateAiContent = vi.fn().mockResolvedValue("generated_text");
mockTemplateCache = { getCachedTemplate: vi.fn().mockResolvedValue(null) };
mockDb = { insert: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis() };
mockDb = {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
};
worker = new AiWorker({
consumer: { ack: vi.fn(), nack: vi.fn() } as any,
+71 -25
View File
@@ -179,7 +179,12 @@ describe("BaseWorker Retry and DLQ Logic", () => {
await (worker as any).processWithTracking(msg);
expect(mockConsumer.nack).toHaveBeenCalledWith("msg-1", msg.event, undefined);
expect(mockConsumer.nack).toHaveBeenCalledWith(
"msg-1",
msg.event,
undefined,
expect.stringContaining("max retries exceeded"),
);
});
it("immediately moves to DLQ on attempt 1 when process throws NonRetryableError", async () => {
@@ -211,7 +216,12 @@ describe("BaseWorker Retry and DLQ Logic", () => {
await (worker as any).processWithTracking(msg);
expect(mockConsumer.nack).toHaveBeenCalledWith("msg-perm-1", msg.event, undefined);
expect(mockConsumer.nack).toHaveBeenCalledWith(
"msg-perm-1",
msg.event,
undefined,
"fatal: recipient account terminated",
);
});
it("processes successfully without touching Redis retry keys on happy path", async () => {
@@ -307,7 +317,7 @@ describe("BaseWorker Retry and DLQ Logic", () => {
});
await expect(worker.processInline(inlineEvent)).resolves.toBeUndefined();
expect(mockConsumer.deadLetter).toHaveBeenCalledWith(inlineEvent);
expect(mockConsumer.deadLetter).toHaveBeenCalledWith(inlineEvent, expect.any(String));
expect(mockConsumer.nack).not.toHaveBeenCalled();
});
@@ -1132,7 +1142,7 @@ describe("DeliveryWorker", () => {
);
});
it("handles channel fallback by publishing to the next channel in the fallbackChain if all providers fail", async () => {
it("handles channel fallback by publishing to the next channel in the fallbackChain once its attempts are spent", async () => {
const failingTransport = { send: vi.fn().mockRejectedValue(new Error("Fatal Error")) };
mockTransportRegistry.getAll.mockReturnValue([failingTransport]);
@@ -1151,6 +1161,7 @@ describe("DeliveryWorker", () => {
channel: "email",
priority: "normal",
deliveryOptions: {},
deliveryAttemptCount: 2, // the last of the default 3
fallbackChain: ["sms", "push"], // Fallback chain provided
recipient: {
id: "usr-2",
@@ -1378,7 +1389,9 @@ describe("DeliveryWorker", () => {
{ send: vi.fn().mockResolvedValue({ success: false, error: "mailbox full" }) },
]);
await expect(worker.process(dispatched() as any)).rejects.toThrow("mailbox full");
await expect(worker.process(dispatched({ deliveryAttemptCount: 2 }) as any)).rejects.toThrow(
"mailbox full",
);
expect(mockGlobalEmitter.emit).toHaveBeenCalledWith(
"delivery:failed",
"123e4567-e89b-12d3-a456-426614174001",
@@ -1393,7 +1406,9 @@ describe("DeliveryWorker", () => {
{ send: vi.fn().mockRejectedValue(new Error("smtp down")) },
]);
await expect(worker.process(dispatched() as any)).rejects.toThrow("smtp down");
await expect(worker.process(dispatched({ deliveryAttemptCount: 2 }) as any)).rejects.toThrow(
"smtp down",
);
expect(worker["idempotency"].unmark).toHaveBeenCalledWith(
"123e4567-e89b-12d3-a456-426614174001",
);
@@ -1487,6 +1502,9 @@ describe("DeliveryWorker", () => {
describe("provider rate limiting", () => {
const limitedTransport = (send: any) => ({ send, limits: { limit: 5, windowSeconds: 60 } });
/** The payload parked in Postgres, which is what the scheduler releases. */
const parkedPayload = () =>
(worker as any).db.insert.mock.results[0].value.values.mock.calls[0][0].payload;
it("reschedules the task instead of sending when the provider limit is hit", async () => {
const send = vi.fn();
@@ -1501,7 +1519,7 @@ describe("DeliveryWorker", () => {
const envelope = mockScheduledProducer.publish.mock.calls[0]![0];
expect(envelope.type).toBe("notification.scheduled");
expect(new Date(envelope.payload.scheduledAt).getTime()).toBeGreaterThan(Date.now());
expect(envelope.payload.throttleAttemptCount).toBe(1);
expect(parkedPayload().throttleAttemptCount).toBe(1);
});
it("counts each throttled attempt so the retries cannot loop forever", async () => {
@@ -1510,7 +1528,7 @@ describe("DeliveryWorker", () => {
await worker.process(dispatched({ throttleAttemptCount: 2 }) as any);
expect(mockScheduledProducer.publish.mock.calls[0]![0].payload.throttleAttemptCount).toBe(3);
expect(parkedPayload().throttleAttemptCount).toBe(3);
});
it("gives up once the throttled retries exceed maxAttempts", async () => {
@@ -1629,9 +1647,9 @@ describe("DeliveryWorker", () => {
// ── Circuit breaker ───────────────────────────────────────────────────────
/**
* The worker keys one breaker per `${channel}:${transport.constructor.name}`,
* so these transports are real classes. Two object literals would both report
* the name "Object" and quietly share a single breaker.
* The worker keys one breaker per provider: its channel and class name,
* numbered when a channel has two of one class. These transports are real
* classes so the keys read the way production ones do.
*/
describe("circuit breaker", () => {
class FlakyTransport {
@@ -1663,16 +1681,36 @@ describe("DeliveryWorker", () => {
expect(flaky.send).toHaveBeenCalledTimes(5);
});
it("reports an open breaker as a delivery failure instead of letting it escape", async () => {
it("parks a task an open breaker turned away for a later attempt", async () => {
const flaky = new FlakyTransport();
mockTransportRegistry.getAll.mockReturnValue([flaky]);
for (let i = 0; i < 5; i++) await attempt(dispatched());
mockScheduledProducer.publish.mockClear();
const err = await attempt(dispatched());
// An open circuit says the provider is down, not that the message is bad.
expect(err).toBeUndefined();
expect(flaky.send).toHaveBeenCalledTimes(5);
expect(mockScheduledProducer.publish).toHaveBeenCalledTimes(1);
expect(mockGlobalEmitter.emit).not.toHaveBeenCalledWith(
"delivery:failed",
expect.anything(),
"Circuit breaker is OPEN",
expect.anything(),
expect.anything(),
);
});
it("reports an open breaker on a task's last attempt as a delivery failure", async () => {
mockTransportRegistry.getAll.mockReturnValue([new FlakyTransport()]);
for (let i = 0; i < 5; i++) await attempt(dispatched());
const err = await attempt(dispatched());
const err = await attempt(dispatched({ deliveryAttemptCount: 2 }));
// A short-circuit is still a delivery failure: it must reach the emitter
// and come back as NonRetryableError, or the task retries forever against
// a provider the worker has already given up on.
expect(err).toBeInstanceOf(Error);
// Out of attempts, a short-circuit is a delivery failure like any other:
// it must reach the emitter and come back as NonRetryableError.
expect(err).toBeInstanceOf(NonRetryableError);
expect((err as Error).message).toBe("Circuit breaker is OPEN");
expect(mockGlobalEmitter.emit).toHaveBeenLastCalledWith(
"delivery:failed",
@@ -1766,6 +1804,9 @@ describe("DeliveryWorker", () => {
* a retryable throw would send an already-exhausted task around again.
*/
describe("fallback chain exhaustion", () => {
/** A task on the last of its attempts, so a failure is final. */
const lastAttempt = (payload: Record<string, unknown> = {}) =>
dispatched({ deliveryAttemptCount: 2, ...payload });
const allProvidersFail = () =>
mockTransportRegistry.getAll.mockReturnValue([
{ send: vi.fn().mockRejectedValue(new Error("Provider down")) },
@@ -1774,7 +1815,7 @@ describe("DeliveryWorker", () => {
it("ends the task when the only channel fails with nothing to fall back to", async () => {
allProvidersFail();
await expect(worker.process(dispatched() as any)).rejects.toBeInstanceOf(NonRetryableError);
await expect(worker.process(lastAttempt() as any)).rejects.toBeInstanceOf(NonRetryableError);
expect(mockEnrichedProducers.normal.publish).not.toHaveBeenCalled();
expect(mockGlobalEmitter.emit).toHaveBeenCalledWith(
@@ -1790,7 +1831,7 @@ describe("DeliveryWorker", () => {
allProvidersFail();
await expect(
worker.process(dispatched({ fallbackChain: [], recipient }) as any),
worker.process(lastAttempt({ fallbackChain: [], recipient }) as any),
).rejects.toBeInstanceOf(NonRetryableError);
expect(mockEnrichedProducers.normal.publish).not.toHaveBeenCalled();
@@ -1803,7 +1844,7 @@ describe("DeliveryWorker", () => {
// Without one the chain is inert, so the task has to fail terminally
// rather than look like it was handed on.
await expect(
worker.process(dispatched({ fallbackChain: ["sms", "push"] }) as any),
worker.process(lastAttempt({ fallbackChain: ["sms", "push"] }) as any),
).rejects.toBeInstanceOf(NonRetryableError);
expect(mockEnrichedProducers.normal.publish).not.toHaveBeenCalled();
@@ -1812,7 +1853,7 @@ describe("DeliveryWorker", () => {
it("clears the chain on the final hop instead of passing an empty array on", async () => {
allProvidersFail();
await worker.process(dispatched({ fallbackChain: ["sms"], recipient }) as any);
await worker.process(lastAttempt({ fallbackChain: ["sms"], recipient }) as any);
const published = mockEnrichedProducers.normal.publish.mock.calls[0]![0];
expect(published.payload.channel).toBe("sms");
@@ -1824,7 +1865,7 @@ describe("DeliveryWorker", () => {
allProvidersFail();
// Hop one: email fails and hands the task to sms with the chain spent.
await worker.process(dispatched({ fallbackChain: ["sms"], recipient }) as any);
await worker.process(lastAttempt({ fallbackChain: ["sms"], recipient }) as any);
const next = mockEnrichedProducers.normal.publish.mock.calls[0]![0].payload;
expect(next.channel).toBe("sms");
@@ -1832,7 +1873,7 @@ describe("DeliveryWorker", () => {
mockEnrichedProducers.normal.publish.mockClear();
await expect(
worker.process(
dispatched({
lastAttempt({
channel: next.channel,
fallbackChain: next.fallbackChain,
destination: "+15551234567",
@@ -3265,6 +3306,7 @@ describe("DeliveryWorker", () => {
channel: "email",
destination: "test@example.com",
priority: "normal",
deliveryAttemptCount: 2,
fallbackChain: ["sms"],
recipient: { phone: "+1234567890" },
},
@@ -3304,8 +3346,10 @@ describe("DeliveryWorker", () => {
},
};
// process should throw the error so the worker retry logic handles it
await expect(worker.process(msg as any)).rejects.toThrow("Circuit breaker is OPEN");
// Parked through the scheduler for a later attempt, not failed.
await expect(worker.process(msg as any)).resolves.toBeUndefined();
expect(mockScheduledProducer.publish).toHaveBeenCalledTimes(1);
expect(mockIdempotency.markProcessed).not.toHaveBeenCalled();
// Ensure it was NOT fallback-published
expect(mockEnrichedProducers.normal.publish).not.toHaveBeenCalled();
});
@@ -3332,6 +3376,7 @@ describe("DeliveryWorker", () => {
channel: "email",
destination: "test@example.com",
priority: "normal",
deliveryAttemptCount: 2,
fallbackChain: ["sms"],
recipient: { phone: "+15551234567" },
},
@@ -3436,6 +3481,7 @@ describe("DeliveryWorker", () => {
event: {
payload: {
taskId: "task-all-fail",
deliveryAttemptCount: 2,
projectId: "proj-1",
enrichedEventId: "evt-1",
recipientId: "usr-1",
+6 -8
View File
@@ -296,8 +296,10 @@ describe("Workflow Engine Edge Cases", () => {
expect(mockDb.set).toHaveBeenLastCalledWith({ status: "completed" });
});
it("handles step.waitForEvent timeout (returns null)", async () => {
// 3rd run: waking up from waitForEvent timeout
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: {
@@ -332,13 +334,9 @@ describe("Workflow Engine Edge Cases", () => {
await worker.process(msg);
// Should NOT have published the success notification
expect(mockNotificationProducer.publish).not.toHaveBeenCalled();
// But should have completed
expect(mockDb.update).toHaveBeenCalled();
// Wait, mockDb.set could be called multiple times now (first running, then completed)
// We expect the LAST call to be completed
expect(mockDb.set).toHaveBeenLastCalledWith({ status: "completed" });
// 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 () => {
+2
View File
@@ -11,6 +11,8 @@ export default defineConfig({
environment: "node",
include: ["tests/**/*.test.ts", "packages/**/*.test.ts"],
exclude: ["**/node_modules/**", "**/dist/**", "profiling/**", ".agents/**"],
// Starts the Postgres and Redis every file under tests/integration shares.
globalSetup: ["tests/integration/support/global-setup.ts"],
testTimeout: 1200_000,
hookTimeout: 1200_000,
coverage: {