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

117 lines
6.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 (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)
})
// Final whole-branch review FIX 1(b): setFrontTemplate/setModuleTail now reject a
// cross-half key collision, but a template can still end up with a duplicate key some
// other way (e.g. a setting row written directly, or one that predates that guard) —
// ensureProjectMilestones must not crash with a UNIQUE (client_module_id, key) violation
// or insert the same key twice; it must dedupe within its own insert loop.
it('ensureProjectMilestones tolerates a front/tail key collision in the resolved template', async () => {
const { db, c1, m } = await world()
// Force a front/tail collision by writing the settings directly (bypassing the
// now-guarded setFrontTemplate/setModuleTail). world() ran seedIfEmpty, which already
// seeded the front row, so UPDATE it rather than INSERT.
await db.run(`UPDATE setting SET value=? WHERE key='project.milestone_template.front'`,
JSON.stringify([{ key: 'enquiry', label: 'Enquiry received' }, { key: 'installed', label: 'Front installed' }]))
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'installed', label: 'Tail installed' }, { key: 'go_live', label: 'Go-live' }]))
await db.run(
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition) VALUES (?, ?, ?, 'quoted', 'yearly', 'standard')`,
'colliding-cm', c1.id, m.id,
)
await expect(ensureProjectMilestones(db, 'colliding-cm')).resolves.toBeUndefined()
const ms = await listProjectMilestones(db, 'colliding-cm')
// Only ONE row for the colliding key ('installed'), not two.
expect(ms.filter((mm) => mm.key === 'installed')).toHaveLength(1)
expect(ms.map((mm) => mm.key)).toEqual(['enquiry', 'installed', 'go_live'])
// Re-running stays safe and adds nothing new.
await ensureProjectMilestones(db, 'colliding-cm')
expect(await listProjectMilestones(db, 'colliding-cm')).toHaveLength(3)
})
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
})
})