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

152 lines
7.0 KiB
TypeScript

// apps/hq/test/documents-page.test.ts — Phase 2 (go-live cluster WS-D):
// listDocuments pagination with an honest total (rule 8) + GET /documents pass-through.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument, listDocuments, markStatus } from '../src/repos-documents'
import { clientLedger } from '../src/repos-payments'
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) => createClient(db, 'u1', { name, stateCode: '32' })
const mkDraft = (clientId: string, docType: 'QUOTATION' | 'PROFORMA' | 'INVOICE' = 'QUOTATION') =>
createDraft(db, 'u1', { docType, clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
return { db, m, mkClient, mkDraft }
}
describe('listDocuments — pagination (rule 8)', () => {
it('returns { documents, total, page, pageSize } with defaults page=1 pageSize=50, newest first', async () => {
const { db, mkClient, mkDraft } = await world()
const c = await mkClient('Acme Bank')
const ids: string[] = []
for (let i = 0; i < 55; i++) ids.push((await mkDraft(c.id)).id)
const p1 = await listDocuments(db)
expect(p1.total).toBe(55)
expect(p1.page).toBe(1)
expect(p1.pageSize).toBe(50)
expect(p1.documents).toHaveLength(50)
// newest first — the last-created draft leads the list
expect(p1.documents[0]!.id).toBe(ids[54])
const p2 = await listDocuments(db, { page: 2 })
expect(p2.total).toBe(55) // total is stable across pages
expect(p2.documents).toHaveLength(5)
// no overlap between pages
const seen = new Set(p1.documents.map((d) => d.id))
expect(p2.documents.some((d) => seen.has(d.id))).toBe(false)
})
it('joins the client name onto every row (no N+1 for the list view)', async () => {
const { db, mkClient, mkDraft } = await world()
const a = await mkClient('Acme Bank'); const b = await mkClient('Beta Coop')
await mkDraft(a.id); await mkDraft(b.id)
const page = await listDocuments(db)
const names = new Map(page.documents.map((d) => [d.clientId, d.clientName]))
expect(names.get(a.id)).toBe('Acme Bank')
expect(names.get(b.id)).toBe('Beta Coop')
})
it('type/status/clientId filters compose, with an honest filtered total', async () => {
const { db, mkClient, mkDraft } = await world()
const a = await mkClient('Acme Bank'); const b = await mkClient('Beta Coop')
await mkDraft(a.id, 'QUOTATION')
const invA = await mkDraft(a.id, 'INVOICE')
await issueDocument(db, 'u1', invA.id)
await markStatus(db, 'u1', invA.id, 'sent')
await mkDraft(b.id, 'INVOICE') // stays draft
const filtered = await listDocuments(db, { type: 'INVOICE', clientId: a.id, status: 'sent', pageSize: 1 })
expect(filtered.total).toBe(1)
expect(filtered.documents.map((d) => d.id)).toEqual([invA.id])
const draftInvoices = await listDocuments(db, { type: 'INVOICE', status: 'draft' })
expect(draftInvoices.total).toBe(1)
expect(draftInvoices.documents[0]!.clientId).toBe(b.id)
})
it('clamps pageSize to 200 and floors page/pageSize at 1', async () => {
const { db, mkClient, mkDraft } = await world()
const c = await mkClient('Acme Bank')
for (let i = 0; i < 3; i++) await mkDraft(c.id)
expect((await listDocuments(db, { pageSize: 9999 })).pageSize).toBe(200)
expect((await listDocuments(db, { pageSize: 0 })).pageSize).toBe(1)
expect((await listDocuments(db, { page: 0 })).page).toBe(1)
})
it('clientLedger still surfaces EVERY document — the page cap never truncates the ledger', async () => {
const { db, mkClient, mkDraft } = await world()
const c = await mkClient('Acme Bank')
for (let i = 0; i < 55; i++) await mkDraft(c.id)
const ledger = await clientLedger(db, c.id)
expect(ledger.documents).toHaveLength(55)
})
})
// ---------- GET /documents pass-through ----------
async function appWith() {
const { db, mkClient, mkDraft } = await world()
await createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db
const nullFetch = (async () => new Response('{}', { status: 500 })) as typeof fetch
app.use('/api', apiRouter(db, { f: nullFetch, 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, mkClient, mkDraft }
}
async function login(baseUrl: string) {
return (await (await fetch(`${baseUrl}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'staff@test.in', password: 'staff-password' }),
})).json() as { token: string }).token
}
interface ListJson {
ok: boolean; total: number; page: number; pageSize: number
documents: { id: string; docType: string; clientName: string }[]
}
describe('GET /documents — paginated route', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
it('passes page/pageSize through and reports the honest total + clientName rows', async () => {
const ctx = await appWith(); servers.push(ctx.server)
const c = await ctx.mkClient('Acme Bank')
for (let i = 0; i < 5; i++) await ctx.mkDraft(c.id)
const token = await login(ctx.baseUrl)
const res = await fetch(`${ctx.baseUrl}/documents?page=2&pageSize=2`, {
headers: { authorization: `Bearer ${token}` },
})
expect(res.status).toBe(200)
const json = await res.json() as ListJson
expect(json).toMatchObject({ ok: true, total: 5, page: 2, pageSize: 2 })
expect(json.documents).toHaveLength(2)
expect(json.documents[0]!.clientName).toBe('Acme Bank')
})
it('composes type + status filters with pagination', async () => {
const ctx = await appWith(); servers.push(ctx.server)
const c = await ctx.mkClient('Acme Bank')
await ctx.mkDraft(c.id, 'QUOTATION')
await ctx.mkDraft(c.id, 'INVOICE')
await ctx.mkDraft(c.id, 'INVOICE')
const token = await login(ctx.baseUrl)
const res = await fetch(`${ctx.baseUrl}/documents?type=INVOICE&status=draft&page=1&pageSize=1`, {
headers: { authorization: `Bearer ${token}` },
})
const json = await res.json() as ListJson
expect(json.total).toBe(2) // both draft invoices counted, one on the page
expect(json.documents).toHaveLength(1)
expect(json.documents[0]!.docType).toBe('INVOICE')
})
})