// apps/hq/test/reminders-page.test.ts — go-live cluster WS-C: the Reminders page contract. // The page's four status chips map straight onto GET /reminders?status=; the managerial // Mine/All toggle onto ?owner=. Queued/failed were already covered (quote-followup.test.ts); // this pins the sent/dismissed chips, ?owner= at HTTP level, and staff scoping across chips. import express from 'express' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { openDb, 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 { createDraft, markStatus, type Doc } from '../src/repos-documents' import { saveAccount } from '../src/repos-email' import { encrypt } from '../src/crypto' import { dismissReminder, listReminders, setSetting, upsertReminder } from '../src/repos-reminders' import { sendReminder, type SendReminderDeps } from '../src/send-reminder' 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') 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 { 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 } describe('GET /reminders — Reminders page contract (status chips, Mine/All, staff scope)', () => { 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> 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('every chip maps to ?status= and Mine (?owner=) composes with it; staff scope holds on every chip', async () => { const { db, c, m, staff } = ctx const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) const mine = await sentQuote(db, c, m, '2026-07-01T09:00:00Z', staff.id) // → queued nudge owned by staff const theirs = await sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'someone-else') // → queued nudge owned by someone else await runDailyScan(db, deps, '2026-07-04') // Flip someone-else's nudge to sent; queue a doc-less shared renewal and dismiss it. const nudges = await listReminders(db, { ruleKind: 'quote_followup' }) const theirsRem = nudges.find((r) => r.docId === theirs.id)! expect((await sendReminder(db, deps, theirsRem.id, 'u1')).ok).toBe(true) const renewal = await upsertReminder(db, { ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01', clientId: c.id, now: '2026-07-04T00:00:00Z', }) await dismissReminder(db, 'u1', renewal.id) const ownerTok = await tokenOf('owner@test.in', 'owner-password') // Each chip narrows to exactly its status. expect((await get(ownerTok, '?status=queued')).json.total).toBe(1) const sent = await get(ownerTok, '?status=sent') expect(sent.json.total).toBe(1) expect(sent.json.reminders[0].id).toBe(theirsRem.id) expect(sent.json.reminders[0].status).toBe('sent') const dismissed = await get(ownerTok, '?status=dismissed') expect(dismissed.json.total).toBe(1) expect(dismissed.json.reminders[0].status).toBe('dismissed') expect((await get(ownerTok, '?status=failed')).json.total).toBe(0) // Mine (?owner=) composes with the chip: staff's queued nudge yes, someone-else's sent one no. expect((await get(ownerTok, `?status=queued&owner=${staff.id}`)).json.total).toBe(1) expect((await get(ownerTok, `?status=sent&owner=${staff.id}`)).json.total).toBe(0) // Staff scope holds on EVERY chip: not their sent row; doc-less dismissed row is shared work. const staffTok = await tokenOf('staff@test.in', 'staff-password') const staffQueued = await get(staffTok, '?status=queued') expect(staffQueued.json.total).toBe(1) expect(staffQueued.json.reminders[0].docId).toBe(mine.id) expect((await get(staffTok, '?status=sent')).json.total).toBe(0) expect((await get(staffTok, '?status=dismissed')).json.total).toBe(1) }) })