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/module-roster.test.ts

206 lines
11 KiB
TypeScript

// 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')
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
const m = createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
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', () => {
const { db, m, mkClient } = world()
const a = mkClient('Acme Bank'); const z = mkClient('Zeta CCS'); const b = mkClient('Beta Coop')
const cmA = assignModule(db, 'u1', { clientId: a.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cmA.id, { nextRenewal: '2026-09-01' })
assignModule(db, 'u1', { clientId: z.id, moduleId: m.id, kind: 'yearly' })
assignModule(db, 'u1', { clientId: b.id, moduleId: m.id, kind: 'yearly' })
const page = 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', () => {
const { db, m, mkClient } = world()
for (let i = 0; i < 5; i++) {
assignModule(db, 'u1', { clientId: mkClient(`Bank ${i}`).id, moduleId: m.id, kind: 'yearly' })
}
const dead = assignModule(db, 'u1', { clientId: mkClient('Gone Bank').id, moduleId: m.id, kind: 'yearly' })
db.prepare(`UPDATE client_module SET active=0 WHERE id=?`).run(dead.id)
const p1 = listClientsByModule(db, m.id, { page: 1, pageSize: 2 })
const p3 = 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', () => {
const { db, m } = world()
const page = 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)', () => {
const { db, m, mkClient } = world()
const c = mkClient('Acme Bank')
assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const row = listClientsByModule(db, m.id).clients[0]!
expect(row.cmId).toBeTruthy()
// Same audited write path Client 360 uses — kind validated against allowedKinds.
updateClientModule(db, 'u1', row.cmId, { kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' })
const after = 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.
updateClientModule(db, 'u1', row.cmId, { active: false })
expect(listClientsByModule(db, m.id).total).toBe(0)
})
it('roster kind edit rejects a kind the module does not allow', () => {
const { db, mkClient } = world()
const restricted = createModule(db, 'u1', { code: 'AMC2', name: 'AMC Only', allowedKinds: ['yearly'] })
const c = mkClient('Beta Coop')
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: restricted.id, kind: 'yearly' })
expect(() => updateClientModule(db, 'u1', cm.id, { kind: 'usage' })).toThrow(/does not allow/)
expect(() => updateClientModule(db, 'u1', cm.id, { edition: ' ' })).toThrow(/edition/i)
})
})
// ---------- routes ----------
function appWith(fetchImpl: typeof fetch) {
const { db, m, mkClient } = world()
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const withMail = mkClient('Acme Bank', 'ravi@acme.in')
const noMail = mkClient('Beta Coop') // no contact email — must be reported, not skipped silently
assignModule(db, 'u1', { clientId: withMail.id, moduleId: m.id, kind: 'yearly' })
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 = 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 = 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 = 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 = 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 = ctx.db.prepare(`SELECT to_addr, status, subject FROM email_log ORDER BY id DESC LIMIT 1`)
.get() 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 = 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 = 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)
ctx.db.prepare(`DELETE FROM email_account`).run()
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(ctx.db.prepare(`SELECT COUNT(*) AS n FROM email_log`).get()).toMatchObject({ n: 0 })
})
})