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.
202 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|