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.
192 lines
9.9 KiB
TypeScript
192 lines
9.9 KiB
TypeScript
// apps/hq/test/milestone-status.test.ts — milestone→status auto-drive (never downgrade).
|
|
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, getClientModule, updateClientModule } from '../src/repos-modules'
|
|
import { setMilestone } from '../src/repos-milestones'
|
|
|
|
async function world() {
|
|
const db = openDb(':memory:')
|
|
await seedIfEmpty(db)
|
|
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
|
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
|
return { db, c, m, cmId: cm.id }
|
|
}
|
|
|
|
describe('milestone → status auto-drive (D22 status line)', () => {
|
|
it('ticking quotation_approved advances status to ordered', async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'quotation_approved', true)
|
|
const cm = await getClientModule(db, cmId)
|
|
expect(cm!.status).toBe('ordered')
|
|
})
|
|
|
|
it('ticking installation advances status to installed', async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'installation', true)
|
|
const cm = await getClientModule(db, cmId)
|
|
expect(cm!.status).toBe('installed')
|
|
})
|
|
|
|
it('ticking go_live advances status to live', async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'go_live', true)
|
|
expect((await getClientModule(db, cmId))!.status).toBe('live')
|
|
})
|
|
|
|
it('unticking never downgrades status', async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'go_live', true)
|
|
await setMilestone(db, 'u1', cmId, 'go_live', false)
|
|
expect((await getClientModule(db, cmId))!.status).toBe('live')
|
|
})
|
|
|
|
it('a lower step never downgrades a higher status', async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'go_live', true) // live
|
|
await setMilestone(db, 'u1', cmId, 'installation', true) // still live
|
|
expect((await getClientModule(db, cmId))!.status).toBe('live')
|
|
})
|
|
})
|
|
|
|
// Final whole-branch review FIX 5: installedOn / trainedOn became write-only once the UI's
|
|
// RowDate / summary-table date writers were removed with nothing to replace them — the
|
|
// milestone-driven status advance is now that replacement.
|
|
describe('milestone-driven installed_on / trained_on stamping (FIX 5)', () => {
|
|
it("ticking 'installation' (which advances status to installed) stamps installed_on from the milestone's done_on", async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
|
const cm = await getClientModule(db, cmId)
|
|
expect(cm!.status).toBe('installed')
|
|
expect(cm!.installedOn).toBe('2026-03-25')
|
|
})
|
|
|
|
it("ticking 'training' (which advances status to trained) stamps trained_on from the milestone's done_on", async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'training', true, '2026-05-02')
|
|
const cm = await getClientModule(db, cmId)
|
|
expect(cm!.status).toBe('trained')
|
|
expect(cm!.trainedOn).toBe('2026-05-02')
|
|
})
|
|
|
|
it('never overwrites an installed_on already on file (e.g. set by import / direct edit)', async () => {
|
|
const { db, cmId } = await world()
|
|
await updateClientModule(db, 'u1', cmId, { installedOn: '2025-01-01' })
|
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
|
const cm = await getClientModule(db, cmId)
|
|
expect(cm!.status).toBe('installed') // status still advances
|
|
expect(cm!.installedOn).toBe('2025-01-01') // date untouched
|
|
})
|
|
|
|
it('re-ticking after an untick does not move an already-stamped installed_on', async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
|
await setMilestone(db, 'u1', cmId, 'installation', false)
|
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-04-01')
|
|
expect((await getClientModule(db, cmId))!.installedOn).toBe('2026-03-25')
|
|
})
|
|
|
|
it('leaves installed_on / trained_on untouched for a step with no date-column mapping (e.g. go_live)', async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'go_live', true, '2026-06-01')
|
|
const cm = await getClientModule(db, cmId)
|
|
expect(cm!.status).toBe('live')
|
|
expect(cm!.installedOn).toBeNull()
|
|
expect(cm!.trainedOn).toBeNull()
|
|
})
|
|
|
|
it('the status-advance audit payload reflects the installed_on stamp', async () => {
|
|
const { db, cmId } = await world()
|
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
|
const audits = await db.all<{ after_json: string | null }>(
|
|
`SELECT after_json FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update' ORDER BY id`,
|
|
cmId,
|
|
)
|
|
const stamped = audits.find((a) => (a.after_json ?? '').includes('installedOn'))
|
|
expect(stamped).toBeDefined()
|
|
expect(JSON.parse(stamped!.after_json!)).toMatchObject({ status: 'installed', installedOn: '2026-03-25' })
|
|
})
|
|
})
|
|
|
|
// The date-stamp used to ride on the status UPGRADE, so a project already at the furthest
|
|
// status could never record one. That is the state of all ~300 APEX-imported projects: they
|
|
// come in at 'live', ticking Installation/Training upgrades nothing, and — with the UI's own
|
|
// date writers removed — installed_on/trained_on became permanently unfillable, leaving the
|
|
// Module Directory's "Installed" column blank forever. Ticking now stamps regardless.
|
|
describe('date stamping is independent of the status upgrade (APEX-imported projects at live)', () => {
|
|
it("ticking 'installation' on a project already at live records installed_on and leaves the status alone", async () => {
|
|
const { db, cmId } = await world()
|
|
await updateClientModule(db, 'u1', cmId, { status: 'live' })
|
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
|
const cm = await getClientModule(db, cmId)
|
|
expect(cm!.installedOn).toBe('2026-03-25')
|
|
expect(cm!.status).toBe('live') // unchanged — no downgrade, no spurious upgrade
|
|
})
|
|
|
|
it("ticking 'training' on a project already at live records trained_on and leaves the status alone", async () => {
|
|
const { db, cmId } = await world()
|
|
await updateClientModule(db, 'u1', cmId, { status: 'live' })
|
|
await setMilestone(db, 'u1', cmId, 'training', true, '2026-05-02')
|
|
const cm = await getClientModule(db, cmId)
|
|
expect(cm!.trainedOn).toBe('2026-05-02')
|
|
expect(cm!.status).toBe('live')
|
|
})
|
|
|
|
it('still never overwrites a date already on file, even with no status move', async () => {
|
|
const { db, cmId } = await world()
|
|
await updateClientModule(db, 'u1', cmId, { status: 'live', installedOn: '2025-01-01' })
|
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
|
expect((await getClientModule(db, cmId))!.installedOn).toBe('2025-01-01')
|
|
})
|
|
|
|
it('the audit payload records the date-only change (no status key, since status did not move)', async () => {
|
|
const { db, cmId } = await world()
|
|
await updateClientModule(db, 'u1', cmId, { status: 'live' })
|
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
|
const audits = await db.all<{ after_json: string | null }>(
|
|
`SELECT after_json FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update' ORDER BY id`,
|
|
cmId,
|
|
)
|
|
// The last 'update' row is the tick's own (the earlier one is the status seed above).
|
|
const after = JSON.parse(audits.at(-1)!.after_json!) as Record<string, unknown>
|
|
expect(after['installedOn']).toBe('2026-03-25')
|
|
expect(after['status']).toBeUndefined()
|
|
})
|
|
|
|
it('a re-tick that changes nothing writes no client_module audit row at all', async () => {
|
|
const { db, cmId } = await world()
|
|
await updateClientModule(db, 'u1', cmId, { status: 'live', installedOn: '2025-01-01' })
|
|
const countBefore = (await db.all(
|
|
`SELECT id FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update'`, cmId)).length
|
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
|
const countAfter = (await db.all(
|
|
`SELECT id FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update'`, cmId)).length
|
|
expect(countAfter).toBe(countBefore)
|
|
})
|
|
})
|
|
|
|
// Pre-ship audit FIX E: STEP_STATUS only had keys from the NEW template vocabulary
|
|
// (installation/training/go_live/quotation_approved), but TAIL_FALLBACK — used by any module
|
|
// with no seeded/owner-configured tail (e.g. WHATSAPP, ATM, AMC) — still uses the legacy
|
|
// 'installed' key and has no training step. So ticking "Installation done" on one of those
|
|
// modules left status at 'quoted' and installed_on NULL forever (the UI's date writers were
|
|
// removed with nothing to replace them there). STEP_STATUS now maps the legacy key too.
|
|
describe('fallback-template (TAIL_FALLBACK) modules auto-advance too (FIX E)', () => {
|
|
it("ticking the legacy 'installed' step (no seeded per-module template) advances status to installed and stamps installed_on", async () => {
|
|
const db = openDb(':memory:')
|
|
await seedIfEmpty(db)
|
|
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
|
// A module code with no seeded per-module tail (seed.ts only carries SMS/RTGS/MOBILEAPP
|
|
// overrides) and no global tail setting -> resolves to the code TAIL_FALLBACK, whose
|
|
// install step is keyed 'installed', not 'installation'.
|
|
const m = await createModule(db, 'u1', { code: 'ATM', name: 'ATM Service' })
|
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
|
await setMilestone(db, 'u1', cm.id, 'installed', true, '2026-03-25')
|
|
const after = await getClientModule(db, cm.id)
|
|
expect(after!.status).toBe('installed')
|
|
expect(after!.installedOn).toBe('2026-03-25')
|
|
})
|
|
})
|