// 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.map((p) => p.clientModuleId)).toContain(cm.id) const row = stalled.find((p) => p.clientModuleId === cm.id)! expect(row.clientName).toBe('Kothavara SCB') expect(row.moduleCode).toBe('MISC') }) 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).toHaveLength(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.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.map((p) => p.clientModuleId)).not.toContain(cm.id) }) 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.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.map((p) => p.clientModuleId)).not.toContain(cm.id) }) 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() }) }) })