|
|
// apps/hq/test/quote-followup.test.ts — Phase 6: escalating quote follow-up (spec §7)
|
|
|
import express from 'express'
|
|
|
import Database from 'better-sqlite3'
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
|
import { openDb, rebuildReminderRuleKindCheck, type DB } from '../src/db'
|
|
|
import { seedIfEmpty } from '../src/seed'
|
|
|
import { createStaff } from '../src/auth'
|
|
|
import { apiRouter } from '../src/api'
|
|
|
import { createClient, type Client } from '../src/repos-clients'
|
|
|
import { createModule, setPrice, type Module } from '../src/repos-modules'
|
|
|
import {
|
|
|
cancelDocument, convertDocument, createDraft, getDocument, issueDocument, markStatus, type Doc,
|
|
|
} from '../src/repos-documents'
|
|
|
import { saveAccount } from '../src/repos-email'
|
|
|
import { encrypt } from '../src/crypto'
|
|
|
import {
|
|
|
getReminder, listQueue, listReminders, queueCounts, 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': 'SiMS' }),
|
|
|
now: () => '2026-07-10T09:00:00Z',
|
|
|
}
|
|
|
|
|
|
async function world() {
|
|
|
const db = openDb(':memory:'); await seedIfEmpty(db)
|
|
|
await saveAccount(db, 'us@sims.com', encrypt('refresh-token', KEY))
|
|
|
await setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in') // sends refuse a dead relative link without it
|
|
|
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
|
|
|
const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
|
|
|
await 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. */
|
|
|
async function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Promise<Doc> {
|
|
|
const q = await createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
|
await markStatus(db, by, q.id, 'sent')
|
|
|
await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`, sentAt, q.id)
|
|
|
return q
|
|
|
}
|
|
|
|
|
|
const followups = (db: DB) => listReminders(db, { ruleKind: 'quote_followup' })
|
|
|
const shareCount = async (db: DB): Promise<number> =>
|
|
|
((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM document_share`))!).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', async () => {
|
|
|
const db = openDb(':memory:')
|
|
|
const up = await 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', () => {
|
|
|
// Raw better-sqlite3 handle: the rebuild helper is a SQLite-only migration that
|
|
|
// operates below the async DB interface (it takes SqliteRaw).
|
|
|
const db = new Database(':memory:')
|
|
|
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 } = await world()
|
|
|
const q = await 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 = await 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 } = await world()
|
|
|
await 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 = await 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 } = await world()
|
|
|
await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
|
const q = await sentQuote(db, c, m, '2026-06-01T09:00:00Z')
|
|
|
await markStatus(db, 'u1', q.id, 'accepted')
|
|
|
const res = await runDailyScan(db, deps, '2026-07-10')
|
|
|
expect(res.created['quote_followup'] ?? 0).toBe(0)
|
|
|
})
|
|
|
it('a converted quote is NEVER chased again — the next scan creates nothing', async () => {
|
|
|
const { db, c, m } = await world()
|
|
|
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
await runDailyScan(db, deps, '2026-07-04') // d3 queued
|
|
|
await convertDocument(db, 'u1', q.id, 'PROFORMA')
|
|
|
expect((await getDocument(db, q.id))!.status).toBe('invoiced') // left the 'sent' set for good
|
|
|
const res = await runDailyScan(db, deps, '2026-07-09') // would be d7
|
|
|
expect(res.created['quote_followup'] ?? 0).toBe(0)
|
|
|
expect((await followups(db)).every((r) => r.status === 'dismissed')).toBe(true)
|
|
|
})
|
|
|
it('a pre-fix converted quote (still status=sent) is excluded via its live forward child', async () => {
|
|
|
const { db, c, m } = await world()
|
|
|
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
await convertDocument(db, 'u1', q.id, 'PROFORMA')
|
|
|
await db.run(`UPDATE document SET status='sent' WHERE id=?`, q.id) // data converted before the status flip landed
|
|
|
const res = await runDailyScan(db, deps, '2026-07-09')
|
|
|
expect(res.created['quote_followup'] ?? 0).toBe(0)
|
|
|
})
|
|
|
it('a lost client is never chased, whatever their quote row says', async () => {
|
|
|
const { db, c, m } = await world()
|
|
|
await sentQuote(db, c, m, '2026-06-01T09:00:00Z')
|
|
|
await db.run(`UPDATE client SET status='lost' WHERE id=?`, c.id)
|
|
|
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 } = await world()
|
|
|
await setSetting(db, 'u1', 'quote.followup.policy', 'auto')
|
|
|
await 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 = (await followups(db))[0]!
|
|
|
expect(row.policyApplied).toBe('auto')
|
|
|
expect(row.status).toBe('sent')
|
|
|
expect(await shareCount(db)).toBe(1)
|
|
|
const share = (await db.get<{ expires_at: string | null }>(`SELECT expires_at FROM document_share`))!
|
|
|
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(await 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 } = await world()
|
|
|
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const rem = (await followups(db))[0]!
|
|
|
const auditBefore = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log`))!).n
|
|
|
const { ctx } = await reminderContext(db, rem, 'SiMS')
|
|
|
const mail = reminderEmail('quote_followup', ctx)
|
|
|
expect(mail.subject).toContain('SiMS')
|
|
|
expect(await shareCount(db)).toBe(0) // resolve-only: preview NEVER mints
|
|
|
expect(((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log`))!).n).toBe(auditBefore)
|
|
|
})
|
|
|
it('degrades gracefully when the quote has no docNo — never renders "quotation null"', async () => {
|
|
|
const { db, c, m } = await world()
|
|
|
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z') // never issued: docNo is null (F10)
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'SiMS')
|
|
|
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 } = await world()
|
|
|
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
const issued = await issueDocument(db, 'u1', q.id)
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'SiMS')
|
|
|
expect(reminderEmail('quote_followup', ctx).subject).toContain(issued.docNo!)
|
|
|
})
|
|
|
it('send resolves the dated subject/body on the injected clock, not the wall clock', async () => {
|
|
|
const { db, c, m } = await world()
|
|
|
// A dated row that takes over on 2026-07-11: resolving on 07-10 must not see it.
|
|
|
await db.run(
|
|
|
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
|
|
|
VALUES ('rs-future','quote_followup','2026-07-11',NULL,'3,7,14','FUTURE {ref}','future body {shareUrl}')`,
|
|
|
)
|
|
|
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const rem = (await followups(db))[0]!
|
|
|
const before = await reminderContext(db, rem, 'SiMS', '2026-07-10')
|
|
|
expect(reminderEmail('quote_followup', before.ctx).subject).not.toContain('FUTURE')
|
|
|
const after = await reminderContext(db, rem, 'SiMS', '2026-07-11')
|
|
|
expect(reminderEmail('quote_followup', after.ctx).subject).toContain('FUTURE')
|
|
|
})
|
|
|
it('refuses to send when share.base_url is unset — loud error, NO share minted, row still queued', async () => {
|
|
|
const { db, c, m } = await world()
|
|
|
await db.run(`DELETE FROM setting WHERE key='share.base_url'`)
|
|
|
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const rem = (await followups(db))[0]!
|
|
|
await expect(sendReminder(db, deps, rem.id, 'u1')).rejects.toThrow(/share\.base_url/)
|
|
|
expect(await shareCount(db)).toBe(0) // nothing public left behind by the refused send
|
|
|
expect((await getReminder(db, rem.id))!.status).toBe('queued')
|
|
|
})
|
|
|
it('auto mode parks a hard-failing send as failed instead of crashing the scan', async () => {
|
|
|
const { db, c, m } = await world()
|
|
|
await db.run(`DELETE FROM setting WHERE key='share.base_url'`)
|
|
|
await setSetting(db, 'u1', 'quote.followup.policy', 'auto')
|
|
|
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
const res = await runDailyScan(db, deps, '2026-07-04')
|
|
|
expect(res.created['quote_followup']).toBe(1)
|
|
|
expect(res.autoFailed).toBe(1)
|
|
|
const row = (await followups(db))[0]!
|
|
|
expect(row.status).toBe('failed')
|
|
|
expect(row.error).toContain('share.base_url')
|
|
|
expect(await shareCount(db)).toBe(0)
|
|
|
})
|
|
|
it('resolves the recipient BEFORE minting — a client without email leaves no share behind', async () => {
|
|
|
const { db, m } = await world()
|
|
|
const bare = await createClient(db, 'u1', { name: 'NoMail Co', stateCode: '32' })
|
|
|
await sentQuote(db, bare, m, '2026-07-01T09:00:00Z')
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const rem = (await followups(db)).find((r) => r.clientId === bare.id)!
|
|
|
await expect(sendReminder(db, deps, rem.id, 'u1')).rejects.toThrow(/recipient/i)
|
|
|
expect(await shareCount(db)).toBe(0)
|
|
|
})
|
|
|
it('manual send mints one ~60-day share and a second send reuses it', async () => {
|
|
|
const { db, c, m } = await world()
|
|
|
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
await runDailyScan(db, deps, '2026-07-04') // d3, manual
|
|
|
const first = (await followups(db))[0]!
|
|
|
const out = await sendReminder(db, deps, first.id, 'u1')
|
|
|
expect(out).toEqual({ ok: true })
|
|
|
expect(await shareCount(db)).toBe(1)
|
|
|
// The sent body carries a live share link.
|
|
|
const { ctx } = await reminderContext(db, (await getReminder(db, first.id))!, 'SiMS')
|
|
|
expect(reminderEmail('quote_followup', ctx).bodyText).toContain('/share/')
|
|
|
await runDailyScan(db, deps, '2026-07-08') // d7
|
|
|
const second = (await followups(db)).find((r) => r.duePeriod === 'd7')!
|
|
|
expect((await sendReminder(db, deps, second.id, 'u1')).ok).toBe(true)
|
|
|
expect(await shareCount(db)).toBe(1) // reused, not duplicated
|
|
|
})
|
|
|
})
|
|
|
|
|
|
// ---------- STOP cleanup ----------
|
|
|
|
|
|
describe('quote_followup STOP cleanup', () => {
|
|
|
async function withOpenNudge(status?: 'accepted' | 'lost') {
|
|
|
const { db, c, m } = await world()
|
|
|
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const rem = (await followups(db))[0]!
|
|
|
expect(rem.status).toBe('queued')
|
|
|
if (status !== undefined) await 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((await getReminder(db, remId))!.status).toBe('dismissed')
|
|
|
const audit = (await db.get<{ n: number }>(
|
|
|
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder' AND entity_id=? AND action='update'`,
|
|
|
remId,
|
|
|
))!
|
|
|
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((await getReminder(db, remId))!.status).toBe('dismissed')
|
|
|
})
|
|
|
it('convertDocument dismisses the quote’s open nudges in the same transaction', async () => {
|
|
|
const { db, q, remId } = await withOpenNudge()
|
|
|
await convertDocument(db, 'u1', q.id, 'PROFORMA')
|
|
|
expect((await getReminder(db, remId))!.status).toBe('dismissed')
|
|
|
})
|
|
|
it('cancelDocument dismisses the quotation’s open nudges — dead paper is not chased', async () => {
|
|
|
const { db, q, remId } = await withOpenNudge()
|
|
|
await issueDocument(db, 'u1', q.id) // only issued documents can cancel
|
|
|
await cancelDocument(db, 'u1', q.id)
|
|
|
expect((await 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')
|
|
|
await markStatus(db, 'u1', q.id, 'accepted')
|
|
|
expect((await 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 } = await world()
|
|
|
await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const page = await 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 } = await world()
|
|
|
const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
|
|
|
await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
|
|
|
await sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'staff-b')
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const a = await listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' })
|
|
|
expect(a.total).toBe(1)
|
|
|
expect(a.rows[0]!.ownerId).toBe('staff-a')
|
|
|
const boss = await listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' })
|
|
|
expect(boss.total).toBe(2)
|
|
|
// A staff request cannot widen its scope via ownerId.
|
|
|
const sneaky = await 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 } = await world()
|
|
|
const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
|
|
|
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
|
|
|
await sentQuote(db, c2, m, '2026-07-01T09:00:00Z')
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
const p1 = await listQueue(db, { page: 1, pageSize: 1 })
|
|
|
const p2 = await 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)
|
|
|
})
|
|
|
it('doc-less reminders (no derived owner) stay visible to staff viewers and their counts', async () => {
|
|
|
const { db, c, m } = await world()
|
|
|
await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-b') // someone else's quote nudge
|
|
|
await runDailyScan(db, deps, '2026-07-04')
|
|
|
await upsertReminder(db, {
|
|
|
ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01',
|
|
|
clientId: c.id, now: '2026-07-04T00:00:00Z',
|
|
|
})
|
|
|
const a = await listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' })
|
|
|
expect(a.total).toBe(1) // the shared renewal — NOT staff-b's quote nudge
|
|
|
expect(a.rows[0]!.ruleKind).toBe('renewal_due')
|
|
|
expect(await queueCounts(db, 'staff-a')).toEqual({ queued: 1, failed: 0 })
|
|
|
const boss = await listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' })
|
|
|
expect(boss.total).toBe(2)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
// ---------- GET /reminders — ?status= narrows the SAME scoped, paginated view ----------
|
|
|
|
|
|
describe('GET /reminders?status=', () => {
|
|
|
async function setup() {
|
|
|
const { db, c, m } = await world()
|
|
|
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
|
|
const staff = await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
|
|
|
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`
|
|
|
return { db, c, m, staff, server, base }
|
|
|
}
|
|
|
let ctx: Awaited<ReturnType<typeof setup>>
|
|
|
beforeAll(async () => { ctx = await setup() })
|
|
|
afterAll(() => ctx.server.close())
|
|
|
|
|
|
const tokenOf = async (email: string, password: string) =>
|
|
|
(await (await fetch(`${ctx.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(`${ctx.base}/reminders${qs}`, { headers: { authorization: `Bearer ${token}` } })
|
|
|
return { status: res.status, json: await res.json() as any }
|
|
|
}
|
|
|
|
|
|
it('is owner-scoped and paginated for staff — no unscoped flat list one query-param away', async () => {
|
|
|
const c2 = await createClient(ctx.db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
|
|
|
const mine = await sentQuote(ctx.db, ctx.c, ctx.m, '2026-07-01T09:00:00Z', ctx.staff.id)
|
|
|
await sentQuote(ctx.db, c2, ctx.m, '2026-07-01T09:00:00Z', 'someone-else')
|
|
|
await runDailyScan(ctx.db, deps, '2026-07-04')
|
|
|
const staffTok = await tokenOf('staff@test.in', 'staff-password')
|
|
|
const own = await get(staffTok, '?status=queued')
|
|
|
expect(own.status).toBe(200)
|
|
|
expect(own.json.total).toBe(1) // honest scoped total, usable for the badge
|
|
|
expect(own.json.reminders).toHaveLength(1)
|
|
|
expect(own.json.reminders[0].docId).toBe(mine.id)
|
|
|
const ownerTok = await tokenOf('owner@test.in', 'owner-password')
|
|
|
const all = await get(ownerTok, '?status=queued')
|
|
|
expect(all.json.total).toBe(2)
|
|
|
const paged = await get(ownerTok, '?status=queued&pageSize=1&page=2')
|
|
|
expect(paged.json.reminders).toHaveLength(1)
|
|
|
expect(paged.json.total).toBe(2)
|
|
|
expect((await get(ownerTok, '?status=failed')).json.total).toBe(0)
|
|
|
})
|
|
|
})
|