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

295 lines
15 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// apps/hq/test/quote-followup.test.ts — Phase 6: escalating quote follow-up (spec §7)
import { describe, it, expect } from 'vitest'
import { openDb, rebuildReminderRuleKindCheck, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient, type Client } from '../src/repos-clients'
import { createModule, setPrice, type Module } from '../src/repos-modules'
import { convertDocument, createDraft, issueDocument, markStatus, type Doc } from '../src/repos-documents'
import { saveAccount } from '../src/repos-email'
import { encrypt } from '../src/crypto'
import {
getReminder, listQueue, listReminders, setSetting, upsertReminder,
} from '../src/repos-reminders'
import { reminderContext, sendReminder, type SendReminderDeps } from '../src/send-reminder'
import { reminderEmail } from '../src/reminder-templates'
import { runDailyScan, type ScanDeps } from '../src/scheduler'
const KEY = '11'.repeat(32)
const okFetch = (async (url: string) =>
new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch
const deps: ScanDeps & SendReminderDeps = {
gmail: { f: okFetch, clientId: 'cid', clientSecret: 'sec', keyHex: KEY },
renderPdf: async () => Buffer.from('%PDF-fake'),
company: () => ({ 'company.name': 'Tecnostac' }),
now: () => '2026-07-10T09:00:00Z',
}
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m }
}
/** A QUOTATION marked sent, with the first-sent event pinned to `sentAt` for age math. */
function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Doc {
const q = createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
markStatus(db, by, q.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`).run(sentAt, q.id)
return q
}
const followups = (db: DB) => listReminders(db, { ruleKind: 'quote_followup' })
const shareCount = (db: DB): number =>
(db.prepare(`SELECT COUNT(*) AS n FROM document_share`).get() as { n: number }).n
// ---------- schema migration (rebuild-once via the shared helper) ----------
const OLD_REMINDER_DDL = `
CREATE TABLE reminder (
id TEXT PRIMARY KEY,
rule_kind TEXT NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced')),
subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL,
doc_id TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
error TEXT, created_at TEXT NOT NULL, sent_at TEXT,
UNIQUE (rule_kind, subject_id, due_period)
)`
describe('reminder.rule_kind CHECK widening', () => {
it('a fresh DB accepts quote_followup directly', () => {
const db = openDb(':memory:')
const up = upsertReminder(db, { ruleKind: 'quote_followup', subjectId: 'q1', duePeriod: 'd3', clientId: 'c1', docId: 'q1', now: '2026-07-10T00:00:00Z' })
expect(up.created).toBe(true)
})
it('rebuilds an old-CHECK table preserving rows, UNIQUE key and status CHECK; idempotent', () => {
const db = openDb(':memory:')
db.exec(`DROP TABLE reminder`)
db.exec(OLD_REMINDER_DDL)
db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-old','invoice_overdue','inv1','2026-07','c1','queued','manual','2026-07-01T00:00:00Z')`,
).run()
// Old CHECK really rejects the new kind before the rebuild.
expect(() => db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-new','quote_followup','q1','d3','c1','queued','manual','2026-07-01T00:00:00Z')`,
).run()).toThrow()
rebuildReminderRuleKindCheck(db)
// Old row copied across, new kind now allowed.
expect(db.prepare(`SELECT rule_kind FROM reminder WHERE id='r-old'`).get()).toMatchObject({ rule_kind: 'invoice_overdue' })
db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-new','quote_followup','q1','d3','c1','queued','manual','2026-07-01T00:00:00Z')`,
).run()
// Idempotency key preserved verbatim.
expect(() => db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-dup','quote_followup','q1','d3','c1','queued','manual','2026-07-01T00:00:00Z')`,
).run()).toThrow()
// status CHECK preserved verbatim.
expect(() => db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-bad','quote_followup','q2','d3','c1','bogus','manual','2026-07-01T00:00:00Z')`,
).run()).toThrow()
// Re-run is a no-op (rows intact).
rebuildReminderRuleKindCheck(db)
expect((db.prepare(`SELECT COUNT(*) AS n FROM reminder`).get() as { n: number }).n).toBe(2)
})
})
// ---------- the daily scan ----------
describe('runDailyScan — quote_followup escalation', () => {
it('fires each interval at most once per quote, day by day', async () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
expect((await runDailyScan(db, deps, '2026-07-02')).created['quote_followup'] ?? 0).toBe(0) // age 1 < 3
expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup']).toBe(1) // d3
expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup'] ?? 0).toBe(0) // same day re-run
expect((await runDailyScan(db, deps, '2026-07-08')).created['quote_followup']).toBe(1) // d7
expect((await runDailyScan(db, deps, '2026-07-15')).created['quote_followup']).toBe(1) // d14
expect((await runDailyScan(db, deps, '2026-07-20')).created['quote_followup'] ?? 0).toBe(0) // ladder consumed
const rows = followups(db)
expect(rows.map((r) => r.duePeriod).sort()).toEqual(['d14', 'd3', 'd7'])
expect(rows.every((r) => r.subjectId === q.id && r.docId === q.id && r.policyApplied === 'manual')).toBe(true)
})
it('catch-up fires ONLY the single highest crossed milestone', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-06-01T09:00:00Z') // age 39 on first scan — past 3, 7 and 14
const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['quote_followup']).toBe(1)
const rows = followups(db)
expect(rows).toHaveLength(1)
expect(rows[0]!.duePeriod).toBe('d14')
})
it('ignores quotes that are not sent (draft / accepted / lost / cancelled leave the set)', async () => {
const { db, c, m } = world()
createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const q = sentQuote(db, c, m, '2026-06-01T09:00:00Z')
markStatus(db, 'u1', q.id, 'accepted')
const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['quote_followup'] ?? 0).toBe(0)
})
it('auto policy drains sends after the scan, at-most-once, minting one ~60-day share', async () => {
const { db, c, m } = world()
setSetting(db, 'u1', 'quote.followup.policy', 'auto')
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const res = await runDailyScan(db, deps, '2026-07-04') // d3
expect(res.created['quote_followup']).toBe(1)
expect(res.autoSent).toBe(1)
const row = followups(db)[0]!
expect(row.policyApplied).toBe('auto')
expect(row.status).toBe('sent')
expect(shareCount(db)).toBe(1)
const share = db.prepare(`SELECT expires_at FROM document_share`).get() as { expires_at: string | null }
expect(share.expires_at).not.toBeNull() // never-expiring links are refused (F12)
const days = (Date.parse(share.expires_at!) - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(59); expect(days).toBeLessThan(61)
// Next milestone reuses the live share instead of duplicating it.
const res2 = await runDailyScan(db, deps, '2026-07-08') // d7
expect(res2.autoSent).toBe(1)
expect(shareCount(db)).toBe(1)
// Re-run creates nothing and sends nothing (unique key → at-most-once).
const res3 = await runDailyScan(db, deps, '2026-07-08')
expect(res3.created['quote_followup'] ?? 0).toBe(0)
expect(res3.autoSent).toBe(0)
})
})
// ---------- preview / context (read-only) and send (mints) ----------
describe('quote_followup context, preview and send', () => {
it('preview writes nothing — no share minted, no audit rows', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]!
const auditBefore = (db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n
const { ctx } = reminderContext(db, rem, 'Tecnostac')
const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).toContain('Tecnostac')
expect(shareCount(db)).toBe(0) // resolve-only: preview NEVER mints
expect((db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n).toBe(auditBefore)
})
it('degrades gracefully when the quote has no docNo — never renders "quotation null"', async () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') // never issued: docNo is null (F10)
await runDailyScan(db, deps, '2026-07-04')
const { ctx } = reminderContext(db, followups(db)[0]!, 'Tecnostac')
const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).not.toContain('null')
expect(mail.bodyText).not.toContain('null')
expect(mail.subject).toContain(`dated ${q.docDate}`)
expect(mail.bodyText).toContain('Acme') // clientName substituted
})
it('uses the doc number when the quote is issued', async () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const issued = issueDocument(db, 'u1', q.id)
await runDailyScan(db, deps, '2026-07-04')
const { ctx } = reminderContext(db, followups(db)[0]!, 'Tecnostac')
expect(reminderEmail('quote_followup', ctx).subject).toContain(issued.docNo!)
})
it('manual send mints one ~60-day share and a second send reuses it', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') // d3, manual
const first = followups(db)[0]!
const out = await sendReminder(db, deps, first.id, 'u1')
expect(out).toEqual({ ok: true })
expect(shareCount(db)).toBe(1)
// The sent body carries a live share link.
const { ctx } = reminderContext(db, getReminder(db, first.id)!, 'Tecnostac')
expect(reminderEmail('quote_followup', ctx).bodyText).toContain('/share/')
await runDailyScan(db, deps, '2026-07-08') // d7
const second = followups(db).find((r) => r.duePeriod === 'd7')!
expect((await sendReminder(db, deps, second.id, 'u1')).ok).toBe(true)
expect(shareCount(db)).toBe(1) // reused, not duplicated
})
})
// ---------- STOP cleanup ----------
describe('quote_followup STOP cleanup', () => {
async function withOpenNudge(status?: 'accepted' | 'lost') {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]!
expect(rem.status).toBe('queued')
if (status !== undefined) markStatus(db, 'u1', q.id, status)
return { db, q, remId: rem.id }
}
it('markStatus accepted dismisses open nudges, each audited', async () => {
const { db, remId } = await withOpenNudge('accepted')
expect(getReminder(db, remId)!.status).toBe('dismissed')
const audit = db.prepare(
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder' AND entity_id=? AND action='update'`,
).get(remId) as { n: number }
expect(audit.n).toBe(1) // per-row setReminderStatus, not a silent bulk UPDATE
})
it('markStatus lost dismisses open nudges', async () => {
const { db, remId } = await withOpenNudge('lost')
expect(getReminder(db, remId)!.status).toBe('dismissed')
})
it('convertDocument dismisses the quotes open nudges in the same transaction', async () => {
const { db, q, remId } = await withOpenNudge()
convertDocument(db, 'u1', q.id, 'PROFORMA')
expect(getReminder(db, remId)!.status).toBe('dismissed')
})
it('leaves already-sent follow-ups untouched', async () => {
const { db, q, remId } = await withOpenNudge()
await sendReminder(db, deps, remId, 'u1')
markStatus(db, 'u1', q.id, 'accepted')
expect(getReminder(db, remId)!.status).toBe('sent') // history is history
})
})
// ---------- queue: labelling, owner filtering, pagination ----------
describe('listQueue — quote_followup labelling, owner scope, pagination', () => {
it('labels quote follow-ups and derives the owner from document.created_by', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
await runDailyScan(db, deps, '2026-07-04')
const page = listQueue(db)
expect(page.total).toBe(1)
expect(page.rows[0]!.label).toBe('Quote follow-up (d3)')
expect(page.rows[0]!.ownerId).toBe('staff-a')
expect(page.rows[0]!.clientName).toBe('Acme')
})
it('staff see only their own rows; managerial viewers see everything', async () => {
const { db, c, m } = world()
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'staff-b')
await runDailyScan(db, deps, '2026-07-04')
const a = listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' })
expect(a.total).toBe(1)
expect(a.rows[0]!.ownerId).toBe('staff-a')
const boss = listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' })
expect(boss.total).toBe(2)
// A staff request cannot widen its scope via ownerId.
const sneaky = listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a', ownerId: 'staff-b' })
expect(sneaky.total).toBe(1)
expect(sneaky.rows[0]!.ownerId).toBe('staff-a')
})
it('paginates with an honest total', async () => {
const { db, c, m } = world()
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
sentQuote(db, c2, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const p1 = listQueue(db, { page: 1, pageSize: 1 })
const p2 = listQueue(db, { page: 2, pageSize: 1 })
expect(p1.total).toBe(2); expect(p2.total).toBe(2)
expect(p1.rows).toHaveLength(1); expect(p2.rows).toHaveLength(1)
expect(p1.rows[0]!.id).not.toBe(p2.rows[0]!.id)
})
})