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.
86 lines
4.7 KiB
TypeScript
86 lines
4.7 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', async () => {
|
|
const { db, p1 } = await world()
|
|
const ms = await listProjectMilestones(db, p1.id)
|
|
expect(ms.map((m) => m.key)).toEqual(['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(7)
|
|
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(6)
|
|
await ensureProjectMilestones(db, 'imported-cm') // second call adds nothing
|
|
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6)
|
|
})
|
|
|
|
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
|
|
})
|
|
})
|