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>
313 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|