// apps/hq/test/settings-reminders.test.ts — Task 3: reminder settings + dated schedule inserts import { describe, expect, it, beforeAll, afterAll } from 'vitest' import express from 'express' import { openDb } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createStaff } from '../src/auth' import { apiRouter } from '../src/api' import { insertSchedule, listSchedules, resolveSchedule } from '../src/repos-reminders' const fresh = async () => { const db = openDb(':memory:'); await seedIfEmpty(db); return db } describe('schedule settings', () => { it('lists the two seeded open-ended rows', async () => { const rows = await listSchedules(await fresh()) expect(rows.map((r) => r.ruleKind).sort()).toEqual(['invoice_overdue', 'quote_followup']) }) it('insertSchedule appends a dated row that resolveSchedule picks from its date', async () => { const db = await fresh() const row = await insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: ' 2, 5,5, 9 ', }) expect(row.dayOffsets).toBe('2,5,9') // normalized: trimmed, deduped, sorted expect((await resolveSchedule(db, 'quote_followup', '2026-08-02')).dayOffsets).toEqual([2, 5, 9]) expect((await resolveSchedule(db, 'quote_followup', '2026-07-20')).dayOffsets).toEqual([3, 7, 14]) expect(await listSchedules(db)).toHaveLength(3) // append-only: old row still there }) it('rejects bad input', async () => { const db = await fresh() await expect(insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).rejects.toThrow() await expect(insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).rejects.toThrow() await expect(insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: 'x,-2' })).rejects.toThrow() // Strict CSV: partial garbage must reject the WHOLE input, never save '3,7' // with the typo'd 14-day follow-up silently dropped from the cadence. await expect(insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: '3,7,I4' })).rejects.toThrow(/Invalid day offset/) expect(await listSchedules(db)).toHaveLength(2) // nothing appended by any rejected attempt }) it('audits the insert', async () => { const db = await fresh() const row = await insertSchedule(db, 'u1', { ruleKind: 'invoice_overdue', effectiveFrom: '2026-09-01', dayOffsets: '10,20' }) const audit = await db.get(`SELECT * FROM audit_log WHERE entity='reminder_schedule' AND entity_id=?`, row.id) expect(audit).toBeDefined() }) }) describe('settings reminders routes', () => { const appWith = async () => { const db = openDb(':memory:'); await seedIfEmpty(db) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const server = app.listen(0) const base = `http://localhost:${(server.address() as { port: number }).port}/api` return { db, server, base } } let ctx: Awaited> beforeAll(async () => { ctx = await appWith() }) afterAll(() => ctx.server.close()) const login = async (email: string, password: string) => (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token const call = async (token: string, method: string, path: string, body?: unknown) => { const res = await fetch(ctx.base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) }) return { status: res.status, json: await res.json() as any } } it('validates whole body before writing any setting', async () => { const token = await login('owner@test.in', 'owner-password') // Get initial state const initial = await call(token, 'GET', '/settings/reminders') const initialOverdue = initial.json.overdueDays // Attempt to update with mixed valid/invalid fields — should fail const mixed = await call(token, 'PUT', '/settings/reminders', { overdueDays: 5, renewalDays: -1 }) expect(mixed.status).toBe(400) // Verify overdueDays was NOT persisted (no partial write) const after = await call(token, 'GET', '/settings/reminders') expect(after.json.overdueDays).toBe(initialOverdue) }) it('day settings round-trip: a valid PUT persists and GET reflects it', async () => { const token = await login('owner@test.in', 'owner-password') const put = await call(token, 'PUT', '/settings/reminders', { overdueDays: 9, renewalDays: 21 }) expect(put.status).toBe(200) expect(put.json.ok).toBe(true) const after = await call(token, 'GET', '/settings/reminders') expect(after.json.overdueDays).toBe(9) expect(after.json.renewalDays).toBe(21) // Both writes were audited (same-transaction rule) const audits = await ctx.db.all<{ entity_id: string }>( `SELECT entity_id FROM audit_log WHERE entity='setting' AND entity_id IN ('reminders.overdue_days','reminders.renewal_days')`, ) expect(new Set(audits.map((a) => a.entity_id)).size).toBe(2) }) })