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.
206 lines
11 KiB
TypeScript
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')
|
|
|
|
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@sims.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 })
|
|
})
|
|
})
|