import { computeRucDv, dueDateFor, formatIsoDate } from '@impuestos/rules'; import { afterEach, describe, expect, it } from 'vitest'; import { createHarness, type Harness } from '../../test/harness'; import type { Channels, OutgoingMessage } from '../notifications'; import { runAutoConfirmSweep, runDeadlineSweep, runDigestSweep } from './sweep-handlers'; import { scheduleSweeps, sweepDedupeKey } from './sweeps'; let harness: Harness | null = null; afterEach(async () => { await harness?.close(); harness = null; }); /** Records what would have gone out, so the sweeps can be asserted without a network. */ function recordingChannels(): Channels & { sent: { channel: string; message: OutgoingMessage }[] } { const sent: { channel: string; message: OutgoingMessage }[] = []; return { sent, push: async (_targets, message) => { sent.push({ channel: 'push', message }); return { gone: [] }; }, email: async (_to, _subject, message) => { sent.push({ channel: 'email', message }); }, telegram: async (_chatId, message) => { sent.push({ channel: 'telegram', message }); }, }; } describe('scheduleSweeps', () => { it('queues each sweep once per window', async () => { const h = (harness = await createHarness()); const now = new Date('2026-09-04T09:00:00Z'); expect(await scheduleSweeps(h.deps.handle.db, now)).toEqual([ 'deadline_sweep', 'auto_confirm_sweep', 'digest_sweep', ]); // A second tick in the same window adds nothing. expect(await scheduleSweeps(h.deps.handle.db, now)).toEqual([]); // The next hour brings the hourly one back, and only that one. expect(await scheduleSweeps(h.deps.handle.db, new Date('2026-09-04T10:00:00Z'))).toEqual([ 'digest_sweep', ]); }); it('keys daily sweeps by the day in Asuncion and hourly ones by the hour', () => { const key = sweepDedupeKey('deadline_sweep', new Date('2026-09-04T02:00:00Z')); // 02:00 UTC is still the 3rd in Paraguay. expect(key).toBe('deadline_sweep:2026-09-03'); expect(sweepDedupeKey('digest_sweep', new Date('2026-09-04T10:00:00Z'))).toBe( 'digest_sweep:2026-09-04:10', ); }); }); /** * The phase 4 acceptance case. Maria's RUC base ends in 6, so RULES.md puts her IVA * deadline on the 19th; the clock is moved to exactly ten days before that date and the * sweep is expected to queue her reminder. */ describe('deadline sweep, ten days out', () => { it('queues a notification for a taxpayer whose deadline is ten days away', async () => { const h = (harness = await createHarness()); // Derived, not hardcoded: whatever the calendario says the due date is, T-10 is the day // the sweep has to fire. const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6)); const tenDaysBefore = new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000); const queued = await runDeadlineSweep({ db: h.deps.handle.db, now: () => tenDaysBefore }); expect(queued).toBeGreaterThan(0); const jobs = await h.deps.handle.db .selectFrom('jobs') .selectAll() .where('type', '=', 'send_notification') .execute(); const maria = await h.deps.handle.db .selectFrom('user') .select('id') .where('email', '=', 'maria@demo.local') .executeTakeFirstOrThrow(); const hers = jobs.filter((job) => (JSON.parse(job.payload) as { userId: string }).userId === maria.id); expect(hers.length).toBeGreaterThan(0); // The dedupe key names the milestone, which is what keeps it to one reminder. expect(hers.some((job) => job.payload.includes(':T-10'))).toBe(true); expect(hers.some((job) => job.payload.includes('deadline_t2'))).toBe(true); }); it('queues nothing on a day that is not a milestone', async () => { const h = (harness = await createHarness()); const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6)); const sevenDaysBefore = new Date(Date.parse(`${dueDate}T12:00:00Z`) - 7 * 86_400_000); await runDeadlineSweep({ db: h.deps.handle.db, now: () => sevenDaysBefore }); const jobs = await h.deps.handle.db .selectFrom('jobs') .selectAll() .where('type', '=', 'send_notification') .execute(); expect(jobs).toEqual([]); }); it('does not send the same reminder twice', async () => { const h = (harness = await createHarness()); const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6)); const tenDaysBefore = new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000); const first = await runDeadlineSweep({ db: h.deps.handle.db, now: () => tenDaysBefore }); const second = await runDeadlineSweep({ db: h.deps.handle.db, now: () => tenDaysBefore }); expect(first).toBeGreaterThan(0); expect(second).toBe(0); }); it('fires on the day itself', async () => { const h = (harness = await createHarness()); const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6)); await runDeadlineSweep({ db: h.deps.handle.db, now: () => new Date(`${dueDate}T12:00:00Z`), }); const jobs = await h.deps.handle.db .selectFrom('jobs') .selectAll() .where('type', '=', 'send_notification') .execute(); expect(jobs.some((job) => job.payload.includes('deadline_t0'))).toBe(true); }); }); describe('the queued notification actually goes out', () => { it('reaches the channels the user has enabled, in their language', async () => { const channels = recordingChannels(); const h = (harness = await createHarness({ channels })); const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6)); const tenDaysBefore = new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000); await runDeadlineSweep({ db: h.deps.handle.db, now: () => tenDaysBefore }); await h.runJobs(); // Email is on for the seeded users, push is not, and no Telegram id is linked. expect(channels.sent.some((entry) => entry.channel === 'email')).toBe(true); expect(channels.sent.some((entry) => entry.channel === 'push')).toBe(false); expect(channels.sent.some((entry) => entry.channel === 'telegram')).toBe(false); // Spanish, because that is what her profile says. const message = channels.sent.find((entry) => entry.channel === 'email'); expect(message?.message.body).toContain('vence tu 120'); expect(message?.message.path).toBe('/declaraciones'); }); it('sends in English when the profile says so', async () => { const channels = recordingChannels(); const h = (harness = await createHarness({ channels })); await h.deps.handle.db .updateTable('profiles') .set({ locale: 'en' }) .where((eb) => eb( 'user_id', '=', eb.selectFrom('user').select('id').where('email', '=', 'maria@demo.local'), ), ) .execute(); const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6)); await runDeadlineSweep({ db: h.deps.handle.db, now: () => new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000), }); await h.runJobs(); expect(channels.sent.some((entry) => entry.message.body.includes('is due'))).toBe(true); }); it('sends nothing to a frozen account', async () => { const channels = recordingChannels(); const h = (harness = await createHarness({ channels })); await h.deps.handle.db.updateTable('user').set({ banned: 1 }).execute(); const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6)); await runDeadlineSweep({ db: h.deps.handle.db, now: () => new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000), }); await h.runJobs(); expect(channels.sent).toEqual([]); }); }); describe('auto confirm sweep', () => { it('confirms high confidence documents once the window has passed', async () => { const h = (harness = await createHarness()); // The seeded documents were created well in the past, so seven days have elapsed. const confirmed = await runAutoConfirmSweep({ db: h.deps.handle.db, now: () => new Date('2026-09-30T12:00:00Z'), }); expect(confirmed).toBeGreaterThan(0); const remaining = await h.deps.handle.db .selectFrom('documents') .leftJoin('classifications', 'classifications.document_id', 'documents.id') .select(['documents.id as id', 'classifications.confidence as confidence']) .where('documents.status', '=', 'needs_review') .execute(); // Whatever is left is exactly what a person still needs to look at. for (const row of remaining) expect(row.confidence ?? 0).toBeLessThan(0.8); }); it('leaves everything alone when the setting is off', async () => { const h = (harness = await createHarness()); await h.deps.handle.db.updateTable('profiles').set({ auto_confirm_days: 0 }).execute(); expect( await runAutoConfirmSweep({ db: h.deps.handle.db, now: () => new Date('2026-12-31T12:00:00Z'), }), ).toBe(0); }); it('never touches a decision the user already made', async () => { const h = (harness = await createHarness()); const pending = await h.deps.handle.db .selectFrom('documents') .select('id') .where('status', '=', 'needs_review') .executeTakeFirstOrThrow(); await h.deps.handle.db .updateTable('classifications') .set({ decided_by: 'user', confidence: 0.99 }) .where('document_id', '=', pending.id) .execute(); await runAutoConfirmSweep({ db: h.deps.handle.db, now: () => new Date('2026-12-31T12:00:00Z'), }); const after = await h.deps.handle.db .selectFrom('documents') .select('status') .where('id', '=', pending.id) .executeTakeFirstOrThrow(); expect(after.status).toBe('needs_review'); }); }); describe('digest sweep', () => { it('queues one bundled digest at the user chosen hour', async () => { const h = (harness = await createHarness()); const nineAm = new Date('2026-09-10T09:00:00Z'); expect(await runDigestSweep({ db: h.deps.handle.db, now: () => nineAm })).toBeGreaterThan(0); // Once per day, whatever else happens. expect(await runDigestSweep({ db: h.deps.handle.db, now: () => nineAm })).toBe(0); }); it('says nothing at any other hour', async () => { const h = (harness = await createHarness()); expect( await runDigestSweep({ db: h.deps.handle.db, now: () => new Date('2026-09-10T14:00:00Z'), }), ).toBe(0); }); it('says nothing when the bandeja is empty', async () => { const h = (harness = await createHarness()); await h.deps.handle.db .updateTable('documents') .set({ status: 'confirmed' }) .where('status', '=', 'needs_review') .execute(); expect( await runDigestSweep({ db: h.deps.handle.db, now: () => new Date('2026-09-10T09:00:00Z'), }), ).toBe(0); }); }); describe('the RUC the seed is built on', () => { it('still has the check digit the deadline day depends on', () => { // Base ends in 6, which RULES.md maps to the 19th. Every date in this file follows. expect(computeRucDv('4123456')).toBe(1); }); });