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

202 lines
10 KiB
TypeScript

// 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 encrypts, 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')
await expect(setModulePassword(db, 'u1', cm.id, 'rfUb9kW0', '')).rejects.toThrow(/HQ_SECRET_KEY/)
const withPw = await setModulePassword(db, 'u1', cm.id, 'rfUb9kW0', KEY)
expect(withPw.hasPassword).toBe(true)
const stored = (await db.get<{ password_enc: string }>(
`SELECT password_enc FROM client_module WHERE id=?`, cm.id,
))!
expect(stored.password_enc).not.toContain('rfUb9kW0') // encrypted at rest
expect(await revealModulePassword(db, 'u1', cm.id, KEY)).toBe('rfUb9kW0')
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<string, number>; 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)
})
})