feat(hq): attachment download filename on GET /documents/:id/pdf?download=1
Serve the document PDF as an attachment with a doc-no-derived filename
("QT/26-27-0001" -> "QT-26-27-0001.pdf", null docNo -> "draft.pdf") when
?download=1 is present; keep the default inline so the composer iframe
preview still works. renderPdf is now injectable into apiRouter so the
header logic is tested without launching Chrome (house DI pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
parent
63e377d649
commit
25b96e2514
@ -0,0 +1,80 @@
|
|||||||
|
// apps/hq/test/pdf-download.test.ts
|
||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import express from 'express'
|
||||||
|
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 } from '../src/repos-documents'
|
||||||
|
import { apiRouter } from '../src/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/documents/:id/pdf serves the PDF INLINE by default (so the composer iframe
|
||||||
|
* preview keeps working). With ?download=1 it switches to an attachment with a filename
|
||||||
|
* derived from the doc no ("QT/26-27-0001" → "QT-26-27-0001.pdf"; a null docNo draft →
|
||||||
|
* "draft.pdf"). renderPdf is injected (a fake %PDF- buffer) so the header logic is
|
||||||
|
* exercised without launching Chrome — the house pattern for PDF-touching tests.
|
||||||
|
*/
|
||||||
|
function setup() {
|
||||||
|
const db = openDb(':memory:'); seedIfEmpty(db)
|
||||||
|
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
||||||
|
const c = createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' })
|
||||||
|
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
|
||||||
|
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
|
||||||
|
// An issued quotation carries a docNo with a '/' (QT/26-27-0001)…
|
||||||
|
const issued = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
||||||
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
|
||||||
|
// …a separate, un-issued draft keeps docNo=null.
|
||||||
|
const draftDoc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
||||||
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
||||||
|
const fakePdf = async (): Promise<Buffer> => Buffer.from('%PDF-1.4 fake')
|
||||||
|
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||||
|
app.use('/api', apiRouter(db, undefined, fakePdf))
|
||||||
|
const server = app.listen(0)
|
||||||
|
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||||
|
return { db, server, base, issued, draftDoc }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /documents/:id/pdf — download filename (Content-Disposition)', () => {
|
||||||
|
const ctx = setup()
|
||||||
|
afterAll(() => ctx.server.close())
|
||||||
|
const login = async (): Promise<string> =>
|
||||||
|
(await (await fetch(`${ctx.base}/auth/login`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }),
|
||||||
|
})).json() as any).token
|
||||||
|
const getPdf = async (path: string, token: string) =>
|
||||||
|
fetch(`${ctx.base}${path}`, { headers: { authorization: `Bearer ${token}` } })
|
||||||
|
|
||||||
|
it('serves inline by default (no ?download) so the composer iframe preview still works', async () => {
|
||||||
|
const token = await login()
|
||||||
|
const res = await getPdf(`/documents/${ctx.issued.id}/pdf`, token)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/pdf')
|
||||||
|
expect(res.headers.get('content-disposition')).toMatch(/^inline/)
|
||||||
|
// Not an attachment.
|
||||||
|
expect(res.headers.get('content-disposition')).not.toMatch(/^attachment/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serves an attachment with the slugged doc-no filename on ?download=1', async () => {
|
||||||
|
const token = await login()
|
||||||
|
const res = await getPdf(`/documents/${ctx.issued.id}/pdf?download=1`, token)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const cd = res.headers.get('content-disposition') ?? ''
|
||||||
|
expect(cd).toMatch(/^attachment/)
|
||||||
|
// QT/26-27-0001 → QT-26-27-0001.pdf (the '/' is not a legal filename char).
|
||||||
|
const slug = ctx.issued.docNo!.replaceAll('/', '-')
|
||||||
|
expect(cd).toContain(`filename="${slug}.pdf"`)
|
||||||
|
expect(slug).toMatch(/^QT-\d{2}-\d{2}-\d{4}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names a null-docNo draft "draft.pdf" on download', async () => {
|
||||||
|
const token = await login()
|
||||||
|
const res = await getPdf(`/documents/${ctx.draftDoc.id}/pdf?download=1`, token)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const cd = res.headers.get('content-disposition') ?? ''
|
||||||
|
expect(cd).toMatch(/^attachment/)
|
||||||
|
expect(cd).toContain('filename="draft.pdf"')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue