tcef-notifications/test/poller.test.ts
vjrj d20e168c90 Fase 1a: microservicio tcef-notifications (workers FCM v1 + email)
Sustituye el envío de push por la API legacy de GCM (muerta desde jun-2024) y
saca el envío del observer de Meteor a una cola BullMQ controlada.

- poller: consume 'notifications' pendientes (campos correctos; el cron viejo
  tenía un typo nofitied/emailNofitied que nunca casaba)
- fcm-worker: firebase-admin (FCM HTTP v1), payload compatible con la app
  Flutter (FLUTTER_NOTIFICATION_CLICK, collapseKey=_id, data); purga tokens
  muertos sin reintentar
- email-worker: nodemailer + plantillas new-fire portadas verbatim
- idempotencia persistente: notification_sends, indice unico (notificationId,
  channel) -> sobrevive reinicios y replays
- salvaguardas anti-spam: modos dry-run/shadow/canary/full, exclusion mutua por
  canal (OWNED_CHANNELS + notifDisabledChannels en Meteor), kill switch,
  limites por usuario/dia y circuit breaker por batch
- docs/legacy-behavior.md (caracterizacion) + docs/rollout.md
- 47 tests (vitest + mongodb-memory-server), typecheck y build OK
- deploy: systemd + pm2 (Node 22 standalone)

Falta el service account de Firebase para envio real de push (pedir al usuario).
2026-07-13 00:10:29 +02:00

93 lines
3.3 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type { Queue } from 'bullmq';
import type { MongoMemoryServer } from 'mongodb-memory-server';
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, silentLog, startMongo } from './helpers.js';
let server: MongoMemoryServer;
let repos: Repos;
let baseCfg: Config;
beforeAll(async () => {
const s = await startMongo();
server = s.server;
repos = s.repos;
baseCfg = s.cfg;
});
afterAll(async () => {
await repos.close();
await server.stop();
});
beforeEach(async () => {
await repos.notifications.deleteMany({});
});
/** 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 } });
expect(pendingFilter('email')).toEqual({ type: 'web', emailNotified: { $ne: true } });
});
});
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 });
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);
});
});