Testing
The notifkit repository already runs 676 test cases. The first half of this page groups them by what they defend, so you don't re-prove covered failures. The second half covers what only you can test: your templates, consent data and transports.
What is already covered
The repository ships 676 test cases across 31 files: 673 unit and integration cases plus the three chaos scenarios below. Vitest enforces a 80% coverage floor on lines, functions, branches and statements, failing the build below it. All of it runs on every commit.
Grouped by what the tests defend, not by filename. Counts are cases; most assert several things.
The HTTP surface: 180 cases
| Suite | Cases | What it pins down |
|---|---|---|
api-suppressions.test.ts |
58 | The compliance handlers: suppression list, create and delete; campaign listing and the full stats funnel including the engagementTracked warnings; notification status and cancellation; and both unsubscribe routes (that GET only renders and never opts anyone out, that POST does, and that a tampered or unsigned token is rejected identically to a valid one for a stranger). |
api.test.ts |
52 | Every handler in isolation. Auth token extraction from both header forms, all CRUD endpoints, the four notify targeting modes, workflow trigger including the inline-user shorthand, the limit/channel/topic filters on template and workflow listing, and router parameter decoding. |
api-admin.test.ts |
30 | The operational routes: health degrading independently for Redis and Postgres, worker heartbeats going silent, stream depths and the computed success rate, the whole DLQ lifecycle (list, replay onto the right priority stream, delete, and each one's failure mode), workflow definition create and cancel, scheduled payloads, user details, and the SSE stream, including that it drops events belonging to another project and unsubscribes on client disconnect. |
api.integration.test.ts |
27 | The same endpoints against a real Postgres and Redis in throwaway containers: project bootstrap with the admin key, key rejection, template sync, user and contact lifecycle, preferences, logs, workflow trigger, event ingest, and all three probe routes. |
http.test.ts |
13 | The transport plumbing under the handlers: body reads across chunked streams, the 5 MB cap returning 413 and destroying the request, malformed JSON becoming a 400, Content-Length computed in bytes instead of characters so a multibyte subject line does not truncate, and Zod issues shaped into the documented error body. |
The pipeline workers: 158 cases
| Suite | Cases | What it pins down |
|---|---|---|
workers.test.ts |
101 | The decisions each worker makes. Quiet hours across timezones and DST, including the release instant it computes. Per-user throttling, priority bypass, and per-project overrides with their settings cache. The engine's gate sequence and a 12-case suppression gate, which pins down that a lookup failure never degrades to "nothing is suppressed". Delivery across 27 cases: provider ordering, provider-level rate limiting, the push path and invalid-token deactivation, channel fallback, retry counting and the hand-off to the DLQ. Plus enricher fan-out, the scheduler ZSET, AI routing, and the event worker's waiter matching with nested dot-notation criteria. |
scheduler-poll.test.ts |
22 | The poll loop on its own. The lock is taken with NX and an expiry so a crashed poller cannot hold it, is released only if still owned, and is released even when the poll throws. All 16 shards are claimed with a visibility timeout, a shard whose Lua errored is skipped instead of failing the poll, and a full shard asks for another round while shards that merely add up do not. Then release: routing by priority, the fallback when a priority has no producer, and cleanup of a corrupt payload, a payload missing from Postgres, and a failed Postgres delete. |
workers-workflow.test.ts |
21 | The workflow worker's edge cases: unknown event types and missing handlers, a lock it cannot acquire and a concurrent resume collision, every duration unit on step.wait and waitForEvent including 7d, an invalid unit failing the instance instead of expiring instantly, a timed-out step staying null on replay, step.run memoisation and its throw path, JSON workflows executed from the database, unknown step actions, and the lock always released in finally. |
workers-ai.test.ts |
14 | Prompt interpolation, generated content landing in the template, the per-notification prompt cap, invalid payloads dropped instead of retried, and error classification so a permanent failure is not retried and re-billed. |
Queue, delivery and shared primitives: 131 cases
| Suite | Cases | What it pins down |
|---|---|---|
shared-primitives.test.ts |
38 | The five building blocks everything else rests on, each tested for its failure behaviour instead of its happy path. AsyncSemaphore: FIFO ordering, permits handed straight to the next waiter, and an unbalanced release that must not bank a spare permit. LRUCache: eviction order, recency on read, per-entry TTL, and an expired entry freeing its slot. BatchProcessor: size and time flushes, per-index resolution, a whole batch rejecting together, and recovery on the next one. DataLoader: same-tick coalescing and per-key error slots. CircuitBreaker: opening at the threshold, single-flight probing, and re-opening on a failed probe. |
queue.test.ts |
36 | Consumer-group creation, acks and nacks, batch reads across multiple streams, the pending scanner and autoclaim, batch publishing, and the idempotency guard. |
registry.test.ts · providers.test.ts |
13 · 21 | Transport priority ordering and lookup by channel; the Resend and FCM transports against mocked SDKs, invalid-token detection, webhook signature verification including tampered and unsigned bodies, and the per-template sender: that a template's from wins, that the constructor's is used when it names none, and that a non-string falls back instead of being coerced. |
shared.test.ts · idempotency.test.ts |
10 · 7 | Id generation and the error hierarchy; the idempotency guard's SET NX semantics, custom TTLs, and the unconditional markProcessed the engine uses to close out a skipped message. |
race-conditions.test.ts |
6 | The same primitives under real contention, and the only suite where a passing assertion means "exactly one". 20 concurrent callers on one idempotency key admit exactly one; 25 concurrent reads of a cold project collapse into one database load, and a rejected in-flight load does not poison the callers behind it; 20 callers against an open breaker send one probe; 100 concurrent batch items each resolve to their own index; 50 tasks through a semaphore leave an active count of zero. |
Data, templates and compliance: 95 cases
| Suite | Cases | What it pins down |
|---|---|---|
repositories.units.test.ts |
36 | Every repository's mapping and its project guard, against a recording fake of the drizzle client so the assertion is on the query that was built. That an absent preference row means opted in; that contacts resolve in a single join instead of a query per user; that a template, instance, or key belonging to another project is invisible; that a project delete cascades across users, contacts, suppressions, logs and workflows; and that an API key is stored only as a hash. |
unsubscribe.test.ts |
27 | The signed-token scheme, mostly in the negative: a wrong secret, a tampered payload, a tampered signature, a signature of the wrong length, and six shapes of malformed token are all rejected without throwing. Tokens never expire, are deterministic, and are URL-safe. Then the RFC 8058 headers (both of them, angle-bracketed, with a token the verifier accepts and no doubled slash), and target normalisation, so a re-suppressed address collides on conflict instead of duplicating. |
repositories.mock.test.ts · repositories.test.ts |
9 · 5 | Serialization-failure retry on the bulk user upsert and its exhaustion path; then reads and writes against a real database, including that a lookup in one project cannot see another's rows. |
templates-cache.test.ts · templates.test.ts |
10 · 8 | Cache miss, fill, and the two invalidation paths, plus that a repository miss is not cached as null. Renderer registration and fallback, HTML escaping on by default, missing variables rendering empty instead of literal, and interpolation through nested objects and arrays. |
The SDK and the contracts: 109 cases
| Suite | Cases | What it pins down |
|---|---|---|
client.test.ts |
64 | Every method on NotifkitClient, asserting the path, verb, body and headers it puts on the wire, and that a non-2xx becomes an Error carrying the server's message. Covers the newer methods too: notification status and cancellation, scheduled messages, user profile and preferences, template querying, and system health, metrics and DLQ. |
workflows.test.ts · workflows-sdk.test.ts |
13 · 11 | Suspend on step.wait, deterministic replay skipping already-executed steps, waitForEvent resolving and timing out; then target resolution: segment, topic, user as string or object, an array rejected, and the fall-back to the instance's own user with a clear throw when there is nobody to send to. |
regressions.test.ts |
15 | Named after the defects they lock down. See below. |
contracts.test.ts · phase3.test.ts |
3 · 3 | Stream payload schemas accept valid events and reject malformed ones. |
The chaos suite
Three tests that assert not on logic but that messages survive failures. All need Docker and are slow on purpose.
| Test | What it does | What it asserts |
|---|---|---|
chaos/crash.test.ts |
Runs workers as separate child processes and SIGKILLs one to three of them at random, repeatedly, while 100 notifications are in flight. |
Every message is still delivered once the survivors reclaim the pending entries. |
chaos/recovery.test.ts |
Injects 5,000 messages while docker pauseing Postgres, Redis, or both at random intervals for a few seconds at a time. |
Nothing is lost across the freezes: injection retries and the pipeline drains afterwards. |
chaos/load.test.ts |
Pushes 10,000 messages through the full pipeline, sampling heap as it goes. | All 10,000 delivered, and heap growth stays under 1 GB: the leak check. |
Five files need a Docker daemon: the three chaos scenarios,
api.integration.test.ts and repositories.test.ts. The last two start
throwaway Postgres and Redis containers in beforeAll and don't self-skip, so
without Docker they fail on a hook timeout (after one and two minutes respectively) instead of
reporting "skipped". That's your environment, not the code.
The other 26 files, 641 cases, need no daemon and finish in under ten
seconds. npm test runs everything including chaos, with no script that excludes
it, so the Vitest timeout is 20 minutes. For the fast, hermetic set:
npx vitest run \
--exclude "tests/chaos/**" \
--exclude "tests/api.integration.test.ts" \
--exclude "tests/repositories.test.ts"
Regressions, pinned to the bug
regressions.test.ts names each case after the defect it pins, so a failure tells
you which old bug came back:
| Id | The bug it prevents |
|---|---|
P1-5 | Template injection. A variable containing a quote breaking the JSON, or forging a sibling template field. Also covers HTML-escaping values that land in html but not text, and stripping CR/LF so a value cannot inject a header. |
P1-8 | Throttle keys not namespaced by project, letting one tenant's volume throttle another's. |
P2-13 | Pending messages claimed against the wrong stream in multi-stream mode. |
P0-3 | The scheduler's Lua poll erroring on a three-argument call because the visibility timeout had no default. |
P1-11 | Retrying a permanent AI error and being billed again for it. Timeouts, rate limits and 5xx still retry; 4xx does not. |
P0-2 | A .native.native mis-reference in the worker Redis wiring. |
P0-2 + P0-4 | The engine and the scheduler drifting apart: the scheduled event the engine emits is validated against the real schema the scheduler reads it with, so a field added on one side cannot silently strand messages on the other. |
What the suite does not cover
These are the gaps your own tests should fill:
- Real provider APIs. Resend and FCM are mocked at the SDK boundary, so nothing proves your API key works or your domain is verified.
- Your templates. No test knows whether
{{trackingUrl}}is a key you actually send. - Your consent data. Whether the right users are opted out of the right topics depends on your data, not the engine.
- The transports you write. The registry is tested; your
send()is not. - The dashboard. The Vite SPA under
dashboard/has no suite in this count. packages/mcp, also without a suite. The endpoints it calls are covered; its tool definitions aren't.- Quiet hours and scheduling on a live clock. The release instant is computed and asserted, but no test sleeps until it arrives; the chaos and integration runs exercise the scheduler firing on the wall clock.
Running it yourself
git clone https://github.com/devkitshq/notifkit.git
cd notifkit
npm install
npm test # everything, with coverage
npx vitest run tests/workers.test.ts # one file, no Docker needed
npx vitest run tests/chaos # the slow ones, Docker required
npx vitest # watch mode
npx vitest list # print all 676 case names without running them
npx vitest run -t "suppression gate" # every case whose full name matches
vitest list is the fastest way to check "is this already covered?". Case names are
sentences about behaviour, so grepping them usually settles it.
The rest of this page covers what the suite can't.
Writing your own tests
Notification code is awkward to test: the outcome happens elsewhere, later, and often the right behaviour is that nothing was sent. notifkit gives you a seam at the transport, where nearly all your assertions belong.
The order to build it in
The next three sections give you the pieces, in order. Build them once and every later test is four lines:
- A capturing transport, which records instead of sending. No Docker, no provider account.
- A server fixture: one
NotifkitServerper test file, with a project bootstrapped and a client pointed at it. - A wait helper, since
notify()returns long before delivery and a bare assertion after the call always fails.
Then the most valuable assertion you can write is that no placeholder survived rendering:
it("leaves no unrendered placeholder", async () => {
email.reset();
await client.addUser("usr_1", [{ channel: "email", target: "[email protected]" }]);
const delivered = waitForDelivery(server);
await client.notify({
user: "usr_1",
template: "welcome",
channels: ["email"],
data: { name: "Alice", company: "Acme" },
});
await delivered;
expect(JSON.stringify(email.contents[0])).not.toContain("{{");
});
An unmatched placeholder renders as an empty string instead of throwing, so a data-key typo is silent in production and invisible to a test that only checks delivery happened. This two-line assertion catches the whole class.
Three levels
| Level | Needs | Answers |
|---|---|---|
| Transport double | Nothing | Did we ask for the right thing? Right template, right channel, right data. |
| Full pipeline | Docker + testcontainers | Did the rules apply? Opt-outs, quiet hours, fallback, rendering. |
| Contract | Provider sandbox | Does our transport speak the provider's dialect correctly? |
Most of your suite belongs at the first level. The second verifies parts you didn't write; a handful is worth having.
A capturing transport
The test double is a class that records instead of sending. The Delivery worker only knows the
Transport interface, so it substitutes cleanly for a real provider.
import type {
Transport, NotificationDispatchedPayload, DeliveryResult, NotificationChannel,
} from "notifkit";
export class CapturingTransport implements Transport {
readonly sent: NotificationDispatchedPayload[] = [];
constructor(
readonly channel: NotificationChannel = "email",
private readonly outcome: DeliveryResult = { success: true, providerMessageId: "test-1" },
) {}
async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {
this.sent.push(task);
return this.outcome;
}
// Convenience for assertions
get contents() {
return this.sent.map((t) => t.renderedContent.content as Record<string, string>);
}
reset() { this.sent.length = 0; }
}
Failure is one constructor argument away:
const flakyPush = new CapturingTransport("push", { success: false, error: "provider down" });
const deadToken = new CapturingTransport("push", { success: false, invalidToken: true });
Testing the whole pipeline
Without URLs, NotifkitServer starts throwaway Postgres and Redis containers, so an
end-to-end test needs no fixture of your own. Two prerequisites. First, install the
testcontainers packages: notifkit keeps them as external dev dependencies, out of the published
bundle, so installing notifkit doesn't bring them.
npm install -D @testcontainers/postgresql @testcontainers/redis
Second, containers depend on NODE_ENV: anything but production gets
them; production throws Missing required configuration. Set the
server's nodeEnv option explicitly, since CI often pins the ambient value to
production. Docker must be running either way.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { NotifkitServer, NotifkitClient } from "notifkit";
import { CapturingTransport } from "./capturing-transport.js";
const PORT = 34567;
const email = new CapturingTransport("email");
let server: NotifkitServer;
let client: NotifkitClient;
beforeAll(async () => {
process.env.ADMIN_API_KEY = "test-admin-key";
server = new NotifkitServer({
services: ["all"],
nodeEnv: "development",
port: PORT,
logLevel: "silent",
providers: [email],
});
await server.start();
// Bootstrap a project and take its key.
const res = await fetch(`http://localhost:${PORT}/v1/projects`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin-key" },
body: JSON.stringify({ name: "test" }),
});
const { apiKey } = await res.json();
client = new NotifkitClient({ baseUrl: `http://localhost:${PORT}`, apiKey });
await client.syncTemplates({
templates: [{
id: "welcome",
channel: "email",
topic: ["transactional"],
content: { subject: "Hi {{name}}", text: "Welcome, {{name}}." },
}],
});
}, 120_000);
afterAll(async () => { await server.stop(); });
Pulling images is slow on a cold machine. Raise the timeouts (testTimeout: 120_000
and hookTimeout: 240_000 are realistic) and share one server per file, not per test.
Waiting for something asynchronous
notify() returns before delivery, so a bare assertion after it always fails. Wait on
the server's events, not a sleep:
export function waitForDelivery(server: NotifkitServer, timeoutMs = 10_000) {
return new Promise<{ taskId: string; channel: string }>((resolve, reject) => {
const timer = setTimeout(() => {
server.off("delivery:delivered", onDone);
reject(new Error("no delivery within timeout"));
}, timeoutMs);
function onDone(taskId: string, _providerMessageId: string, channel: string) {
clearTimeout(timer);
server.off("delivery:delivered", onDone);
resolve({ taskId, channel });
}
server.on("delivery:delivered", onDone);
});
}
it("renders the template and delivers it", async () => {
await client.addUser("usr_1", [{ channel: "email", target: "[email protected]" }]);
const delivered = waitForDelivery(server);
await client.notify({
user: "usr_1",
template: "welcome",
channels: ["email"],
data: { name: "Alice" },
});
await delivered;
expect(email.sent).toHaveLength(1);
expect(email.sent[0]!.destination).toBe("[email protected]");
expect(email.contents[0]).toMatchObject({
subject: "Hi Alice",
text: "Welcome, Alice.",
});
});
Asserting that nothing was sent
Half of what you'll prove is a negative: the opt-out worked, quiet hours held. There's no delivery event to wait on, so listen for the skip:
function waitForSkip(server: NotifkitServer, timeoutMs = 10_000) {
return new Promise<{ reason: string }>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("no skip event")), timeoutMs);
server.once("notification:skipped", (payload: any) => {
clearTimeout(timer);
resolve(payload);
});
});
}
it("does not send to a user who opted out of the topic", async () => {
await client.addUser("usr_2", [{ channel: "email", target: "[email protected]" }], {
preferences: { topics: { transactional: false } },
});
email.reset();
const skipped = waitForSkip(server);
await client.notify({ user: "usr_2", template: "welcome", channels: ["email"] });
expect((await skipped).reason).toBe("user_opted_out");
expect(email.sent).toHaveLength(0);
});
| Event | Fires when |
|---|---|
delivery:delivered | A transport reported success. |
delivery:failed | Every transport for the channel failed, and no fallback remained. |
notification:skipped | A gate dropped it. Carries reason. |
notification:throttled | The per-user hourly cap was hit. |
notification:failed | Retries exhausted; the message went to the DLQ. |
notification:canceled | A scheduled task was cancelled before dispatch. |
Testing a fallback chain
const push = new CapturingTransport("push", { success: false, error: "device unreachable" });
const email = new CapturingTransport("email");
// server started with providers: [push, email]
it("rolls over to email when push fails", async () => {
await client.addUser("usr_3", [
{ channel: "email", target: "[email protected]" },
{ channel: "push", target: "tok_dead" }
]);
const delivered = waitForDelivery(server, 20_000);
await client.notify({
user: "usr_3",
template: "welcome",
channels: ["push", "email"],
fallback: true,
});
expect((await delivered).channel).toBe("email");
expect(push.sent).toHaveLength(1); // tried first
expect(email.sent).toHaveLength(1); // then rolled over
});
Give fallback tests a longer timeout: rollover re-enters the pipeline at the enriched stream and passes through the Engine again.
Testing time-dependent behaviour
Fake timers don't help: the deferral lives in Redis and a poller elsewhere releases it. Assert on the decision, not the eventual send:
it("defers a normal-priority send during quiet hours", async () => {
await client.addUser("usr_4", [{ channel: "email", target: "[email protected]" }], {
timezone: "UTC",
// A window that certainly contains "now".
preferences: { quietHours: [{ start: "00:00", end: "23:59" }] },
});
email.reset();
await client.notify({ user: "usr_4", template: "welcome", channels: ["email"] });
await new Promise((r) => setTimeout(r, 2000));
expect(email.sent).toHaveLength(0); // held, not sent
const { scheduled } = await fetch(`${base}/v1/notifications/scheduled`, { headers })
.then((r) => r.json());
expect(scheduled.length).toBeGreaterThan(0); // parked for later
});
it("lets a critical send through quiet hours", async () => {
const delivered = waitForDelivery(server);
await client.notify({
user: "usr_4", template: "welcome", channels: ["email"], priority: "critical",
});
await expect(delivered).resolves.toBeTruthy();
});
Testing your own transport in isolation
A transport is a plain class. Give it a payload and assert on what it does, with no server, containers or Docker.
import type { NotificationDispatchedPayload } from "notifkit";
const task = {
projectId: "p_1",
taskId: "t_1",
enrichedEventId: "e_1",
recipientId: "usr_1",
channel: "webhook",
priority: "normal",
templateId: "welcome",
templateVariables: { name: "Alice" },
recipient: { id: "usr_1", locale: "en", timezone: "UTC", preferences: {} },
renderedContent: { content: { text: "Welcome, Alice." } },
destination: "https://hooks.example.com/abc",
deliveryOptions: { maxAttempts: 3, timeoutMs: 10_000 },
} as unknown as NotificationDispatchedPayload;
it("posts the rendered text and reports the provider id", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(null, { status: 200, headers: { "x-slack-req-id": "req_9" } }),
);
vi.stubGlobal("fetch", fetchMock);
const result = await new SlackTransport().send(task);
expect(result).toEqual({ success: true, providerMessageId: "req_9" });
expect(JSON.parse(fetchMock.mock.calls[0]![1].body)).toEqual({ text: "Welcome, Alice." });
});
Testing workflows
Workflows suspend on wait and the wake-up poller runs every five seconds, so real
durations make slow tests. Use short waits and assert on step outputs, not elapsed time.
workflow("test-flow", async ({ step, event }) => {
await step.notify({ template: "welcome", channels: ["email"] });
await step.wait(process.env.NODE_ENV === "test" ? "1s" : "3d");
await step.notify({ template: "follow-up", channels: ["email"] });
});
it("sends both messages across the wait", async () => {
const { instanceId } = await client.triggerWorkflow({
name: "test-flow",
input: { user: { id: "usr_5" } },
});
await vi.waitFor(async () => {
const instance = await client.getWorkflow(instanceId);
expect(instance.status).toBe("completed");
}, { timeout: 30_000, interval: 1000 });
expect(email.sent).toHaveLength(2);
});
Handlers live in an in-process registry, so the test file must import the module that calls
workflow() before triggering. A workflow that silently does nothing is almost
always a missing import.
Habits that keep the suite honest
- Give every test its own user id and idempotency key. Markers last an hour (24 hours for an
x-idempotency-key), so reusing one makes the second test silently do nothing. - Run with
logLevel: "silent"; the chatty pipeline buries useful failures. - Start one server per file in
beforeAll, neverbeforeEach; container startup dominates runtime. - Reset transports between tests instead of recreating the server.
- Never assert right after
notify(). Wait on an event or poll withvi.waitFor; a baresetTimeoutis the flakiest option. - Mind the throttle: a hundred sends to one user in a test hits the hourly cap, and the rest vanish.