// apps/hq/test/reminder-schedule.test.ts — Phase 4: dated reminder schedule (spec §4, §7) import { describe, it, expect } from 'vitest' import { uuidv7 } from '@sims/domain' import { openDb, type DB } from '../src/db' import { resolveSchedule, SCHEDULE_DEFAULTS } from '../src/repos-reminders' import { seedIfEmpty } from '../src/seed' interface Row { ruleKind: string; from: string; to?: string | null offsets: string; subject?: string | null; body?: string | null } /** Raw insert — proves a cadence change is a dated DB row, no code edit (rule 3). */ async function insertSchedule(db: DB, r: Row): Promise { await db.run( `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) VALUES (?, ?, ?, ?, ?, ?, ?)`, uuidv7(), r.ruleKind, r.from, r.to ?? null, r.offsets, r.subject ?? null, r.body ?? null, ) } describe('resolveSchedule', () => { it('falls back to the code defaults when no row exists', async () => { const db = openDb(':memory:') // unseeded — table empty const qf = await resolveSchedule(db, 'quote_followup', '2026-07-17') expect(qf.dayOffsets).toEqual([3, 7, 14]) expect(qf.source).toBe('default') expect(qf.subject).toContain('{ref}') expect(qf.body).toContain('{shareUrl}') const inv = await resolveSchedule(db, 'invoice_overdue', '2026-07-17') expect(inv.dayOffsets).toEqual([7, 15, 30]) expect(inv.source).toBe('default') expect(inv.subject).toBeNull() // invoice mail text stays in reminder-templates expect(inv.body).toBeNull() }) it('returns the dated row active on today; latest effective_from wins', async () => { const db = openDb(':memory:') await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '3,7,14' }) await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-07-01', offsets: '2,5,9' }) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([2, 5, 9]) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).source).toBe('db') expect((await resolveSchedule(db, 'quote_followup', '2026-03-01')).dayOffsets).toEqual([3, 7, 14]) // a row dated in the future is not active yet expect((await resolveSchedule(db, 'quote_followup', '2025-12-31')).source).toBe('default') }) it('tied effective_from resolves deterministically: highest id wins on any engine', async () => { const db = openDb(':memory:') const ins = (id: string, offsets: string) => db.run( `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) VALUES (?, 'quote_followup', '2026-01-01', NULL, ?, NULL, NULL)`, id, offsets, ) // Insert the higher id FIRST so plain scan order would pick the other row — // the tiebreaker (id DESC; UUIDv7 ids are time-ordered) must decide, not the plan. await ins('zzzz-high', '2,4') await ins('aaaa-low', '5,10') expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([2, 4]) }) it('effective_to closes the window: active requires effective_to > today', async () => { const db = openDb(':memory:') await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', to: '2026-07-01', offsets: '1,2' }) expect((await resolveSchedule(db, 'quote_followup', '2026-06-30')).dayOffsets).toEqual([1, 2]) expect((await resolveSchedule(db, 'quote_followup', '2026-07-01')).source).toBe('default') // boundary day excluded }) it('a new dated row changes cadence with no code edit', async () => { const db = openDb(':memory:') await seedIfEmpty(db) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([3, 7, 14]) await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-08-01', offsets: '5,10' }) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([3, 7, 14]) // unchanged before expect((await resolveSchedule(db, 'quote_followup', '2026-08-01')).dayOffsets).toEqual([5, 10]) // new cadence after }) it('parses messy CSV ascending and falls back on garbage offsets', async () => { const db = openDb(':memory:') await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: ' 14, 3 ,7 ' }) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([3, 7, 14]) await insertSchedule(db, { ruleKind: 'invoice_overdue', from: '2026-01-01', offsets: 'a,b,-3,0' }) const inv = await resolveSchedule(db, 'invoice_overdue', '2026-07-17') expect(inv.dayOffsets).toEqual([7, 15, 30]) // nothing usable → code default expect(inv.source).toBe('default') }) it('a cadence-only row keeps the code-constant message text', async () => { const db = openDb(':memory:') await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '4,8' }) const r = await resolveSchedule(db, 'quote_followup', '2026-07-17') expect(r.dayOffsets).toEqual([4, 8]) expect(r.subject).toBe(SCHEDULE_DEFAULTS.quote_followup.subject) expect(r.body).toBe(SCHEDULE_DEFAULTS.quote_followup.body) }) }) describe('seeded schedule rows', () => { it('seedIfEmpty seeds one dated row per rule kind, audited, and is idempotent', async () => { const db = openDb(':memory:') await seedIfEmpty(db) await seedIfEmpty(db) // idempotent — no duplicates const rows = await db.all<{ rule_kind: string; n: number }>( `SELECT rule_kind, COUNT(*) AS n FROM reminder_schedule GROUP BY rule_kind ORDER BY rule_kind`, ) expect(rows).toEqual([ { rule_kind: 'invoice_overdue', n: 1 }, { rule_kind: 'quote_followup', n: 1 }, ]) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).source).toBe('db') expect((await resolveSchedule(db, 'invoice_overdue', '2026-07-17')).source).toBe('db') const audits = (await db.get<{ n: number }>( `SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder_schedule'`, ))! expect(audits.n).toBe(2) }) })