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

444 lines
24 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 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': 'Tecnostac' }),
now: () => '2026-07-10T09:00:00Z',
}
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.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, 'Tecnostac')
const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).toContain('Tecnostac')
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]!, '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 } = 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]!, 'Tecnostac')
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, 'Tecnostac', '2026-07-10')
expect(reminderEmail('quote_followup', before.ctx).subject).not.toContain('FUTURE')
const after = await reminderContext(db, rem, 'Tecnostac', '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))!, 'Tecnostac')
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 quotes 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 quotations 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)
})
})