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/stalled-onboarding.test.ts

117 lines
6.8 KiB
TypeScript

// apps/hq/test/stalled-onboarding.test.ts — Task 8: stalled-onboarding query (dashboard/MIS tile).
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 { setMilestone, stalledProjects } 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' })
// A module code with no per-module milestone template seeded (D22 seed.ts only carries
// SMS/RTGS/MOBILEAPP overrides), so the global template applies: advance_paid, setup_done,
// installed, go_live, balance_paid, amc_start.
const m = await createModule(db, 'u1', { code: 'MISC', name: 'Misc Module' })
return { db, c, m }
}
describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
it('lists a project parked past the threshold', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
// Early steps done on an old date; later steps (incl. go_live) stay pending — project is
// still active (status stays 'quoted'/'installed', never 'live').
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
// last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked.
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).toContain(cm.id)
const row = stalled.rows.find((p) => p.clientModuleId === cm.id)!
expect(row.clientName).toBe('Kothavara SCB')
expect(row.moduleCode).toBe('MISC')
expect(stalled.unknownAgeExcluded).toBe(0)
})
it('excludes a recently-progressed project', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
const stalled = await stalledProjects(db, 365, '2026-07-19')
expect(stalled.rows).toHaveLength(0)
expect(stalled.unknownAgeExcluded).toBe(0)
})
it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => {
const { db, c, m } = await world()
// Fully ticked project: nothing pending, so it should never appear regardless of age.
const done = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
for (const key of ['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) {
await setMilestone(db, 'u1', done.id, key, true, '2026-01-01')
}
const stalled = await stalledProjects(db, 1, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(done.id)
// 'live' status (from ticking go_live) also excludes it, which the loop above proves.
expect((await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, done.id))!.status).toBe('live')
})
// Pre-ship audit FIX D: a project with ZERO ticks used to be measured against the sentinel
// '0000-00-00', which is always older than any cutoff — so a project assigned moments ago
// immediately showed up as "stalled" on the dashboard tile. It should instead be measured
// from client_module.created_at (stamped by assignModule), and excluded entirely when that
// isn't known (never guessed to be old).
describe('a no-tick project is measured from its creation date, not treated as infinitely old (FIX D)', () => {
it('a project assigned "today" with zero ticks is NOT stalled', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
// Deterministic: pin created_at to the test's fictional "today" rather than relying on
// assignModule's real wall-clock stamp matching whatever the test's fixed date happens
// to be — this test is about the QUERY logic, not the real-time write path.
await db.run(`UPDATE client_module SET created_at=? WHERE id=?`, '2026-07-19T09:00:00.000Z', cm.id)
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(0)
})
it('a project created long ago with zero ticks IS stalled', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=? WHERE id=?`, '2025-01-01T09:00:00.000Z', cm.id)
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).toContain(cm.id)
})
it('a no-tick project with an UNKNOWN creation date (created_at NULL) is excluded, not assumed ancient', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
// Simulates a client_module that predates the created_at column (or an import that
// doesn't stamp it) — created_at is genuinely unknown.
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id)
const stalled = await stalledProjects(db, 0, '2026-07-19') // even a 0-day cutoff must not flag it
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
})
// FIX 4 (no silent caps): the row above used to just vanish with no trace. It must now be
// counted so the UI can surface "N of unknown age" instead of silently under-reporting.
it('a no-tick row with NULL created_at lands in the unknownAgeExcluded count, not just silently dropped', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id)
const stalled = await stalledProjects(db, 0, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(1)
})
it('assignModule itself stamps a real (non-null) created_at', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const row = await db.get<{ created_at: string | null }>(
`SELECT created_at FROM client_module WHERE id=?`, cm.id,
)
expect(row!.created_at).not.toBeNull()
})
})
})