mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { renderWithTemplate } from "@/templates/render.js";
|
|
import { UserThrottle, ProjectSettingsCache } from "@/rate-limiter/index.js";
|
|
import { PendingMessageScanner } from "@/queue/index.js";
|
|
import { isRetryableAiError, PermanentAiError } from "@/services/ai/main.js";
|
|
import { LUA_SCHEDULER_POLL } from "@/redis/index.js";
|
|
import { EngineWorker } from "@/services/engine/main.js";
|
|
import { SchedulerWorker } from "@/services/scheduler/main.js";
|
|
import { registry } from "@/index.js";
|
|
import { createMockDb } from "./helpers/mock-db.js";
|
|
|
|
// Each test here pins a bug that shipped to main. See CODE_REVIEW_2.txt.
|
|
|
|
describe("renderWithTemplate (P1-5: template injection / JSON break)", () => {
|
|
it("does not break when a variable contains a double quote", () => {
|
|
const template = { content: { subject: "Hi {{name}}", text: "Welcome {{name}}" } };
|
|
const out = renderWithTemplate(template, { name: 'John "JD" Smith' });
|
|
|
|
expect(out.content.subject).toBe('Hi John "JD" Smith');
|
|
expect(out.content.text).toBe('Welcome John "JD" Smith');
|
|
});
|
|
|
|
it("does not let a variable forge sibling template fields", () => {
|
|
const template = { content: { subject: "Hi {{name}}", text: "hello" } };
|
|
// Payload crafted to close the JSON string and inject htmlBody.
|
|
const out = renderWithTemplate(template, {
|
|
name: '","htmlBody":"<script>alert(1)</script>","x":"',
|
|
});
|
|
|
|
expect(out.content).not.toHaveProperty("htmlBody");
|
|
expect(out.content).not.toHaveProperty("x");
|
|
expect(Object.keys(out.content).sort()).toEqual(["subject", "text"]);
|
|
});
|
|
|
|
it("HTML-escapes values landing in html fields but not in text fields", () => {
|
|
const template = { content: { html: "<p>{{bio}}</p>", text: "{{bio}}" } };
|
|
const out = renderWithTemplate(template, { bio: "<script>x</script>" });
|
|
|
|
expect(out.content.html).toBe("<p><script>x</script></p>");
|
|
expect(out.content.text).toBe("<script>x</script>");
|
|
});
|
|
|
|
it("strips CR/LF from header fields to prevent header injection", () => {
|
|
const template = { content: { subject: "Order {{ref}}" } };
|
|
const out = renderWithTemplate(template, { ref: "A1\r\nBcc: victim@example.com" });
|
|
|
|
expect(out.content.subject).toBe("Order A1 Bcc: victim@example.com");
|
|
expect(out.content.subject).not.toContain("\n");
|
|
});
|
|
|
|
it("interpolates nested objects and arrays", () => {
|
|
const template = { content: { blocks: [{ title: "{{a}}" }], meta: { k: "{{b}}" } } };
|
|
const out = renderWithTemplate(template, { a: "one", b: "two" });
|
|
|
|
expect(out.content).toEqual({ blocks: [{ title: "one" }], meta: { k: "two" } });
|
|
});
|
|
});
|
|
|
|
describe("UserThrottle (P1-8: tenant isolation)", () => {
|
|
it("namespaces the throttle key by project", async () => {
|
|
const redis: any = { throttleUser: vi.fn().mockResolvedValue(1) };
|
|
const throttle = new UserThrottle({ redis, maxPerHour: 5 });
|
|
|
|
await throttle.check("proj-a", "user-1", "normal");
|
|
await throttle.check("proj-b", "user-1", "normal");
|
|
|
|
const keyA = redis.throttleUser.mock.calls[0][0];
|
|
const keyB = redis.throttleUser.mock.calls[1][0];
|
|
|
|
expect(keyA).toBe("throttle:proj-a:user:user-1");
|
|
expect(keyB).toBe("throttle:proj-b:user:user-1");
|
|
expect(keyA).not.toBe(keyB);
|
|
});
|
|
});
|
|
|
|
describe("PendingMessageScanner (P2-13: cross-stream claims)", () => {
|
|
it("claims each pending id against the stream it is actually pending on", async () => {
|
|
const xpending = vi.fn().mockImplementation(async (stream: string) => {
|
|
if (stream === "stream-1") return [["1-0", "c", 99_999, 1]];
|
|
if (stream === "stream-2") return [["2-0", "c", 99_999, 1]];
|
|
return [];
|
|
});
|
|
const xclaim = vi.fn().mockResolvedValue([]);
|
|
const redis: any = { xpending, xclaim };
|
|
|
|
const scanner = new PendingMessageScanner({
|
|
redis,
|
|
stream: ["stream-1", "stream-2"] as any,
|
|
group: "g" as any,
|
|
consumer: "c",
|
|
});
|
|
|
|
await scanner.autoclaim(1, 10);
|
|
|
|
// Ids gathered from stream-1 must never be claimed against stream-2.
|
|
for (const call of xclaim.mock.calls) {
|
|
const [stream, , , , ...ids] = call;
|
|
if (stream === "stream-1") expect(ids).toEqual(["1-0"]);
|
|
if (stream === "stream-2") expect(ids).toEqual(["2-0"]);
|
|
}
|
|
expect(xclaim).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("tags pending entries with their stream", async () => {
|
|
const redis: any = {
|
|
xpending: vi
|
|
.fn()
|
|
.mockImplementation(async (stream: string) =>
|
|
stream === "stream-1" ? [["1-0", "c", 5, 1]] : [["2-0", "c", 5, 1]],
|
|
),
|
|
};
|
|
const scanner = new PendingMessageScanner({
|
|
redis,
|
|
stream: ["stream-1", "stream-2"] as any,
|
|
group: "g" as any,
|
|
consumer: "c",
|
|
});
|
|
|
|
const entries = await scanner.getPendingEntries(10);
|
|
expect(entries.map((e) => e.stream)).toEqual(["stream-1", "stream-2"]);
|
|
});
|
|
});
|
|
|
|
describe("LUA_SCHEDULER_POLL (P0-3: missing visibility timeout)", () => {
|
|
it("defaults the visibility timeout so a 3-arg call cannot error", () => {
|
|
expect(LUA_SCHEDULER_POLL).toContain("tonumber(ARGV[3]) or 0");
|
|
});
|
|
});
|
|
|
|
describe("AI error classification (P1-11: no re-billing on permanent errors)", () => {
|
|
it("treats a PermanentAiError as non-retryable", () => {
|
|
expect(isRetryableAiError(new PermanentAiError("bad prompt"))).toBe(false);
|
|
});
|
|
|
|
it("retries timeouts, rate limits and 5xx", () => {
|
|
expect(isRetryableAiError({ name: "TimeoutError" })).toBe(true);
|
|
expect(isRetryableAiError({ statusCode: 429 })).toBe(true);
|
|
expect(isRetryableAiError({ statusCode: 503 })).toBe(true);
|
|
});
|
|
|
|
it("does not retry client errors", () => {
|
|
expect(isRetryableAiError({ statusCode: 400 })).toBe(false);
|
|
expect(isRetryableAiError({ statusCode: 401 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Worker redis wiring (P0-2: .native.native)", () => {
|
|
// These workers are handed the ioredis client, exactly as the bootstraps do.
|
|
// Passing a RedisClient wrapper here would resolve to undefined at call time.
|
|
const logger: any = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
child: vi.fn().mockReturnThis(),
|
|
};
|
|
|
|
it("SchedulerWorker calls zadd directly on the injected client", async () => {
|
|
const redis: any = { zadd: vi.fn() };
|
|
const worker = new SchedulerWorker({
|
|
consumer: { ack: vi.fn(), nack: vi.fn() } as any,
|
|
pendingScanner: {} as any,
|
|
logger,
|
|
registry,
|
|
redis,
|
|
});
|
|
|
|
await worker.process({
|
|
id: "m1",
|
|
event: {
|
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
type: "notification.scheduled",
|
|
timestamp: new Date().toISOString(),
|
|
metadata: { traceId: "t", source: "test", retryCount: 0 },
|
|
payload: {
|
|
projectId: "11111111-1111-4111-8111-111111111111",
|
|
enrichedEventId: "22222222-2222-4222-8222-222222222222",
|
|
taskId: "33333333-3333-4333-8333-333333333333",
|
|
scheduledAt: new Date(Date.now() + 60_000).toISOString(),
|
|
},
|
|
},
|
|
} as any);
|
|
|
|
expect(redis.zadd).toHaveBeenCalledTimes(1);
|
|
expect(redis.zadd.mock.calls[0][0]).toBe("notif:scheduled:zset:3");
|
|
});
|
|
|
|
it("EngineWorker caches the scheduled payload directly on the injected client", async () => {
|
|
const mockSet = vi.fn().mockReturnThis();
|
|
const redis: any = {
|
|
set: vi.fn(),
|
|
pipeline: vi.fn().mockReturnValue({
|
|
set: mockSet,
|
|
zadd: vi.fn().mockReturnThis(),
|
|
exec: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
};
|
|
const scheduledProducer = { publish: vi.fn(), publishBatch: vi.fn() };
|
|
|
|
const worker = new EngineWorker({
|
|
consumer: { ack: vi.fn(), nack: vi.fn() } as any,
|
|
pendingScanner: {} as any,
|
|
logger,
|
|
registry,
|
|
idempotency: {
|
|
checkAndMark: vi.fn().mockResolvedValue(true),
|
|
markProcessed: vi.fn().mockResolvedValue(true),
|
|
unmark: vi.fn().mockResolvedValue(undefined),
|
|
} as any,
|
|
throttle: { check: vi.fn().mockResolvedValue({ allowed: true, count: 1 }) } as any,
|
|
projectSettings: new ProjectSettingsCache(async () => null),
|
|
redis,
|
|
templateCache: { getCachedTemplate: vi.fn().mockResolvedValue(null) } as any,
|
|
aiPendingProducer: { publish: vi.fn() } as any,
|
|
scheduledProducer: scheduledProducer as any,
|
|
outboundProducers: { normal: { publish: vi.fn() } } as any,
|
|
globalEmitter: { emit: vi.fn() } as any,
|
|
contactRepo: {
|
|
findActiveByUserIds: vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
new Map([["usr-1", [{ channel: "email", target: "alice@example.com", active: true }]]]),
|
|
),
|
|
} as any,
|
|
db: createMockDb().db,
|
|
});
|
|
|
|
await worker.process({
|
|
id: "m1",
|
|
event: {
|
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
type: "notification.enriched",
|
|
timestamp: new Date().toISOString(),
|
|
metadata: { traceId: "t", source: "test", retryCount: 0 },
|
|
payload: {
|
|
projectId: "11111111-1111-4111-8111-111111111111",
|
|
rawEventId: "22222222-2222-4222-8222-222222222222",
|
|
recipientId: "usr-1",
|
|
channel: "email",
|
|
priority: "normal",
|
|
templateVariables: {},
|
|
recipient: {
|
|
id: "usr-1",
|
|
locale: "en",
|
|
timezone: "UTC",
|
|
preferences: { optedOut: false, channels: [] },
|
|
},
|
|
scheduledAt: new Date(Date.now() + 60_000).toISOString(),
|
|
},
|
|
},
|
|
} as any);
|
|
|
|
expect(worker["db"].insert).toHaveBeenCalledTimes(1);
|
|
expect(scheduledProducer.publish).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("Engine → Scheduler contract (P0-2 + P0-4 regression)", () => {
|
|
it("the scheduled event the engine emits is accepted by the real schema", async () => {
|
|
const logger: any = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
child: vi.fn().mockReturnThis(),
|
|
};
|
|
const mockSet = vi.fn().mockReturnThis();
|
|
const engineRedis: any = {
|
|
set: vi.fn(),
|
|
pipeline: vi.fn().mockReturnValue({
|
|
set: mockSet,
|
|
zadd: vi.fn().mockReturnThis(),
|
|
exec: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
};
|
|
const scheduledProducer = { publish: vi.fn(), publishBatch: vi.fn() };
|
|
|
|
const engine = new EngineWorker({
|
|
consumer: { ack: vi.fn(), nack: vi.fn() } as any,
|
|
pendingScanner: {} as any,
|
|
logger,
|
|
registry,
|
|
idempotency: {
|
|
checkAndMark: vi.fn().mockResolvedValue(true),
|
|
markProcessed: vi.fn().mockResolvedValue(true),
|
|
unmark: vi.fn().mockResolvedValue(undefined),
|
|
} as any,
|
|
throttle: { check: vi.fn().mockResolvedValue({ allowed: true, count: 1 }) } as any,
|
|
projectSettings: new ProjectSettingsCache(async () => null),
|
|
redis: engineRedis,
|
|
templateCache: { getCachedTemplate: vi.fn().mockResolvedValue(null) } as any,
|
|
aiPendingProducer: { publish: vi.fn() } as any,
|
|
scheduledProducer: scheduledProducer as any,
|
|
outboundProducers: { normal: { publish: vi.fn() } } as any,
|
|
globalEmitter: { emit: vi.fn() } as any,
|
|
contactRepo: {
|
|
findActiveByUserIds: vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
new Map([["usr-1", [{ channel: "email", target: "alice@example.com", active: true }]]]),
|
|
),
|
|
} as any,
|
|
db: createMockDb().db,
|
|
});
|
|
|
|
await engine.process({
|
|
id: "m1",
|
|
event: {
|
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
type: "notification.enriched",
|
|
timestamp: new Date().toISOString(),
|
|
metadata: { traceId: "t", source: "test", retryCount: 0 },
|
|
payload: {
|
|
projectId: "11111111-1111-4111-8111-111111111111",
|
|
rawEventId: "22222222-2222-4222-8222-222222222222",
|
|
recipientId: "usr-1",
|
|
channel: "email",
|
|
priority: "normal",
|
|
templateVariables: {},
|
|
recipient: {
|
|
id: "usr-1",
|
|
locale: "en",
|
|
timezone: "UTC",
|
|
preferences: { optedOut: false, channels: [] },
|
|
},
|
|
scheduledAt: new Date(Date.now() + 60_000).toISOString(),
|
|
},
|
|
},
|
|
} as any);
|
|
|
|
const envelope = scheduledProducer.publish.mock.calls[0]![0];
|
|
const parsed = registry.safeParsePayload("notification.scheduled", envelope.payload);
|
|
expect(parsed.success).toBe(true);
|
|
|
|
// And the scheduler accepts it end to end.
|
|
const schedRedis: any = { zadd: vi.fn() };
|
|
const scheduler = new SchedulerWorker({
|
|
consumer: { ack: vi.fn(), nack: vi.fn() } as any,
|
|
pendingScanner: {} as any,
|
|
logger,
|
|
registry,
|
|
redis: schedRedis,
|
|
});
|
|
|
|
await scheduler.process({
|
|
id: "m2",
|
|
event: {
|
|
...envelope,
|
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
} as any);
|
|
expect(schedRedis.zadd).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|