// apps/hq/test/settings-reminders.test.ts — Task 3: reminder settings + dated schedule inserts import { describe, expect, it, 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 = () => { const db = openDb(':memory:'); seedIfEmpty(db); return db } describe('schedule settings', () => { it('lists the two seeded open-ended rows', () => { const rows = listSchedules(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', () => { const db = fresh() const row = 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(resolveSchedule(db, 'quote_followup', '2026-08-02').dayOffsets).toEqual([2, 5, 9]) expect(resolveSchedule(db, 'quote_followup', '2026-07-20').dayOffsets).toEqual([3, 7, 14]) expect(listSchedules(db)).toHaveLength(3) // append-only: old row still there }) it('rejects bad input', () => { const db = fresh() expect(() => insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).toThrow() expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).toThrow() expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: 'x,-2' })).toThrow() }) it('audits the insert', () => { const db = fresh() const row = insertSchedule(db, 'u1', { ruleKind: 'invoice_overdue', effectiveFrom: '2026-09-01', dayOffsets: '10,20' }) const audit = db.prepare(`SELECT * FROM audit_log WHERE entity='reminder_schedule' AND entity_id=?`).get(row.id) expect(audit).toBeDefined() }) }) describe('settings reminders routes', () => { const appWith = () => { const db = openDb(':memory:'); seedIfEmpty(db) 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 } } const { db, server, base } = appWith() afterAll(() => server.close()) const login = async (email: string, password: string) => (await (await fetch(`${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(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) }) })