// apps/hq/test/module-roster.test.ts — Phase 10: module → client roster + notify + CSV (spec §10) import express from 'express' import { describe, it, expect, afterAll } from 'vitest' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createStaff } from '../src/auth' import { createClient } from '../src/repos-clients' import { assignModule, createModule, listClientsByModule, setPrice, updateClientModule, } from '../src/repos-modules' import { saveAccount } from '../src/repos-email' import { encrypt } from '../src/crypto' import { listAudit } from '../src/audit' import { apiRouter } from '../src/api' const KEY = '11'.repeat(32) const fakePdf = async () => Buffer.from('%PDF-fake') async function world() { const db = openDb(':memory:'); await seedIfEmpty(db) const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 9_000_00, effectiveFrom: '2026-01-01' }) const mkClient = (name: string, email?: string) => createClient(db, 'u1', { name, stateCode: '32', contacts: email !== undefined ? [{ name: 'C', email }] : [], }) return { db, m, mkClient } } describe('listClientsByModule', () => { it('lists active links with price + renewal, ordered by client name, with a true total', async () => { const { db, m, mkClient } = await world() const a = await mkClient('Acme Bank'); const z = await mkClient('Zeta CCS'); const b = await mkClient('Beta Coop') const cmA = await assignModule(db, 'u1', { clientId: a.id, moduleId: m.id, kind: 'yearly' }) await updateClientModule(db, 'u1', cmA.id, { nextRenewal: '2026-09-01' }) await assignModule(db, 'u1', { clientId: z.id, moduleId: m.id, kind: 'yearly' }) await assignModule(db, 'u1', { clientId: b.id, moduleId: m.id, kind: 'yearly' }) const page = await listClientsByModule(db, m.id, { onDate: '2026-07-17' }) expect(page.total).toBe(3) expect(page.clients.map((c) => c.clientName)).toEqual(['Acme Bank', 'Beta Coop', 'Zeta CCS']) expect(page.clients[0]).toMatchObject({ pricePaise: 9_000_00, nextRenewal: '2026-09-01' }) expect(page.totalPricePaise).toBe(27_000_00) // spans all links }) it('excludes inactive links (historical assignments) and paginates with a stable total', async () => { const { db, m, mkClient } = await world() for (let i = 0; i < 5; i++) { await assignModule(db, 'u1', { clientId: (await mkClient(`Bank ${i}`)).id, moduleId: m.id, kind: 'yearly' }) } const dead = await assignModule(db, 'u1', { clientId: (await mkClient('Gone Bank')).id, moduleId: m.id, kind: 'yearly' }) await db.run(`UPDATE client_module SET active=0 WHERE id=?`, dead.id) const p1 = await listClientsByModule(db, m.id, { page: 1, pageSize: 2 }) const p3 = await listClientsByModule(db, m.id, { page: 3, pageSize: 2 }) expect(p1.total).toBe(5) // Gone Bank excluded expect(p1.clients).toHaveLength(2) expect(p3.clients).toHaveLength(1) expect([...p1.clients, ...p3.clients].some((c) => c.clientName === 'Gone Bank')).toBe(false) }) it('a module nobody uses returns an honest empty page', async () => { const { db, m } = await world() const page = await listClientsByModule(db, m.id) expect(page).toMatchObject({ total: 0, clients: [], totalPricePaise: 0 }) }) it('rows carry cmId, and roster edits round-trip through updateClientModule (WS-G full-edit)', async () => { const { db, m, mkClient } = await world() const c = await mkClient('Acme Bank') await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) const row = (await listClientsByModule(db, m.id)).clients[0]! expect(row.cmId).toBeTruthy() // Same audited write path Client 360 uses — kind validated against allowedKinds. await updateClientModule(db, 'u1', row.cmId, { kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' }) const after = (await listClientsByModule(db, m.id)).clients[0]! expect(after).toMatchObject({ kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' }) // Unassign = deactivate: the row leaves the roster and the total shrinks. await updateClientModule(db, 'u1', row.cmId, { active: false }) expect((await listClientsByModule(db, m.id)).total).toBe(0) }) it('roster kind edit rejects a kind the module does not allow', async () => { const { db, mkClient } = await world() const restricted = await createModule(db, 'u1', { code: 'AMC2', name: 'AMC Only', allowedKinds: ['yearly'] }) const c = await mkClient('Beta Coop') const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: restricted.id, kind: 'yearly' }) await expect(updateClientModule(db, 'u1', cm.id, { kind: 'usage' })).rejects.toThrow(/does not allow/) await expect(updateClientModule(db, 'u1', cm.id, { edition: ' ' })).rejects.toThrow(/edition/i) }) }) // ---------- routes ---------- async function appWith(fetchImpl: typeof fetch) { const { db, m, mkClient } = await world() await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' }) await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) const withMail = await mkClient('Acme Bank', 'ravi@acme.in') const noMail = await mkClient('Beta Coop') // no contact email — must be reported, not skipped silently await assignModule(db, 'u1', { clientId: withMail.id, moduleId: m.id, kind: 'yearly' }) await assignModule(db, 'u1', { clientId: noMail.id, moduleId: m.id, kind: 'yearly' }) const app = express(); app.use(express.json()); app.locals['db'] = db app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf)) const server = app.listen(0) const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api` return { db, server, baseUrl, m } } const okFetch = (async (url: string) => new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch async function login(baseUrl: string, email: string, password: string) { return (await (await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), })).json() as { token: string }).token } describe('module roster routes', () => { const servers: { close: () => void }[] = [] afterAll(() => { for (const s of servers) s.close() }) it('GET /modules/:id/clients returns the paginated roster to any signed-in user', async () => { const ctx = await appWith(okFetch); servers.push(ctx.server) const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients`, { headers: { authorization: `Bearer ${token}` }, }) const json = await res.json() as { ok: boolean; total: number; clients: { clientName: string }[] } expect(res.status).toBe(200) expect(json.total).toBe(2) expect(json.clients.map((c) => c.clientName)).toEqual(['Acme Bank', 'Beta Coop']) }) it('GET /modules/:id/clients.csv exports every row as an attachment', async () => { const ctx = await appWith(okFetch); servers.push(ctx.server) const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients.csv`, { headers: { authorization: `Bearer ${token}` }, }) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('text/csv') expect(res.headers.get('content-disposition')).toContain('module-CORE-clients.csv') const body = await res.text() const rows = body.trim().split('\n') expect(rows[0]).toBe('client_code,client_name,status,kind,edition,price_paise,next_renewal') expect(rows).toHaveLength(3) // header + 2 clients expect(body).toContain('Acme Bank') expect(body).toContain('Beta Coop') }) it('POST /modules/:id/notify is owner/manager-only', async () => { const ctx = await appWith(okFetch); servers.push(ctx.server) const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ subject: 'Update', body: 'RTGS window changes tonight.' }), }) expect(res.status).toBe(403) }) it('notify sends to every reachable client, names the unreachable, and audits per recipient', async () => { const ctx = await appWith(okFetch); servers.push(ctx.server) const token = await login(ctx.baseUrl, 'owner@test.in', 'owner-password') const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ subject: 'Core Banking maintenance', body: 'Window: Sunday 02:00.' }), }) const json = await res.json() as { ok: boolean; sent: number; total: number; failed: { client: string; error: string }[] } expect(res.status).toBe(200) expect(json.sent).toBe(1) // Acme has a contact email expect(json.total).toBe(2) expect(json.failed).toEqual([{ client: 'Beta Coop', error: 'no contact email' }]) const log = await ctx.db.get(`SELECT to_addr, status, subject FROM email_log ORDER BY id DESC LIMIT 1`) as { to_addr: string; status: string; subject: string } expect(log).toMatchObject({ to_addr: 'ravi@acme.in', status: 'sent', subject: 'Core Banking maintenance' }) // One audit row per attempted recipient; the unreachable client is named in // the response (warn-not-truncate) but no send happened, so nothing to audit. const audits = (await listAudit(ctx.db)).filter((a) => a.action === 'notify') expect(audits).toHaveLength(1) expect(JSON.parse(audits[0]!.after_json ?? '{}')).toMatchObject({ status: 'sent', subject: 'Core Banking maintenance' }) }) it('notify validates subject/body and the gmail account before sending anything', async () => { const ctx = await appWith(okFetch); servers.push(ctx.server) const token = await login(ctx.baseUrl, 'owner@test.in', 'owner-password') const bad = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ subject: '', body: '' }), }) expect(bad.status).toBe(400) await ctx.db.run(`DELETE FROM email_account`) const disconnected = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ subject: 'S', body: 'B' }), }) expect(disconnected.status).toBe(409) expect(await ctx.db.get(`SELECT COUNT(*) AS n FROM email_log`)).toMatchObject({ n: 0 }) }) })