You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
91 lines
5.1 KiB
TypeScript
91 lines
5.1 KiB
TypeScript
// 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()
|
|
// 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.
|
|
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: '3,7,I4' })).toThrow(/Invalid day offset/)
|
|
expect(listSchedules(db)).toHaveLength(2) // nothing appended by any rejected attempt
|
|
})
|
|
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)
|
|
})
|
|
|
|
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 = db.prepare(
|
|
`SELECT entity_id FROM audit_log WHERE entity='setting' AND entity_id IN ('reminders.overdue_days','reminders.renewal_days')`,
|
|
).all() as { entity_id: string }[]
|
|
expect(new Set(audits.map((a) => a.entity_id)).size).toBe(2)
|
|
})
|
|
})
|