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.
117 lines
6.8 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|