Files
impuestospy/apps/api/src/modules/jobs/sweeps.test.ts
T
MichilisandClaude Opus 5 e4eb1617d1 phase-7: motion, an installable app, and a capture that survives no signal
GSAP carries the counter roll-ups, the bandeja card physics, the dialog
transitions and the three success moments FLOWS.md allows. Every one of
them checks prefers-reduced-motion first and does nothing when it is set.

boneyard and canvas-ui are not what SPEC.md's stack table says they are:
on npm the names belong to two abandoned projects that do neither job.
The skeletons were already ours; the two canvas spots are now sixty lines
each with no dependency. DECISIONS.md records the substitution.

The app installs, keeps a scan taken with no network in IndexedDB and
sends it when there is one, falls back to a page that explains itself,
and can push a deadline notice. Reading the log of what is queued is the
source of truth, so the notice clears when the capture actually lands.

The CSP now allows scripts by per-request nonce rather than by
'unsafe-inline'. That forced /offline to render per request: a
prerendered page carries a build-time nonce no live policy matches, so
its scripts were blocked and it never hydrated.

Two crashes fixed on the way. web-push throws on a VAPID subject that is
not https: or mailto:, and the code handed it APP_PUBLIC_URL, so any
machine with push keys died at boot; a misconfigured optional channel now
switches itself off and says why. And a subscription the push service
answers 410 for is deleted rather than retried forever.

Lighthouse on the production build: accessibility 100, best practices 96,
SEO 100, performance 73. The performance number is not trustworthy on
this machine and DECISIONS.md says why; total blocking time did fall from
17.6s to 1.7s once the hero canvas stopped drawing at full resolution
every frame and the landing page stopped importing GSAP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:48:48 +00:00

313 lines
11 KiB
TypeScript

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);
});
});