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

315 lines
16 KiB
TypeScript

// apps/hq/test/pipeline.test.ts — Phase 5: pipeline chase-list (D-PIPE option B).
// Union of latest-quotation-per-client + bare leads; derived stage/age/next-action/band
// (bands from resolveSchedule — never hardcoded 3/7/14); staff server-forced to own rows;
// actionable-oldest-first sort; LIMIT/OFFSET pagination with total.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb, type DB } from '../src/db'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, convertDocument, markStatus } from '../src/repos-documents'
import { listPipeline } from '../src/repos-pipeline'
import { apiRouter } from '../src/api'
const TODAY = '2026-07-17'
const OWNER_VIEW = { viewerRole: 'owner', viewerId: 'boss', today: TODAY } as const
function daysAgo(n: number): string {
return new Date(Date.parse(TODAY) - n * 86_400_000).toISOString()
}
function setup(): { db: DB; moduleId: string } {
const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
return { db, moduleId: m.id }
}
/** Draft QUOTATION for the client; optionally marked sent with the event backdated `ageDays`. */
function quote(db: DB, clientId: string, moduleId: string, opts: { by?: string; sentAgeDays?: number } = {}) {
const by = opts.by ?? 'u1'
const d = createDraft(db, by, {
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
})
if (opts.sentAgeDays !== undefined) {
markStatus(db, by, d.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`)
.run(daysAgo(opts.sentAgeDays), d.id)
}
return d
}
describe('listPipeline — stage derivation (spec §9)', () => {
it('a bare lead with no quotation appears as Enquiry owned by client.owner_id', () => {
const { db, moduleId } = setup()
const emp = createStaff(db, { email: 'v@x.co', displayName: 'Vikram', role: 'staff', password: 'password2' })
const lead = createClient(db, 'u1', { name: 'Nagari Sah.', stateCode: '32', status: 'lead' })
db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(emp.id, lead.id)
// an active client with no quotation is NOT pipeline material
createClient(db, 'u1', { name: 'Steady Customer', stateCode: '32', status: 'active' })
// a lead WITH a quotation shows as its quote row, not a duplicate enquiry
const quotedLead = createClient(db, 'u1', { name: 'Quoted Lead', stateCode: '32', status: 'lead' })
quote(db, quotedLead.id, moduleId)
const out = listPipeline(db, { ...OWNER_VIEW })
const enquiry = out.rows.find((r) => r.clientId === lead.id)
expect(enquiry).toMatchObject({
stage: 'enquiry', nextAction: 'send_quote', docId: null, amountPaise: null,
ownerId: emp.id, ownerName: 'Vikram', ageDays: null, band: null,
})
expect(out.rows.filter((r) => r.clientId === quotedLead.id)).toHaveLength(1)
expect(out.rows.find((r) => r.clientId === quotedLead.id)!.stage).toBe('new_project')
expect(out.rows.some((r) => r.clientName === 'Steady Customer')).toBe(false)
})
it('derives New Project / Quoted-Waiting / Won from the latest quotation status', () => {
const { db, moduleId } = setup()
const draft = createClient(db, 'u1', { name: 'Draft Co', stateCode: '32', status: 'lead' })
quote(db, draft.id, moduleId)
const sent = createClient(db, 'u1', { name: 'Sent Co', stateCode: '32', status: 'lead' })
quote(db, sent.id, moduleId, { sentAgeDays: 1 })
const accepted = createClient(db, 'u1', { name: 'Accepted Co', stateCode: '32' })
const qa = quote(db, accepted.id, moduleId, { sentAgeDays: 2 })
markStatus(db, 'u1', qa.id, 'accepted')
const invoiced = createClient(db, 'u1', { name: 'Invoiced Co', stateCode: '32' })
const qi = quote(db, invoiced.id, moduleId, { sentAgeDays: 2 })
markStatus(db, 'u1', qi.id, 'accepted')
convertDocument(db, 'u1', qi.id, 'INVOICE')
const by = new Map(listPipeline(db, { ...OWNER_VIEW }).rows.map((r) => [r.clientName, r]))
expect(by.get('Draft Co')).toMatchObject({ stage: 'new_project', nextAction: 'send_quote' })
expect(by.get('Sent Co')).toMatchObject({ stage: 'quoted_waiting', ageDays: 1 })
expect(by.get('Sent Co')!.amountPaise).toBe(11_800_00)
expect(by.get('Accepted Co')).toMatchObject({ stage: 'won', nextAction: 'convert_invoice' })
expect(by.get('Invoiced Co')).toMatchObject({ stage: 'won', nextAction: 'none' })
})
it('the LATEST quotation wins when a client has several', () => {
const { db, moduleId } = setup()
const c = createClient(db, 'u1', { name: 'Two Quotes', stateCode: '32' })
const old = quote(db, c.id, moduleId, { sentAgeDays: 40 })
markStatus(db, 'u1', old.id, 'lost')
quote(db, c.id, moduleId, { sentAgeDays: 2 })
const rows = listPipeline(db, { ...OWNER_VIEW }).rows.filter((r) => r.clientId === c.id)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ stage: 'quoted_waiting', ageDays: 2 })
})
it('Lost (quote lost or client lost) is hidden unless filtered', () => {
const { db, moduleId } = setup()
const lostQuote = createClient(db, 'u1', { name: 'Lost Quote Co', stateCode: '32' })
const q = quote(db, lostQuote.id, moduleId, { sentAgeDays: 5 })
markStatus(db, 'u1', q.id, 'lost')
const lostClient = createClient(db, 'u1', { name: 'Lost Client Co', stateCode: '32', status: 'lost' })
quote(db, lostClient.id, moduleId, { sentAgeDays: 5 })
const alive = createClient(db, 'u1', { name: 'Alive Co', stateCode: '32' })
quote(db, alive.id, moduleId, { sentAgeDays: 1 })
const all = listPipeline(db, { ...OWNER_VIEW })
expect(all.rows.map((r) => r.clientName)).toEqual(['Alive Co'])
expect(all.total).toBe(1)
const lost = listPipeline(db, { ...OWNER_VIEW, filter: 'lost' })
expect(lost.rows.map((r) => r.stage)).toEqual(['lost', 'lost'])
expect(lost.total).toBe(2)
})
it('a quote-less LOST client is reviewable under filter=lost (spec §9), hidden otherwise', () => {
const { db } = setup()
createClient(db, 'u1', { name: 'Quote-less Lost Co', stateCode: '32', status: 'lost' })
expect(listPipeline(db, { ...OWNER_VIEW }).total).toBe(0)
const lost = listPipeline(db, { ...OWNER_VIEW, filter: 'lost' })
expect(lost.total).toBe(1)
expect(lost.rows[0]).toMatchObject({
clientName: 'Quote-less Lost Co', stage: 'lost', nextAction: 'none', docId: null,
})
})
it('QT→PI conversion moves the client out of Quoted/Waiting — no stuck chase row', () => {
const { db, moduleId } = setup()
const c = createClient(db, 'u1', { name: 'Converted Co', stateCode: '32' })
const q = quote(db, c.id, moduleId, { sentAgeDays: 10 })
convertDocument(db, 'u1', q.id, 'PROFORMA')
const rows = listPipeline(db, { ...OWNER_VIEW }).rows.filter((r) => r.clientId === c.id)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ stage: 'won', nextAction: 'none' }) // moved forward, no chase buttons
})
})
describe('listPipeline — next action + band from resolveSchedule (no hardcoded 3/7/14)', () => {
it('default offsets 3/7/14: waiting<3, chase>=3, nudge>=7, final nudge>=14; bands green/amber/red', () => {
const { db, moduleId } = setup()
const mk = (name: string, age: number) => {
const c = createClient(db, 'u1', { name, stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: age })
}
mk('Age2', 2); mk('Age5', 5); mk('Age8', 8); mk('Age15', 15)
const out = listPipeline(db, { ...OWNER_VIEW })
expect(out.dayOffsets).toEqual([3, 7, 14])
const by = new Map(out.rows.map((r) => [r.clientName, r]))
expect(by.get('Age2')).toMatchObject({ nextAction: 'waiting', band: 'green', ageDays: 2 })
expect(by.get('Age5')).toMatchObject({ nextAction: 'chase', band: 'amber', ageDays: 5 })
expect(by.get('Age8')).toMatchObject({ nextAction: 'nudge', band: 'amber', ageDays: 8 })
expect(by.get('Age15')).toMatchObject({ nextAction: 'final_nudge', band: 'red', ageDays: 15 })
})
it('a dated reminder_schedule row changes the bands without a code edit', () => {
const { db, moduleId } = setup()
db.prepare(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES ('rs1', 'quote_followup', '2026-01-01', NULL, '2,4,6', NULL, NULL)`,
).run()
const c = createClient(db, 'u1', { name: 'Fast Lane', stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: 5 })
const out = listPipeline(db, { ...OWNER_VIEW })
expect(out.dayOffsets).toEqual([2, 4, 6])
// age 5 crossed 2 and 4 but not 6 → nudge, amber (would be 'chase' under 3/7/14)
expect(out.rows[0]).toMatchObject({ nextAction: 'nudge', band: 'amber' })
})
})
describe('listPipeline — role gate (staff server-forced to own rows)', () => {
function team(db: DB) {
const a = createStaff(db, { email: 'a@x.co', displayName: 'Asha', role: 'staff', password: 'password2' })
const b = createStaff(db, { email: 'b@x.co', displayName: 'Priya', role: 'staff', password: 'password2' })
return { a: a.id, b: b.id }
}
it('staff see only quotes they created and leads they own; widening params are ignored', () => {
const { db, moduleId } = setup()
const { a, b } = team(db)
const c1 = createClient(db, 'u1', { name: 'Asha Quote Co', stateCode: '32' })
quote(db, c1.id, moduleId, { by: a, sentAgeDays: 4 })
const c2 = createClient(db, 'u1', { name: 'Priya Quote Co', stateCode: '32' })
quote(db, c2.id, moduleId, { by: b, sentAgeDays: 4 })
const leadA = createClient(db, 'u1', { name: 'Asha Lead', stateCode: '32', status: 'lead' })
db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(a, leadA.id)
createClient(db, 'u1', { name: 'Unassigned Lead', stateCode: '32', status: 'lead' })
const mine = listPipeline(db, { viewerRole: 'staff', viewerId: a, today: TODAY })
expect(mine.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co'])
// a widening owner param cannot escape the self scope
const widened = listPipeline(db, { viewerRole: 'staff', viewerId: a, ownerId: b, today: TODAY })
expect(widened.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co'])
// owner/manager see everything, including the unassigned lead (F13 safety net)
const boss = listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', today: TODAY })
expect(boss.total).toBe(4)
// ...and may narrow to one owner, or to themselves via filter=mine
const narrowed = listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', ownerId: b, today: TODAY })
expect(narrowed.rows.map((r) => r.clientName)).toEqual(['Priya Quote Co'])
const bossMine = listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', filter: 'mine', today: TODAY })
expect(bossMine.total).toBe(0)
})
})
describe('listPipeline — sort, overdue filter, pagination', () => {
it('sorts actionable oldest first: overdue by age desc, then enquiry/draft, then won', () => {
const { db, moduleId } = setup()
const mkSent = (name: string, age: number) => {
const c = createClient(db, 'u1', { name, stateCode: '32' })
return quote(db, c.id, moduleId, { sentAgeDays: age })
}
mkSent('Mid 5d', 5)
mkSent('Oldest 15d', 15)
mkSent('Waiting 2d', 2)
createClient(db, 'u1', { name: 'A Lead', stateCode: '32', status: 'lead' })
const won = createClient(db, 'u1', { name: 'Won Co', stateCode: '32' })
markStatus(db, 'u1', quote(db, won.id, moduleId, { sentAgeDays: 30 }).id, 'accepted')
const names = listPipeline(db, { ...OWNER_VIEW }).rows.map((r) => r.clientName)
expect(names).toEqual(['Oldest 15d', 'Mid 5d', 'Waiting 2d', 'A Lead', 'Won Co'])
})
it('filter=overdue keeps only sent quotes past the first offset', () => {
const { db, moduleId } = setup()
const mkSent = (name: string, age: number) => {
const c = createClient(db, 'u1', { name, stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: age })
}
mkSent('Fresh 1d', 1)
mkSent('Over 9d', 9)
createClient(db, 'u1', { name: 'Some Lead', stateCode: '32', status: 'lead' })
const out = listPipeline(db, { ...OWNER_VIEW, filter: 'overdue' })
expect(out.rows.map((r) => r.clientName)).toEqual(['Over 9d'])
expect(out.total).toBe(1)
})
it('paginates with LIMIT/OFFSET semantics and an honest total', () => {
const { db, moduleId } = setup()
for (let i = 0; i < 5; i += 1) {
const c = createClient(db, 'u1', { name: `Client ${i}`, stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: 10 + i })
}
const p1 = listPipeline(db, { ...OWNER_VIEW, page: 1, pageSize: 2 })
expect(p1.total).toBe(5)
expect(p1.rows).toHaveLength(2)
expect(p1.rows[0]!.ageDays).toBe(14) // oldest first
const p3 = listPipeline(db, { ...OWNER_VIEW, page: 3, pageSize: 2 })
expect(p3.rows).toHaveLength(1)
expect(p3.page).toBe(3)
expect(p3.pageSize).toBe(2)
// no overlap, nothing dropped
const all = [
...p1.rows,
...listPipeline(db, { ...OWNER_VIEW, page: 2, pageSize: 2 }).rows,
...p3.rows,
]
expect(new Set(all.map((r) => r.clientId)).size).toBe(5)
})
})
describe('GET /pipeline', () => {
const { db, moduleId } = setup()
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const staffDbId = (db.prepare(`SELECT id FROM staff_user WHERE email='staff@test.in'`).get() as { id: string }).id
const mineClient = createClient(db, 'seed', { name: 'Staff Own Co', stateCode: '32' })
quote(db, mineClient.id, moduleId, { by: staffDbId, sentAgeDays: 4 })
const otherClient = createClient(db, 'seed', { name: 'Someone Else Co', stateCode: '32' })
quote(db, otherClient.id, moduleId, { sentAgeDays: 4 })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
afterAll(() => server.close())
const tokenOf = async (email: string, password: string) =>
(await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json() as { token: string }).token
const get = async (token: string, qs: string) => {
const res = await fetch(`${base}/pipeline${qs}`, { headers: { authorization: `Bearer ${token}` } })
return { status: res.status, json: await res.json() as any }
}
it('returns the derived rows with total/page/pageSize; staff are forced to their own rows', async () => {
const owner = await tokenOf('owner@test.in', 'owner-password')
const all = await get(owner, '')
expect(all.status).toBe(200)
expect(all.json.total).toBe(2)
expect(all.json.page).toBe(1)
expect(all.json.rows).toHaveLength(2)
const staff = await tokenOf('staff@test.in', 'staff-password')
const own = await get(staff, '')
expect(own.json.total).toBe(1)
expect(own.json.rows[0].clientName).toBe('Staff Own Co')
// the widening ?owner= param is ignored server-side for staff
const widened = await get(staff, `?owner=someone-else`)
expect(widened.json.total).toBe(1)
expect(widened.json.rows[0].clientName).toBe('Staff Own Co')
})
it('rejects a bad filter; honours page/pageSize', async () => {
const owner = await tokenOf('owner@test.in', 'owner-password')
expect((await get(owner, '?filter=bogus')).status).toBe(400)
const paged = await get(owner, '?page=2&pageSize=1')
expect(paged.json.total).toBe(2)
expect(paged.json.rows).toHaveLength(1)
expect(paged.json.pageSize).toBe(1)
})
})