Reviewing an invoice or a verifikat bilaga meant saving a file and opening it from the Downloads folder (user request, christian@odinaero.se 2026-07-25). - GET /api/invoices/[id]/pdf accepts ?disposition=inline and serves the PDF for in-browser review; anything else keeps the download behaviour every existing caller relies on. The filename still travels in the header, so the browser viewer's own save action produces the same name as the download button, and nosniff pins the content type. - The invoice detail page gets a "Förhandsgranska" action next to "Ladda ner PDF". It resolves the document through the same resolveInvoicePdfSource path as the download, so preview cannot become the shortcut that presents a re-render as the invoice the customer received: the archived delivery wins, a re-render is shown with its caveat, and an unreadable delivery history still asks instead of guessing. The archive dialog now remembers whether the user asked to view or to save, and its fallback does that. - DocumentViewButton (supplier-invoice underlag, staged agent previews) points at the existing /api/documents/:id/inline proxy, so bilagor render in the browser. Navigation now happens straight from the click, so the signed-URL fetch and its popup-blocker workaround are gone. - The three re-render caveat strings and the two archive-dialog descriptions lose their "you downloaded" wording so they stay true for both actions; five new keys in sv + en. Tests: route cases for the default, inline and unknown disposition values; invoiceRerenderUrl cases for both modes and id encoding. npm test 11364 passed, lint 0 errors. Button row screenshotted against the design system (pill outline, Eye icon) via a temporary sandbox route. Closes #1190 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
5.9 KiB
TypeScript
174 lines
5.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
createMockRequest,
|
|
createMockRouteParams,
|
|
createQueuedMockSupabase,
|
|
makeCompanySettings,
|
|
makeCustomer,
|
|
makeInvoice,
|
|
} from '@/tests/helpers'
|
|
import { contentDispositionFilename } from '@/lib/api/content-disposition'
|
|
|
|
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
|
const requireAuthMock = vi.fn()
|
|
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
const renderToBufferMock = vi.fn()
|
|
vi.mock('@react-pdf/renderer', () => ({
|
|
renderToBuffer: (...args: unknown[]) => renderToBufferMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/invoices/pdf-template', () => ({
|
|
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
|
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
|
|
SHOW_SWISH_ON_INVOICE: false,
|
|
}))
|
|
|
|
import { GET } from '../route'
|
|
|
|
describe('GET /api/invoices/[id]/pdf', () => {
|
|
const user = { id: 'user-1', email: 'owner@example.test' }
|
|
const customer = makeCustomer({ name: 'Kund ÅÄÖ AB' })
|
|
const company = makeCompanySettings({ company_name: 'Oppy Sverige', bankgiro: '123-4567' })
|
|
const invoice = makeInvoice({
|
|
id: 'invoice-1',
|
|
invoice_number: '2621',
|
|
invoice_date: '2026-07-21',
|
|
customer,
|
|
items: [],
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
|
|
renderToBufferMock.mockResolvedValue(Buffer.from('pdf-bytes'))
|
|
})
|
|
|
|
it('returns 401 when the caller is not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase: mockSupabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(401)
|
|
})
|
|
|
|
it('returns 404 when the invoice does not exist', async () => {
|
|
enqueue({ data: null, error: { message: 'not found' } })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/missing/pdf'),
|
|
createMockRouteParams({ id: 'missing' }),
|
|
)
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
|
|
it('returns a descriptive UTF-8 filename for the PDF download', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
|
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
|
|
it('forces a download by default', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.headers.get('Content-Disposition')).toMatch(/^attachment;/)
|
|
})
|
|
|
|
it('serves the PDF inline for in-browser review on ?disposition=inline (#1190)', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf', {
|
|
searchParams: { disposition: 'inline' },
|
|
}),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Disposition')).toMatch(/^inline;/)
|
|
// The filename still travels with it, so the browser viewer's own save
|
|
// action produces the same name as the download button would.
|
|
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
|
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
|
|
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
|
})
|
|
|
|
it('keeps the download behaviour for an unknown disposition value', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf', {
|
|
searchParams: { disposition: 'evil' },
|
|
}),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.headers.get('Content-Disposition')).toMatch(/^attachment;/)
|
|
})
|
|
|
|
it('returns 400 before rendering when a foreign payment account is missing', async () => {
|
|
enqueue({ data: { ...invoice, currency: 'EUR' }, error: null })
|
|
enqueue({ data: { ...company, invoice_payment_accounts: {} }, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(renderToBufferMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('marks PDF generation errors as private and non-cacheable', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
renderToBufferMock.mockRejectedValueOnce(new Error('render failed'))
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(500)
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
})
|