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.
152 lines
7.0 KiB
TypeScript
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')
|
|
})
|
|
})
|