Sending notifications
notify.send() is the one call you'll make from your application code. It's fully typed against the notifications you registered with createNotifyKit().
Under the hood, every send walks through this pipeline:
Payload schema check, idempotency check, dedup check, rate limit check.
Look up recipient, resolve preferences per channel, check quiet hours.
Write inbox items, queue email/SMS/webhook deliveries, fire hooks, return result.
Common patterns
Most sends fit one of these four shapes. Copy the one that matches your use case — each handles the safety concern for that context:
Fire-and-forget
User action in a server action — non-blocking, dedup prevents double-sends.
void notify.send({
recipientId: userId,
notificationId: "comment_mentioned",
payload: { ... },
dedupeKey: `mention:${postId}:${actorId}`,
dedupeWindowMs: 5 * 60_000,
})Retry-safe webhook
Incoming webhook that may fire multiple times — idempotency key guarantees at-most-once.
await notify.send({
recipientId: userId,
notificationId: "payment_received",
payload: { ... },
idempotencyKey: `stripe:${event.id}`,
})Broadcast to a list
Fan-out to a team or followers — parallel with per-recipient idempotency keys.
await Promise.allSettled(
userIds.map(id => notify.send({
recipientId: id,
notificationId: "project_shipped",
payload: { ... },
idempotencyKey: `ship:${projId}:${id}`,
}))
)Preview before sending
Dry-run for debugging or admin tooling — zero side effects, shows full pipeline resolution.
const explanation = await notify.explain({
recipientId: userId,
notificationId: "comment_mentioned",
payload: { ... },
})
// explanation.channels.email.outcomeidempotencyKey. If the user can trigger the same event twice → add dedupeKey. If response time matters → use void (fire-and-forget). Scroll down for the full decision flow.Pipeline decision map
Each send passes through a sequence of gates. At every gate, the pipeline can exit early with a specific outcome. When debugging "why didn't the user get it?" — find which gate stopped it:
| Gate | Check | If it fails | SendResult signal |
|---|---|---|---|
| 1. Payload validation | Schema match (types + required fields) | Throws — no record created, no delivery | Exception (not a result field) |
| 2. Idempotency | Has this idempotencyKey been seen? | Returns the original result immediately | idempotent: true |
| 3. Deduplication | Has this dedupeKey been seen within the window? | Delivery is skipped; an audit record is still created | skipped[].reason === "duplicate" |
| 4. Rate limit | Is the recipient under the threshold for this notification? | Send is dropped — permanently gone | rateLimited: true |
| 5. Digest buffer | Is a digest configured? Buffer the payload. | Buffered for later — no immediate delivery | digested: true |
| 6. Recipient lookup | Does the recipient exist in the database? | Throws — send cannot proceed without a recipient | Exception (not a result field) |
| 7. Per-channel preference | Has the user opted out of this channel? | Channel skipped (others may still fire) | skipped[].reason: "preferences_disabled" |
| 8. Destination check | Does the recipient have the address (email, phone)? | Channel skipped | skipped[].reason: "missing_address" |
| 9. Condition function | Does the channel's condition(payload) return true? | Channel skipped | skipped[].reason: "condition_false" |
| 10. Quiet hours | Is the recipient in their quiet window? (push channels only) | Deferred — scheduled for window end | deferredChannels: ["email", ...] |
| 11. Delivery | Provider call succeeds? | Retried with backoff → fallback if exhausted | deliveries[].status: "sent" | "failed" |
SendResult always tells you which gate was the last one reached — use it to pinpoint where the pipeline stopped.skipped.length > 0 AND deliveries.length > 0 at the same time.Quick troubleshooting
Send not working? Match your symptom to the root cause and fix:
| Symptom | Root cause | Fix |
|---|---|---|
send() throws VALIDATION_ERROR | Payload doesn't match the notification's schema | Check field names and types against your payload definition. Missing fields and wrong types both trigger this. |
send() throws RECIPIENT_NOT_FOUND | upsertRecipient() was never called for this user | Call upsertRecipient({ id, email }) before the first send(). Common in new-user signup flows. |
result.deliveries is empty, no inbox item | All channels were skipped (preferences, missing address, condition) | Check result.skipped — the reason field tells you which gate blocked it. Use notify.explain() for a full trace. |
| Inbox item created but no email sent | User opted out of email, or recipient has no email field | Check result.skipped for preferences_disabled or missing_address. Verify the recipient has an email. |
result.rateLimited === true | Recipient exceeded the rate limit for this notification | Expected behavior. If the limit is too tight, increase max or widen windowMs in the notification definition. |
result.digested === true, nothing delivered | Send was buffered into a digest window — delivery happens later | Not a bug. Wait for the window to expire, or call flushDigests() to force it. In tests, use windowMs: 0. |
| Same notification sent twice to the same user | No idempotencyKey or dedupeKey configured | Add idempotencyKey (for retryable triggers) or dedupeKey (for user-triggered events). See Common patterns above. |
| Email delivered but arrives in spam | Sender domain not authenticated (SPF/DKIM/DMARC) | Not a NotifyKit issue — configure DNS records for your sending domain in your email provider's dashboard. |
notify.explain() for any mystery. It dry-runs the full pipeline — same validation, preferences, quiet hours, channel resolution — and tells you what would happen without actually sending. If send() isn't doing what you expect, explain() shows you exactly which gate stopped it. See Explain & dry run.Basic send
await notify.upsertRecipient({
id: user.id,
email: user.email,
name: user.name,
})
const result = await notify.send({
recipientId: user.id,
notificationId: "comment_mentioned",
payload: {
actorName: "Rey",
postTitle: "Launch Plan",
postUrl: "/posts/42",
},
})Send options at a glance
Beyond recipientId, notificationId, and payload, these optional fields control pipeline behavior:
| Option | What it does | When to add it |
|---|---|---|
tenantId | Scopes the send to an organization | Multi-tenant apps — ensures tenant-level preferences apply |
idempotencyKey | Returns the original result on duplicate calls | Retryable triggers (webhooks, queue jobs) — prevents double-sends |
dedupeKey | Skips if the same key was seen within the window | Noisy events (edits, reactions) — collapses rapid duplicates |
dedupeWindowMs | Duration the dedup key is remembered | Pair with dedupeKey — defaults to 5 minutes |
dryRun | Returns a DeliveryExplanation without writing anything | Debugging — see what would happen for a given input |
idempotencyKey. If the user can trigger the same logical event repeatedly, add dedupeKey. They solve different problems — use both when both apply.SendResult
The result tells you exactly what happened — use it for logging, analytics, or conditional follow-up logic:
| Field | Type | Meaning |
|---|---|---|
notification | NotificationRecord | null | The created record, or null if digested |
inboxItems | InboxItem[] | Inbox rows written to the database |
deliveries | DeliveryRecord[] | All delivery attempts including failures |
skipped | SkippedDelivery[] | Channels skipped with reasons (preference opt-out, missing destination) |
deferredChannels | ChannelType[] | Channels held back by quiet hours |
digested | boolean | Send was buffered into a digest window |
rateLimited | boolean | Send was dropped by a rate limit |
idempotent | boolean | Duplicate send — idempotency key already seen |
result.rateLimited or result.digested before showing success toasts — the user may not actually receive anything immediately.Type safety
These fail at compile time, not at runtime:
// ❌ Unknown notification id
await notify.send({
recipientId: "u_1",
notificationId: "wrong_id", // TS error: not assignable
payload: {},
})
// ❌ Wrong payload shape
await notify.send({
recipientId: "u_1",
notificationId: "comment_mentioned",
payload: { actorName: 42 }, // TS error: number is not string
})Inline vs async queue
| Mode | Behavior | Best for |
|---|---|---|
inlineQueue() (default) | send() awaits provider calls | Simple apps, scripts, testing |
setTimeoutQueue() | send() returns immediately, deliveries run async | Prototypes where losing in-flight work is acceptable |
Custom (Queue interface) | You control when workers run | BullMQ, SQS, Cloudflare Queues |
Swap in setTimeoutQueue() to return quickly and run deliveries later in the same process. This is useful for demos, but it is not a durable production queue:
import { setTimeoutQueue } from "@notifykitjs/core"
const notify = createNotifyKit({
// ...
queue: setTimeoutQueue(),
retry: { maxAttempts: 5, delayMs: (n) => 500 * 2 ** (n - 1) },
})
// Wait for in-flight jobs before shutdown:
await notify.drain()Event hooks
Every interesting moment fires a hook. See Hooks & observability for the full list.
createNotifyKit({
// ...
on: {
"notification.created": ({ notification }) =>
metrics.inc("notifications.created"),
"delivery.sent": ({ delivery }) =>
metrics.inc("delivery.sent", { channel: delivery.channel }),
"delivery.failed": ({ delivery, error }) =>
sentry.captureException(error),
},
})Quiet hours
Checking results
After every send, check the result to understand what actually happened:
| Check | What it means | Common action |
|---|---|---|
result.digested | Buffered into a digest — no immediate delivery | Don't show a "sent!" toast |
result.rateLimited | Dropped by rate limit — permanently gone | Log for monitoring |
result.idempotent | Duplicate — original result returned | Safe to ignore |
result.skipped.length > 0 | Some channels were skipped | Check .reason for debugging |
result.deferredChannels.length > 0 | Held by quiet hours | Will deliver when window ends |
Where to call send()
send() is server-only — call it anywhere you have access to your NotifyKit instance. Use this decision flow to pick the right pattern for your context:
Can your trigger retry?
Webhooks, queue jobs, and cron tasks can fire multiple times. If yes → add an idempotencyKey.
Does the user's response time depend on delivery?
In server actions and API routes, the user waits. If delivery must outlive the request → write an outbox row or enqueue a durable job.
Can the same logical event fire many times?
Multiple edits to the same comment, repeated likes. If yes → add a dedupeKey scoped to the entity.
| Context | Example | Await? | Key to use |
|---|---|---|---|
| Server action | User posts a comment → notify mentioned users | Persist an outbox row; otherwise await it | dedupeKey (same mention in same post) |
| API route (incoming webhook) | Stripe webhook → notify user of payment | Yes — confirm delivery for webhook ack | idempotencyKey (webhook can retry) |
| Background job | Order ships → notify customer with tracking | Yes — job has no response deadline | idempotencyKey (job can retry) |
| Cron / scheduled | Daily digest → batch notify all users | Yes — sequential within the batch | dedupeKey with date (prevents double-send) |
// Server action — persist intent with the business mutation
"use server"
import { notify } from "@/lib/notifykit"
export async function addComment(postId: string, body: string) {
return db.transaction(async tx => {
const comment = await tx.comments.create({ postId, body, authorId: session.userId })
for (const userId of extractMentions(body)) {
await tx.notificationOutbox.create({
notificationId: "comment_mentioned",
recipientId: userId,
payload: {
actorName: session.userName,
postTitle: comment.postTitle,
postUrl: `/posts/${postId}`,
},
idempotencyKey: `mention:${comment.id}:${userId}`,
})
}
return comment
})
}
// A durable worker claims the outbox row, then awaits notify.send({
// ...row,
// payload: row.payload,
// }) before marking it complete.notify.send() directly when that simpler tradeoff is acceptable.Sending to multiple recipients
send() targets one recipient at a time — to broadcast, loop over your recipient list. Each send resolves independently (its own preferences, quiet hours, rate limits).
// Notify all team members when a project ships
const members = await db.teamMembers.findMany({ where: { teamId } })
const results = await Promise.allSettled(
members
.filter(m => m.userId !== actor.id) // don't notify the person who did it
.map(m =>
notify.send({
recipientId: m.userId,
notificationId: "project_shipped",
payload: { projectName, actorName: actor.name },
idempotencyKey: `shipped:${projectId}:${m.userId}`,
})
)
)
const failed = results.filter(r => r.status === "rejected")
if (failed.length) logger.warn(`${failed.length} sends failed`, { projectId })| Pattern | When to use | Trade-off |
|---|---|---|
Promise.allSettled() | Broadcast to a team or followers (10-100 recipients) | One failure doesn't block the rest. Inspect results to log failures. |
Sequential for...of | Ordered sends or very large lists (1000+) | Slower, but avoids connection pool exhaustion. Add a small delay between batches. |
| Background job per recipient | Async fan-out from a queue (BullMQ, SQS) | Best for scale — each send retries independently. Requires queue infrastructure. |
idempotencyKey for broadcasts. If your trigger retries (webhook, queue job), each recipient gets a unique key so duplicates are safely skipped. Format: `event:${eventId}:${recipientId}`.Error recovery
send() itself only throws for programming errors (bad payload, missing recipient). Provider failures are captured in the result and retried automatically. But bulk operations introduce a third category: partial failures at the orchestration layer.
After a broadcast, check which sends rejected. Log recipient IDs and the error.
Transient errors (DB timeout, connection reset) → retry. Permanent errors (missing recipient) → dead-letter.
A handful of failures is normal. 20%+ failure rate across a broadcast means something systemic.
// Robust broadcast with error classification
async function broadcastWithRecovery(recipientIds: string[], notification: SendInput) {
const results = await Promise.allSettled(
recipientIds.map(id =>
notify.send({ ...notification, recipientId: id, idempotencyKey: `${notification.idempotencyKey}:${id}` })
)
)
const succeeded = results.filter(r => r.status === "fulfilled")
const failed = results
.map((r, i) => ({ result: r, recipientId: recipientIds[i] }))
.filter(({ result }) => result.status === "rejected")
// Classify failures
const retryable = failed.filter(({ result }) =>
result.status === "rejected" && isTransient(result.reason)
)
const deadLettered = failed.filter(({ result }) =>
result.status === "rejected" && !isTransient(result.reason)
)
// Log and alert
if (failed.length > 0) {
logger.warn("Broadcast partial failure", {
total: recipientIds.length,
succeeded: succeeded.length,
retryable: retryable.length,
deadLettered: deadLettered.length,
})
}
// Alert on high failure rate
const failureRate = failed.length / recipientIds.length
if (failureRate > 0.2) {
await alertOncall("Broadcast failure rate >20%", { failureRate, notification })
}
// Retry transient failures (once, with backoff)
if (retryable.length > 0) {
await delay(2000)
await Promise.allSettled(
retryable.map(({ recipientId }) =>
notify.send({ ...notification, recipientId, idempotencyKey: `${notification.idempotencyKey}:${recipientId}` })
)
)
}
return { succeeded: succeeded.length, failed: failed.length, retried: retryable.length }
}
function isTransient(error: unknown): boolean {
const message = error instanceof Error ? error.message : ""
return message.includes("timeout") || message.includes("ECONNRESET") || message.includes("503")
}| Failure type | Examples | Strategy |
|---|---|---|
| Transient | DB timeout, connection reset, 503 from dependency | Retry once after delay. If still failing, dead-letter and alert. |
| Permanent | Recipient not found, validation error, missing provider | Dead-letter immediately. These won't succeed on retry. |
| Systemic | >20% failure rate in a single broadcast | Alert oncall. Likely a DB outage or misconfiguration, not per-recipient. |
event:recipient), retrying the entire broadcast is safe — recipients who already succeeded get a no-op replay, and only the failed ones are re-attempted.Common anti-patterns
These patterns compile fine but cause subtle issues in production. Each one shows the problem and the fix:
| Anti-pattern | What goes wrong | Fix |
|---|---|---|
| Provider work in hot paths | Response latency includes database and provider work | Persist an outbox/queue job and process it in a durable worker |
| Generic dedup keys | Key like "comment" silences all comment notifications after the first one | Scope to the entity: `comment:${postId}:${actorId}` |
| Missing idempotency on retryable triggers | Queue job retries → user gets the same email 3 times | Always add idempotencyKey when the caller can retry |
| Notifying the actor | "Rey commented on your post" sent to Rey themselves | Filter recipientId !== actorId before sending |
| One notification ID for all audiences | User can't opt out of low-priority "watched post" updates without also losing direct mentions | Separate IDs per audience: comment_mentioned vs comment_on_watched |
// ❌ Untracked fire-and-forget can disappear on crash or deploy
"use server"
export async function addComment(postId: string, body: string) {
const comment = await db.comments.create({ postId, body })
void notify.send({ recipientId: mentioned, ... })
return comment
}
// ✅ Persist notification intent with the business operation
"use server"
export async function addComment(postId: string, body: string) {
return db.transaction(async tx => {
const comment = await tx.comments.create({ postId, body })
await tx.notificationOutbox.create({
type: "comment_mentioned",
recipientId: mentioned,
payload: { postId, commentId: comment.id },
})
return comment
})
}setTimeoutQueue() is best-effort only. It improves response latency but does not survive process exits or serverless freezes. Use it only when losing an in-flight notification is acceptable.// ❌ Generic dedup key — silences ALL comment notifications globally
await notify.send({
recipientId: userId,
notificationId: "comment_mentioned",
payload,
dedupeKey: "comment", // Only one comment notification per 5 min, ever
})
// ✅ Scoped dedup key — collapses only rapid duplicates for the same context
await notify.send({
recipientId: userId,
notificationId: "comment_mentioned",
payload,
dedupeKey: `comment:${postId}:${actorId}:${recipientId}`,
})// ❌ No idempotency on a webhook handler — retries cause duplicates
export async function POST(req: Request) {
const event = await req.json()
await notify.send({
recipientId: event.userId,
notificationId: "payment_received",
payload: { amount: event.amount },
})
return Response.json({ ok: true })
}
// ✅ Idempotency key derived from the event — retries are safe no-ops
export async function POST(req: Request) {
const event = await req.json()
await notify.send({
recipientId: event.userId,
notificationId: "payment_received",
payload: { amount: event.amount },
idempotencyKey: `stripe:${event.id}`,
})
return Response.json({ ok: true })
}send() level — but if your code calls send() with a wrong recipient, a too-broad key, or in the wrong place, the pipeline will faithfully deliver the wrong thing. Test your orchestration logic, not just the pipeline mechanics.One event, multiple notifications
Real applications rarely send one notification per event. A single domain action — posting a comment, shipping an order, completing a deploy — usually triggers different notifications to different audiences. Here's how to structure that fan-out:
| Event | Audience | Notification | Why it's different |
|---|---|---|---|
| Comment posted | @mentioned users | comment_mentioned | Urgent — they were directly addressed |
| Post author | comment_on_your_post | Important but not as targeted | |
| Post watchers | comment_on_watched | Informational — they opted into updates | |
| Deploy completed | PR author | deploy_succeeded | Their code is live — action item |
| Team channel (webhook) | deploy_webhook | System-to-system, no inbox needed | |
| Stakeholders | release_shipped | High-level summary, different payload |
notificationId to everyone, users can't opt out of "comment on watched post" without also losing "mentioned you."Orchestration pattern
Extract a notification dispatcher for each domain event. It receives the event context and decides who gets what:
// lib/notifications/on-comment-posted.ts
import { notify } from "@/lib/notifykit"
export async function onCommentPosted(comment: {
id: string
postId: string
authorId: string
body: string
postAuthorId: string
}) {
const mentions = extractMentions(comment.body)
const watchers = await db.watchers.findMany({ where: { postId: comment.postId } })
const actor = await db.users.findFirst({ where: { id: comment.authorId } })
// 1. Notify mentioned users (highest priority)
const mentionSends = mentions
.filter(userId => userId !== comment.authorId) // don't notify yourself
.map(userId =>
notify.send({
recipientId: userId,
notificationId: "comment_mentioned",
payload: { actorName: actor.name, postUrl: `/posts/${comment.postId}` },
dedupeKey: `mention:${comment.postId}:${comment.authorId}:${userId}`,
dedupeWindowMs: 5 * 60_000,
})
)
// 2. Notify post author (unless they wrote the comment)
const authorSend = comment.authorId !== comment.postAuthorId
? notify.send({
recipientId: comment.postAuthorId,
notificationId: "comment_on_your_post",
payload: { actorName: actor.name, postUrl: `/posts/${comment.postId}` },
dedupeKey: `reply:${comment.postId}:${comment.authorId}`,
dedupeWindowMs: 5 * 60_000,
})
: null
// 3. Notify watchers (lowest priority — often digested)
const alreadyNotified = new Set([...mentions, comment.postAuthorId, comment.authorId])
const watcherSends = watchers
.filter(w => !alreadyNotified.has(w.userId))
.map(w =>
notify.send({
recipientId: w.userId,
notificationId: "comment_on_watched",
payload: { actorName: actor.name, postUrl: `/posts/${comment.postId}` },
})
)
await Promise.allSettled([...mentionSends, authorSend, ...watcherSends].filter(Boolean))
}| Design decision | Why |
|---|---|
| Deduplicate the audience | A mentioned user who also watches the post should get the mention (higher priority), not both |
| Skip self-notifications | The comment author shouldn't get "someone commented on your post" for their own comment |
| Different dedup keys per type | Mention dedup scopes to (post, actor, target). Reply dedup scopes to (post, actor). Different collapse logic. |
Promise.allSettled | One failed send (e.g., missing recipient) shouldn't block the others |
Where to call the dispatcher
// Server action — fire after the mutation
"use server"
import { onCommentPosted } from "@/lib/notifications/on-comment-posted"
export async function addComment(postId: string, body: string) {
const comment = await db.comments.create({ ... })
// Fire-and-forget — don't block the user response
void onCommentPosted(comment)
return comment
}
// Or from a background job (webhook, queue worker):
export async function handleCommentWebhook(payload: CommentEvent) {
await onCommentPosted(payload.comment) // safe to await — no user waiting
}lib/notifications/on-*.ts. Each file owns the fan-out logic for one event — who gets notified, with what priority, and which dedup/idempotency keys to use. Your mutation code stays clean (one void onCommentPosted() call) and notification logic is testable in isolation.Testing sends
Notification orchestration logic — who gets notified, with what keys, from which trigger — is easy to get wrong and hard to debug in production. Test at two levels: unit (the orchestrator in isolation) and integration (the full pipeline with real results).
Test setup
Create a test instance with memoryAdapter() and fakeEmailProvider() — zero external deps, instant delivery:
// test/helpers/notifykit.ts
import { createNotifyKit, memoryAdapter, fakeEmailProvider, channel, notification } from "@notifykitjs/core"
export const commentMentioned = notification({
id: "comment_mentioned",
payload: { actorName: "string", postTitle: "string", postUrl: "string" },
channels: [
channel.inbox()({ title: "{{actorName}} mentioned you", body: "In {{postTitle}}", actionUrl: "{{postUrl}}" }),
channel.email()({ subject: "{{actorName}} mentioned you", body: "Open {{postUrl}}" }),
],
})
export function createTestNotify() {
return createNotifyKit({
notifications: [commentMentioned] as const,
database: memoryAdapter(),
providers: { email: fakeEmailProvider() },
})
}Integration: assert on SendResult
import { describe, it, expect, beforeEach } from "vitest"
import { createTestNotify } from "./helpers/notifykit"
describe("comment mention sends", () => {
let notify: ReturnType<typeof createTestNotify>
beforeEach(async () => {
notify = createTestNotify()
await notify.upsertRecipient({ id: "alice", email: "alice@test.com" })
})
it("delivers to inbox and email", async () => {
const result = await notify.send({
recipientId: "alice",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
})
expect(result.inboxItems).toHaveLength(1)
expect(result.inboxItems[0].title).toBe("Rey mentioned you")
expect(result.deliveries).toHaveLength(1)
expect(result.deliveries[0].channel).toBe("email")
expect(result.deliveries[0].status).toBe("sent")
expect(result.skipped).toHaveLength(0)
})
it("deduplicates within window", async () => {
const opts = {
recipientId: "alice" as const,
notificationId: "comment_mentioned" as const,
payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
dedupeKey: "mention:p1:rey",
dedupeWindowMs: 60_000,
}
const first = await notify.send(opts)
const second = await notify.send(opts)
expect(first.inboxItems).toHaveLength(1)
expect(second.inboxItems).toHaveLength(0) // deduped
})
it("idempotency returns original result", async () => {
const opts = {
recipientId: "alice" as const,
notificationId: "comment_mentioned" as const,
payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
idempotencyKey: "job:abc",
}
const first = await notify.send(opts)
const replay = await notify.send(opts)
expect(replay.idempotent).toBe(true)
expect(replay.notification?.id).toBe(first.notification?.id)
})
})Unit: test orchestrators in isolation
Your on-*.ts orchestrators are pure functions of the event. Test the logic (who gets notified, skip-self, dedup keys) without sending real notifications:
// lib/notifications/on-comment-posted.test.ts
import { describe, it, expect, vi } from "vitest"
import { onCommentPosted } from "./on-comment-posted"
// Mock the notify instance
const mockSend = vi.fn().mockResolvedValue({ inboxItems: [], deliveries: [] })
vi.mock("@/lib/notifykit", () => ({
notify: { send: (...args) => mockSend(...args) },
}))
describe("onCommentPosted", () => {
it("notifies mentioned users but not the author", async () => {
await onCommentPosted({
id: "c1",
postId: "p1",
authorId: "rey",
mentions: ["alice", "bob", "rey"], // rey should be filtered
postTitle: "Launch Plan",
})
expect(mockSend).toHaveBeenCalledTimes(2) // alice + bob, not rey
expect(mockSend).not.toHaveBeenCalledWith(
expect.objectContaining({ recipientId: "rey" })
)
})
it("uses per-mention dedup keys", async () => {
await onCommentPosted({
id: "c1",
postId: "p1",
authorId: "rey",
mentions: ["alice"],
postTitle: "Launch",
})
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
dedupeKey: "mention:p1:rey:alice",
})
)
})
})| Test level | What it proves | I/O boundary | When it catches bugs |
|---|---|---|---|
| Unit (mocked send) | Orchestration logic — who, skip-self, key design | No NotifyKit I/O | Before the notification system is even involved |
| Integration (memory adapter) | Full pipeline — preferences, dedup, delivery, result shape | In-memory database and fake providers | Payload mismatches, template bugs, dedup window issues |
| E2E (real provider, staging) | Actual email arrives, webhook hits endpoint | Real network, DNS, and provider | Provider config, DNS, auth token expiry |
result.inboxItems, result.skipped, and result.deliveries — not on database state or internal method calls. The result is the public contract; internals can change between versions without breaking your tests.