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)
81 lines
3.1 KiB
TypeScript
81 lines
3.1 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import type { Queue } from 'bullmq';
|
|
import type { Repos } from '../src/db.js';
|
|
import type { Config } from '../src/config.js';
|
|
import { createPoller } from '../src/poller.js';
|
|
import { pendingFilter } from '../src/poller.js';
|
|
import type { Channel, SendJob } from '../src/types.js';
|
|
import { aNotif, freshRepos, silentLog, testCfg } from './helpers.js';
|
|
|
|
let repos: Repos;
|
|
const baseCfg: Config = testCfg();
|
|
|
|
beforeEach(async () => {
|
|
repos = await freshRepos();
|
|
});
|
|
|
|
/** Fake queue recording add() calls. */
|
|
function fakeQueues() {
|
|
const added: Record<Channel, SendJob[]> = { fcm: [], email: [] };
|
|
const make = (ch: Channel) =>
|
|
({
|
|
async add(_name: string, job: SendJob) {
|
|
added[ch].push(job);
|
|
},
|
|
}) as unknown as Queue<SendJob>;
|
|
return { queues: { fcm: make('fcm'), email: make('email') } as Record<Channel, Queue<SendJob>>, added };
|
|
}
|
|
|
|
describe('pendingFilter', () => {
|
|
it('uses the CORRECT field names (old cron had a typo)', () => {
|
|
expect(pendingFilter('fcm')).toEqual({ type: 'mobile', notified: { $ne: true }, 'processedBy.fcm': { $exists: false } });
|
|
expect(pendingFilter('email')).toEqual({ type: 'web', emailNotified: { $ne: true }, 'processedBy.email': { $exists: false } });
|
|
});
|
|
});
|
|
|
|
describe('poller.runOnce', () => {
|
|
it('enqueues only pending docs of owned channels and claims them', async () => {
|
|
await repos.notifications.insertMany([
|
|
aNotif({ type: 'mobile' }), // pending fcm
|
|
aNotif({ type: 'mobile', notified: true }), // already done
|
|
aNotif({ type: 'web' }), // pending email
|
|
aNotif({ type: 'web', emailNotified: true }), // already done
|
|
]);
|
|
const { queues, added } = fakeQueues();
|
|
const poller = createPoller(baseCfg, repos, queues, silentLog);
|
|
|
|
const res = await poller.runOnce();
|
|
|
|
expect(res).toEqual({ fcm: 1, email: 1, telegram: 0 });
|
|
expect(added.fcm).toHaveLength(1);
|
|
expect(added.email).toHaveLength(1);
|
|
// claimedBy provenance set
|
|
expect(await repos.notifications.countDocuments({ claimedBy: baseCfg.serviceName })).toBe(2);
|
|
});
|
|
|
|
it('only polls owned channels', async () => {
|
|
await repos.notifications.insertMany([aNotif({ type: 'mobile' }), aNotif({ type: 'web' })]);
|
|
const { queues, added } = fakeQueues();
|
|
const cfg = { ...baseCfg, ownedChannels: ['fcm'] as Channel[] };
|
|
const poller = createPoller(cfg, repos, queues, silentLog);
|
|
|
|
const res = await poller.runOnce();
|
|
|
|
expect(res.fcm).toBe(1);
|
|
expect(added.email).toHaveLength(0);
|
|
});
|
|
|
|
it('circuit breaker halts on oversized batch', async () => {
|
|
const many = Array.from({ length: 5 }, () => aNotif({ type: 'mobile' }));
|
|
await repos.notifications.insertMany(many);
|
|
const { queues, added } = fakeQueues();
|
|
const cfg = { ...baseCfg, ownedChannels: ['fcm'] as Channel[], limits: { ...baseCfg.limits, maxPerBatch: 3 } };
|
|
const poller = createPoller(cfg, repos, queues, silentLog);
|
|
|
|
const res = await poller.runOnce();
|
|
|
|
// Batch of 5 > max 3 -> nothing enqueued, poller halted.
|
|
expect(res.fcm).toBe(0);
|
|
expect(added.fcm).toHaveLength(0);
|
|
});
|
|
});
|