In shadow/dry-run/kill-switch modes, when the target has no token/recipient/
chat, or when not in the canary cohort, the handlers returned without marking
the notification, so the poller (pendingFilter on notified:{$ne:true})
re-selected the same batch every cycle forever (observed: fcm:500 every 10s).
Add a service-owned per-channel marker processedBy.<channel>, set in every
terminal 'won't/can't send' branch, and exclude marked docs in pendingFilter.
Unlike the shared notified/emailNotified/telegramNotified flags, processedBy is
invisible to the old system, so it never suppresses a real send in prod shadow.
- types.ts: NotificationDoc.processedBy?: Partial<Record<Channel, Date>>
- poller.ts: pendingFilter excludes processedBy.<channel>
- handlers.ts: markProcessed() in no-token/no-recipient/no-chat, decideSend=false,
dead-token and telegram-blocked branches
- fake-repo: dotted-path support in matching and $set/$unset
- tests: regression test + processedBy assertions (86 passing)
227 lines
9.6 KiB
TypeScript
227 lines
9.6 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import type { Repos } from '../src/db.js';
|
|
import type { Config } from '../src/config.js';
|
|
import { processEmailJob, processFcmJob, type Deps } from '../src/handlers.js';
|
|
import { pendingFilter } from '../src/poller.js';
|
|
import { aNotif, aUser, fakeFcm, fakeMailer, freshRepos, silentLog, testCfg } from './helpers.js';
|
|
|
|
let repos: Repos;
|
|
const baseCfg: Config = testCfg();
|
|
|
|
beforeEach(async () => {
|
|
repos = await freshRepos();
|
|
});
|
|
|
|
function deps(over: Partial<Deps> = {}): Deps {
|
|
return { cfg: baseCfg, repos, log: silentLog, ...over };
|
|
}
|
|
|
|
describe('FCM handler', () => {
|
|
it('sends push and marks the doc notified (full mode)', async () => {
|
|
const notif = aNotif();
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser());
|
|
const fcm = fakeFcm({ ok: true, messageId: 'mid-1' });
|
|
|
|
await processFcmJob(deps({ fcm }), notif._id.toHexString());
|
|
|
|
expect(fcm.calls).toHaveLength(1);
|
|
expect(fcm.calls[0]!.token).toBe('tok-abc');
|
|
const after = await repos.notifications.findOne({ _id: notif._id });
|
|
expect(after?.notified).toBe(true);
|
|
expect(after?.notifiedAt).toBeInstanceOf(Date);
|
|
const send = await repos.sends.findOne({ notificationId: notif._id, channel: 'fcm' });
|
|
expect(send?.status).toBe('sent');
|
|
});
|
|
|
|
it('idempotent across a simulated restart: never double-sends', async () => {
|
|
const notif = aNotif();
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser());
|
|
const fcm = fakeFcm({ ok: true, messageId: 'mid-1' });
|
|
|
|
// First run sends and marks notified.
|
|
await processFcmJob(deps({ fcm }), notif._id.toHexString());
|
|
// Simulate a replay/restart: the doc is already notified -> guard skips.
|
|
await processFcmJob(deps({ fcm }), notif._id.toHexString());
|
|
// Even if the notified flag were somehow cleared, the send record blocks it.
|
|
await repos.notifications.updateOne({ _id: notif._id }, { $unset: { notified: '' } });
|
|
await processFcmJob(deps({ fcm }), notif._id.toHexString());
|
|
|
|
expect(fcm.calls).toHaveLength(1); // only ONE real send, ever
|
|
});
|
|
|
|
it('dry-run does not send and does not mark the doc', async () => {
|
|
const notif = aNotif();
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser());
|
|
const fcm = fakeFcm({ ok: true, messageId: 'x' });
|
|
|
|
await processFcmJob(deps({ cfg: { ...baseCfg, mode: 'dry-run' }, fcm }), notif._id.toHexString());
|
|
|
|
expect(fcm.calls).toHaveLength(0);
|
|
const after = await repos.notifications.findOne({ _id: notif._id });
|
|
expect(after?.notified).toBeUndefined(); // never touches the old system's flag
|
|
expect(after?.processedBy?.fcm).toBeInstanceOf(Date); // but marks it processed…
|
|
expect(await repos.sends.countDocuments({})).toBe(0); // …with no reservation in dry-run
|
|
});
|
|
|
|
it('does not re-enqueue a doc already processed in shadow mode', async () => {
|
|
// Regression for the infinite fcm:500/cycle re-enqueue: in shadow the handler
|
|
// marks processedBy so the poller filter no longer selects the doc.
|
|
const notif = aNotif();
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser());
|
|
const fcm = fakeFcm({ ok: true, messageId: 'x' });
|
|
const shadow = { ...baseCfg, mode: 'shadow' as const };
|
|
|
|
// Before: the poller would select this pending mobile doc.
|
|
expect(await repos.notifications.countDocuments(pendingFilter('fcm'))).toBe(1);
|
|
|
|
await processFcmJob(deps({ cfg: shadow, fcm }), notif._id.toHexString());
|
|
|
|
expect(fcm.calls).toHaveLength(0); // nothing sent in shadow
|
|
const after = await repos.notifications.findOne({ _id: notif._id });
|
|
expect(after?.notified).toBeUndefined(); // old system's flag untouched
|
|
expect(after?.processedBy?.fcm).toBeInstanceOf(Date);
|
|
// After: the poller no longer selects it -> no re-enqueue.
|
|
expect(await repos.notifications.countDocuments(pendingFilter('fcm'))).toBe(0);
|
|
});
|
|
|
|
it('kill switch stops sending', async () => {
|
|
const notif = aNotif();
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser());
|
|
const fcm = fakeFcm({ ok: true, messageId: 'x' });
|
|
|
|
await processFcmJob(deps({ cfg: { ...baseCfg, killSwitch: true }, fcm }), notif._id.toHexString());
|
|
|
|
expect(fcm.calls).toHaveLength(0);
|
|
});
|
|
|
|
it('canary sends only to the cohort', async () => {
|
|
const inCohort = aNotif({ userId: 'u1' });
|
|
const outCohort = aNotif({ userId: 'u2', subsId: undefined });
|
|
await repos.notifications.insertMany([inCohort, outCohort]);
|
|
await repos.users.insertMany([aUser({ _id: 'u1' }), aUser({ _id: 'u2' })]);
|
|
const fcm = fakeFcm({ ok: true, messageId: 'x' });
|
|
const cfg = { ...baseCfg, mode: 'canary' as const, canary: { userIds: ['u1'], subsIds: [] } };
|
|
|
|
await processFcmJob(deps({ cfg, fcm }), inCohort._id.toHexString());
|
|
await processFcmJob(deps({ cfg, fcm }), outCohort._id.toHexString());
|
|
|
|
expect(fcm.calls).toHaveLength(1);
|
|
expect(fcm.calls[0]!.token).toBe('tok-abc');
|
|
});
|
|
|
|
it('purges dead FCM tokens and does not retry', async () => {
|
|
const notif = aNotif();
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser());
|
|
const fcm = fakeFcm({ ok: false, deadToken: true, error: 'messaging/registration-token-not-registered' });
|
|
|
|
await processFcmJob(deps({ fcm }), notif._id.toHexString());
|
|
|
|
const user = await repos.users.findOne({ _id: 'u1' });
|
|
expect(user?.fireBaseToken).toBeNull();
|
|
expect(user?.fireBaseTokenDeadAt).toBeInstanceOf(Date);
|
|
const send = await repos.sends.findOne({ notificationId: notif._id, channel: 'fcm' });
|
|
expect(send?.status).toBe('dead-token'); // recorded -> won't retry
|
|
const after = await repos.notifications.findOne({ _id: notif._id });
|
|
expect(after?.notified).toBeUndefined(); // not marked as notified
|
|
});
|
|
|
|
it('transient error releases the reservation so it can retry', async () => {
|
|
const notif = aNotif();
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser());
|
|
const fcm = fakeFcm({ ok: false, deadToken: false, error: 'UNAVAILABLE' });
|
|
|
|
await expect(processFcmJob(deps({ fcm }), notif._id.toHexString())).rejects.toThrow();
|
|
// reservation released -> a later retry can proceed
|
|
expect(await repos.sends.countDocuments({ notificationId: notif._id })).toBe(0);
|
|
|
|
const fcm2 = fakeFcm({ ok: true, messageId: 'ok' });
|
|
await processFcmJob(deps({ fcm: fcm2 }), notif._id.toHexString());
|
|
expect(fcm2.calls).toHaveLength(1);
|
|
});
|
|
|
|
it('skips users without a fireBaseToken', async () => {
|
|
const notif = aNotif();
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser({ fireBaseToken: null }));
|
|
const fcm = fakeFcm({ ok: true, messageId: 'x' });
|
|
|
|
await processFcmJob(deps({ fcm }), notif._id.toHexString());
|
|
|
|
expect(fcm.calls).toHaveLength(0);
|
|
const after = await repos.notifications.findOne({ _id: notif._id });
|
|
expect(after?.notified).toBeUndefined();
|
|
expect(after?.processedBy?.fcm).toBeInstanceOf(Date); // terminal: won't be re-enqueued
|
|
});
|
|
|
|
it('enforces the per-user/day limit', async () => {
|
|
await repos.users.insertOne(aUser());
|
|
const cfg = { ...baseCfg, limits: { ...baseCfg.limits, perUserPerDay: 2 } };
|
|
const fcm = fakeFcm({ ok: true, messageId: 'x' });
|
|
|
|
// Three pending notifs for the same user.
|
|
const notifs = [aNotif(), aNotif(), aNotif()];
|
|
await repos.notifications.insertMany(notifs);
|
|
for (const n of notifs) {
|
|
await processFcmJob(deps({ cfg, fcm }), n._id.toHexString());
|
|
}
|
|
|
|
expect(fcm.calls).toHaveLength(2); // third blocked by limit
|
|
});
|
|
});
|
|
|
|
describe('Email handler', () => {
|
|
it('sends email and marks emailNotified', async () => {
|
|
const notif = aNotif({ type: 'web' });
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser());
|
|
const mailer = fakeMailer();
|
|
|
|
await processEmailJob(deps({ mailer }), notif._id.toHexString());
|
|
|
|
expect(mailer.calls).toHaveLength(1);
|
|
expect(mailer.calls[0]!.to).toBe('ana@example.com');
|
|
expect(mailer.calls[0]!.content.subject.length).toBeGreaterThan(0);
|
|
const after = await repos.notifications.findOne({ _id: notif._id });
|
|
expect(after?.emailNotified).toBe(true);
|
|
});
|
|
|
|
it('removes the doc when there is no verified recipient (full mode)', async () => {
|
|
const notif = aNotif({ type: 'web' });
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser({ emails: [{ address: 'ana@example.com', verified: false }] }));
|
|
const mailer = fakeMailer();
|
|
|
|
await processEmailJob(deps({ mailer }), notif._id.toHexString());
|
|
|
|
expect(mailer.calls).toHaveLength(0);
|
|
expect(await repos.notifications.findOne({ _id: notif._id })).toBeNull(); // removed
|
|
});
|
|
|
|
it('does not remove the doc for missing recipient in shadow mode', async () => {
|
|
const notif = aNotif({ type: 'web' });
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser({ emails: [{ address: 'x@y', verified: false }] }));
|
|
const mailer = fakeMailer();
|
|
|
|
await processEmailJob(deps({ cfg: { ...baseCfg, mode: 'shadow' }, mailer }), notif._id.toHexString());
|
|
|
|
expect(await repos.notifications.findOne({ _id: notif._id })).not.toBeNull();
|
|
});
|
|
|
|
it('transient SMTP error releases reservation and rethrows', async () => {
|
|
const notif = aNotif({ type: 'web' });
|
|
await repos.notifications.insertOne(notif);
|
|
await repos.users.insertOne(aUser());
|
|
const mailer = fakeMailer({ fail: true });
|
|
|
|
await expect(processEmailJob(deps({ mailer }), notif._id.toHexString())).rejects.toThrow();
|
|
expect(await repos.sends.countDocuments({ notificationId: notif._id })).toBe(0);
|
|
});
|
|
});
|