Notify guides
17 · Testing without devices

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.

explain.ts
// 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.

notify.test.ts
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.
notify-token.ts
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.