// apps/hq/test/milestones.test.ts — D22: onboarding milestone tracker. import { describe, it, expect } from 'vitest' import { openDb } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createClient } from '../src/repos-clients' import { createModule, assignModule } from '../src/repos-modules' import { listProjectMilestones, setMilestone, addProjectMilestone, ensureProjectMilestones, milestoneBoard, projectsPendingMilestone, } from '../src/repos-milestones' async function world() { const db = openDb(':memory:') await seedIfEmpty(db) const c1 = await createClient(db, 'u1', { name: 'Alpha SCB', stateCode: '32' }) const c2 = await createClient(db, 'u1', { name: 'Beta SCB', stateCode: '32' }) const m = await createModule(db, 'u1', { code: 'CLOUDBACKUP', name: 'Cloud Backup' }) const p1 = await assignModule(db, 'u1', { clientId: c1.id, moduleId: m.id, kind: 'yearly' }) const p2 = await assignModule(db, 'u1', { clientId: c2.id, moduleId: m.id, kind: 'yearly' }) return { db, c1, c2, m, p1, p2 } } describe('project milestones (D22)', () => { it('seeds the standard checklist on assignment, in template order (front + tail)', async () => { const { db, p1 } = await world() const ms = await listProjectMilestones(db, p1.id) expect(ms.map((m) => m.key)).toEqual([ 'enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved', 'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start', ]) expect(ms.every((m) => !m.done && m.doneOn === null)).toBe(true) }) it('ticks a milestone (stamps the date), unticks (clears it), audited', async () => { const { db, p1 } = await world() const after = await setMilestone(db, 'u1', p1.id, 'advance_paid', true, '2026-07-12') expect(after.find((m) => m.key === 'advance_paid')).toMatchObject({ done: true, doneOn: '2026-07-12' }) const todayTick = await setMilestone(db, 'u1', p1.id, 'go_live', true) // defaults to today expect(todayTick.find((m) => m.key === 'go_live')!.doneOn).toMatch(/^\d{4}-\d{2}-\d{2}$/) const cleared = await setMilestone(db, 'u1', p1.id, 'advance_paid', false) expect(cleared.find((m) => m.key === 'advance_paid')).toMatchObject({ done: false, doneOn: null }) const audits = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log WHERE action='set_milestone'`))!.n expect(audits).toBe(3) await expect(setMilestone(db, 'u1', p1.id, 'nope', true)).rejects.toThrow(/not found/) await expect(setMilestone(db, 'u1', p1.id, 'go_live', true, 'bad-date')).rejects.toThrow(/YYYY-MM-DD/) }) it('adds a project-specific milestone after the standard set', async () => { const { db, p1 } = await world() const ms = await addProjectMilestone(db, 'u1', p1.id, 'AWS region confirmed') expect(ms).toHaveLength(11) // 4 front + 6 tail + 1 custom expect(ms[ms.length - 1]!.label).toBe('AWS region confirmed') await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/) }) it('ensureProjectMilestones is idempotent and back-fills imported projects', async () => { const { db, c1, m } = await world() // Simulate an imported project (direct insert, bypassing assignModule). await db.run( `INSERT INTO client_module (id, client_id, module_id, status, kind, edition) VALUES (?, ?, ?, 'live', 'yearly', 'standard')`, 'imported-cm', c1.id, m.id, ) expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0) await ensureProjectMilestones(db, 'imported-cm') expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10) // 4 front + 6 tail await ensureProjectMilestones(db, 'imported-cm') // second call adds nothing expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10) }) it('boards the whole book and drills into who is pending a milestone', async () => { const { db, c1, p1, p2 } = await world() await setMilestone(db, 'u1', p1.id, 'advance_paid', true) await setMilestone(db, 'u1', p1.id, 'go_live', true) await setMilestone(db, 'u1', p2.id, 'advance_paid', true) const board = await milestoneBoard(db) const advance = board.find((b) => b.key === 'advance_paid')! expect(advance).toMatchObject({ total: 2, done: 2, pending: 0 }) const goLive = board.find((b) => b.key === 'go_live')! expect(goLive).toMatchObject({ total: 2, done: 1, pending: 1 }) const pendingGoLive = await projectsPendingMilestone(db, 'go_live') expect(pendingGoLive).toHaveLength(1) expect(pendingGoLive[0]).toMatchObject({ clientId: p2.clientId, moduleCode: 'CLOUDBACKUP' }) // Deactivating a project drops it from the board. await db.run(`UPDATE client_module SET active=0 WHERE id=?`, p2.id) expect((await milestoneBoard(db)).find((b) => b.key === 'go_live')).toMatchObject({ total: 1, done: 1, pending: 0 }) void c1 }) })