Test without a drawer of phones.
Ask Notify what it would do, use the inbox as your test channel, send email only to the simulator, and mock the API in unit tests.
Use a separate workspace for tests: everything in Notify, from types to keys, is per workspace, and types as code keeps it identical to production.
1
dry_run: what would happen
dry_run: true returns one row per recipient, channel and target: deliver, schedule (with not_before for quiet hours) or skip, with the reason and its fix. It stores and sends nothing and needs no Idempotency-Key. The console’s Explain for recipient is the same call.
// Plans the send against current preferences, devices and quiet hours,
// stores nothing, sends nothing, uses no rate limit.
const plan = await notify.notifications.send({
type: "security.new_sign_in",
recipients: ["user_123"],
data: { city: "Lisbon", device: "Firefox", time: "10:42" },
dry_run: true,
});
if ("plan" in plan) {
for (const row of plan.plan) {
// e.g. push skip no_device "The recipient has no active device for this channel. …"
console.log(row.channel, row.decision, row.reason, row.explanation, row.not_before);
}
}2
The inbox is a real channel you can read
A type with the inbox in its routing writes the item at once, so an end-to-end test can send, then read GET /recipients/{external_id}/inbox (or the client API with a token) and assert on the rendered title. Use test recipient ids you erase afterwards.
3
Email to the simulator only
Real bounces and complaints count against your sending reputation, so automated tests should email only the Amazon SES mailbox simulator: success@simulator.amazonses.com, bounce@… and complaint@… (to see the bounce handling work).
4
Unit tests: a mock API
createMockFetch answers every Notify operation with its example from the spec, records each call, and lets you override what you assert on. No network, no key.
import { Notify } from "@productcraft/notify";
import { createMockFetch } from "@productcraft/notify/testing";
// Answers every operation with the spec's example; override what you assert on.
const fetch = createMockFetch({
"notifications.send": { id: "0193a8b2-0000-7000-8000-000000000001", type: "invoice.paid", recipient_count: 1, status: "accepted", skipped: [] },
});
const notify = new Notify({ apiKey: "test", workspaceId: "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b", fetch });
await notify.notifications.send({ type: "invoice.paid", recipients: ["user_123"] }, { idempotencyKey: "invoice-paid:inv_1" });
const call = fetch.calls[0]!;
console.log(call.operation, call.headers.get("idempotency-key"), call.body); // notifications.send invoice-paid:inv_1 {...}5
Tokens in tests and in production
- Minting a token for an id Notify has not seen creates a bare recipient: in tests, use ids you erase afterwards.
- Mints are limited to 3,000 a minute per workspace. Cache a token per user for most of its lifetime rather than minting on every page load.
import { Notify } from "@productcraft/notify";
const notify = new Notify();
const cache = new Map<string, { token: string; expiresAt: number }>();
// Mints are limited (3,000 a minute per workspace). Reuse a token for most
// of its life instead of minting on every page load.
export async function notifyToken(userId: string): Promise<string> {
const hit = cache.get(userId);
if (hit && hit.expiresAt - Date.now() > 10 * 60_000) return hit.token;
const { token, expires_at } = await notify.recipientTokens.mint({ recipient: userId, ttl_seconds: 3600 });
cache.set(userId, { token, expiresAt: Date.parse(expires_at) });
return token;
}6
Push without devices
For APNs and FCM, check the plan with dry_run and the rendered text with a type preview; a real device is the only proof of display. Web push you can test on your own browser in minutes: web push guide.