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.
sims-hq/apps/hq/test/milestones.test.ts

89 lines
4.8 KiB
TypeScript

// 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
})
})