// apps/hq/test/tickets.test.ts — D20 P1/P2: ticket desk + branches + module service data. import express from 'express' import { describe, it, expect, afterAll } from 'vitest' import { openDb } from '../src/db' import { seedIfEmpty } from '../src/seed' import { apiRouter } from '../src/api' import { createClient } from '../src/repos-clients' import { createModule, assignModule, updateClientModule, setModulePassword, revealModulePassword, getClientModule } from '../src/repos-modules' import { createBranch, listBranches, updateBranch } from '../src/repos-branches' import { createTicket, updateTicket, listTickets, ticketCounts, ticketKinds } from '../src/repos-tickets' import { createStaff } from '../src/auth' const KEY = '11'.repeat(32) async function world() { const db = openDb(':memory:') await seedIfEmpty(db) const staff = await createStaff(db, { email: 't@t.in', displayName: 'Tibin', role: 'staff', password: 'password-1' }) const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) return { db, c, staffId: staff.id } } describe('client branches (D20)', () => { it('creates, lists (active first), edits and deactivates — audited', async () => { const { db, c } = await world() const b1 = await createBranch(db, 'u1', { clientId: c.id, name: 'HEAD OFFICE', code: '1' }) await createBranch(db, 'u1', { clientId: c.id, name: 'PALA BRANCH', code: '2' }) expect((await listBranches(db, c.id)).map((b) => b.name)).toEqual(['HEAD OFFICE', 'PALA BRANCH']) await updateBranch(db, 'u1', b1.id, { active: false }) const after = await listBranches(db, c.id) expect(after.find((b) => b.id === b1.id)!.active).toBe(false) expect(after[0]!.name).toBe('PALA BRANCH') // active sorts first const audits = (await db.get<{ n: number }>( `SELECT COUNT(*) AS n FROM audit_log WHERE entity='client_branch'`, ))!.n expect(audits).toBe(3) await expect(createBranch(db, 'u1', { clientId: c.id, name: ' ' })).rejects.toThrow(/name/i) }) }) describe('ticket desk (D20)', () => { it('walks open → assign → in_progress → closed (stamps closed_on), reopen clears it', async () => { const { db, c, staffId } = await world() const t = await createTicket(db, 'u1', { clientId: c.id, moduleCode: 'SMS', kind: 'MODIFICATION', description: 'Loan form change', }) expect(t.status).toBe('open') expect(t.closedOn).toBeNull() const assigned = await updateTicket(db, 'u1', t.id, { assignedTo: staffId, status: 'in_progress' }) expect(assigned.assignedName).toBe('Tibin') const closed = await updateTicket(db, 'u1', t.id, { status: 'closed' }) expect(closed.closedOn).not.toBeNull() const reopened = await updateTicket(db, 'u1', t.id, { status: 'open' }) expect(reopened.closedOn).toBeNull() await expect(updateTicket(db, 'u1', t.id, { status: 'bogus' as never })).rejects.toThrow(/status/i) }) it('validates client, branch ownership and active assignee', async () => { const { db, c, staffId } = await world() const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' }) const foreign = await createBranch(db, 'u1', { clientId: other.id, name: 'HO' }) await expect(createTicket(db, 'u1', { clientId: 'nope' })).rejects.toThrow(/client/i) await expect(createTicket(db, 'u1', { clientId: c.id, branchId: foreign.id })).rejects.toThrow(/branch/i) await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, staffId) await expect(createTicket(db, 'u1', { clientId: c.id, assignedTo: staffId })).rejects.toThrow(/inactive/i) }) it('lists with chips filters, mine filter, search and honest pagination; counts + kind suggestions', async () => { const { db, c, staffId } = await world() for (let i = 0; i < 55; i++) { await createTicket(db, 'u1', { clientId: c.id, kind: 'MODIFICATION', description: `bulk ${i}` }) } const mine = await createTicket(db, 'u1', { clientId: c.id, kind: 'SOFTWARE ADDONS', description: 'RTGS cert', assignedTo: staffId, }) await updateTicket(db, 'u1', mine.id, { status: 'waiting' }) const page1 = await listTickets(db, { page: 1, pageSize: 50 }) expect(page1.total).toBe(56) expect(page1.tickets).toHaveLength(50) const waiting = await listTickets(db, { status: 'waiting' }) expect(waiting.total).toBe(1) expect(waiting.tickets[0]!.id).toBe(mine.id) const assigned = await listTickets(db, { assignedTo: staffId }) expect(assigned.total).toBe(1) const search = await listTickets(db, { q: 'rtgs cert' }) expect(search.total).toBe(1) const counts = await ticketCounts(db) expect(counts['open']).toBe(55) expect(counts['waiting']).toBe(1) expect(await ticketKinds(db)).toEqual(['MODIFICATION', 'SOFTWARE ADDONS']) }) }) describe('client_module service data (D20)', () => { it('patches provider/username/details/remark; portal password (D31 plaintext) reveals audited, never in payloads', async () => { const { db, c } = await world() const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) const upd = await updateClientModule(db, 'u1', cm.id, { provider: 'BSNL', username: '20250043', remark: 'DEPO CR DR active', details: [{ label: 'SMS balance', value: '82,000' }, { label: 'Reseller', value: 'SiMS' }], }) expect(upd.provider).toBe('BSNL') expect(upd.details).toHaveLength(2) expect(upd.hasPassword).toBe(false) expect(JSON.stringify(upd)).not.toContain('password_enc') const withPw = await setModulePassword(db, 'u1', cm.id, 'rfUb9kW0', '') // D31: no key needed expect(withPw.hasPassword).toBe(true) expect(JSON.stringify(withPw)).not.toContain('rfUb9kW0') // still never in the list payload expect(await revealModulePassword(db, 'u1', cm.id, '')).toBe('rfUb9kW0') // reveal, no key const reveals = (await db.get<{ n: number }>( `SELECT COUNT(*) AS n FROM audit_log WHERE action='reveal_module_password'`, ))!.n expect(reveals).toBe(1) // Clearing with '' works and the audit payload never carried the plaintext. const cleared = await setModulePassword(db, 'u1', cm.id, '', KEY) expect(cleared.hasPassword).toBe(false) const auditBlob = (await db.all<{ after_json: string | null }>( `SELECT after_json FROM audit_log WHERE action='set_module_password'`, )).map((r) => r.after_json ?? '').join('') expect(auditBlob).not.toContain('rfUb9kW0') await expect(updateClientModule(db, 'u1', cm.id, { details: [{ label: '', value: 'x' }], })).rejects.toThrow(/label/i) }) }) describe('D20 routes (gating + round-trip)', () => { const servers: { close: () => void }[] = [] afterAll(() => { for (const s of servers) s.close() }) async function httpWorld() { const { db, c } = await world() await createStaff(db, { email: 'own@t.in', displayName: 'O', role: 'owner', password: 'owner-pass-1' }) const app = express(); app.use(express.json()); app.locals['db'] = db app.use('/api', apiRouter(db, { keyHex: KEY })) const server = app.listen(0); servers.push(server) const base = `http://localhost:${(server.address() as { port: number }).port}/api` const tok = async (email: string, password: string) => ((await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), })).json()) as { token: string }).token return { db, c, base, tok } } const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` }) it('staff can run the ticket desk but NOT touch/reveal module passwords; owner can', async () => { const { db, c, base, tok } = await httpWorld() const staffT = await tok('t@t.in', 'password-1') const ownerT = await tok('own@t.in', 'owner-pass-1') // Staff: full ticket flow. const created = await (await fetch(`${base}/tickets`, { method: 'POST', headers: H(staffT), body: JSON.stringify({ clientId: c.id, kind: 'MODIFICATION', description: 'from http' }), })).json() as { ok: boolean; ticket: { id: string } } expect(created.ok).toBe(true) const listed = await (await fetch(`${base}/tickets?status=open`, { headers: H(staffT) })).json() as { ok: boolean; total: number; counts: Record; kinds: string[] } expect(listed.total).toBe(1) expect(listed.kinds).toContain('MODIFICATION') const closed = await (await fetch(`${base}/tickets/${created.ticket.id}`, { method: 'PATCH', headers: H(staffT), body: JSON.stringify({ status: 'closed' }), })).json() as { ticket: { status: string; closedOn: string | null } } expect(closed.ticket.status).toBe('closed') // Module password: staff 403, owner sets + reveals. const m = await createModule(db, 'u1', { code: 'RTGS', name: 'RTGS' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) const deny = await fetch(`${base}/client-modules/${cm.id}`, { method: 'PATCH', headers: H(staffT), body: JSON.stringify({ password: 'x' }), }) expect(deny.status).toBe(403) const set = await fetch(`${base}/client-modules/${cm.id}`, { method: 'PATCH', headers: H(ownerT), body: JSON.stringify({ password: 'portal-pw', provider: 'SBI' }), }) expect(set.status).toBe(200) const denyReveal = await fetch(`${base}/client-modules/${cm.id}/reveal-password`, { method: 'POST', headers: H(staffT), body: '{}', }) expect(denyReveal.status).toBe(403) const reveal = await (await fetch(`${base}/client-modules/${cm.id}/reveal-password`, { method: 'POST', headers: H(ownerT), body: '{}', })).json() as { ok: boolean; password: string } expect(reveal.password).toBe('portal-pw') // Branches round-trip. const b = await (await fetch(`${base}/clients/${c.id}/branches`, { method: 'POST', headers: H(staffT), body: JSON.stringify({ name: 'HEAD OFFICE', code: '1' }), })).json() as { ok: boolean; branch: { id: string } } expect(b.ok).toBe(true) const branches = await (await fetch(`${base}/clients/${c.id}/branches`, { headers: H(staffT) })).json() as { branches: unknown[] } expect(branches.branches).toHaveLength(1) }) })