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>
234 lines
7.5 KiB
TypeScript
234 lines
7.5 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
invoiceDocumentCaveat,
|
|
invoiceRerenderUrl,
|
|
resolveInvoicePdfSource,
|
|
type InvoicePdfDelivery,
|
|
} from '@/lib/invoices/invoice-pdf-source'
|
|
|
|
const INVOICE_ID = '3f2b9a6e-1c4d-4a51-9d0f-8b7c6e5d4a31'
|
|
const DOCUMENT_ID = 'a1b2c3d4-e5f6-4708-89ab-cdef01234567'
|
|
|
|
function emailDelivery(overrides: Partial<InvoicePdfDelivery> = {}): InvoicePdfDelivery {
|
|
return {
|
|
id: 'delivery-1',
|
|
status: 'sent',
|
|
document_attachment_id: DOCUMENT_ID,
|
|
sent_at: '2026-07-23T09:15:00.000Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('resolveInvoicePdfSource', () => {
|
|
it('serves the archived delivery when one exists', () => {
|
|
const source = resolveInvoicePdfSource({
|
|
invoiceId: INVOICE_ID,
|
|
invoiceStatus: 'sent',
|
|
deliveriesLoaded: true,
|
|
deliveries: [emailDelivery()],
|
|
})
|
|
|
|
expect(source).toEqual({
|
|
kind: 'archived',
|
|
url: `/api/documents/${DOCUMENT_ID}/inline`,
|
|
deliveryId: 'delivery-1',
|
|
sentAt: '2026-07-23T09:15:00.000Z',
|
|
})
|
|
expect(invoiceDocumentCaveat(source)).toBeNull()
|
|
})
|
|
|
|
it('picks the newest completed delivery, ignoring pending and failed attempts', () => {
|
|
const source = resolveInvoicePdfSource({
|
|
invoiceId: INVOICE_ID,
|
|
invoiceStatus: 'paid',
|
|
deliveriesLoaded: true,
|
|
deliveries: [
|
|
emailDelivery({ id: 'pending', status: 'pending', sent_at: null }),
|
|
emailDelivery({
|
|
id: 'failed',
|
|
status: 'failed',
|
|
document_attachment_id: null,
|
|
sent_at: null,
|
|
}),
|
|
emailDelivery({ id: 'newest-sent', document_attachment_id: DOCUMENT_ID }),
|
|
emailDelivery({ id: 'older-sent', document_attachment_id: 'older-doc' }),
|
|
],
|
|
})
|
|
|
|
expect(source).toMatchObject({ kind: 'archived', deliveryId: 'newest-sent' })
|
|
})
|
|
|
|
// The regression this module exists for: a failed delivery-history read used
|
|
// to collapse to [], which is indistinguishable from "never sent", and the
|
|
// download silently switched to a freshly re-rendered PDF while still
|
|
// reporting success.
|
|
it('never re-renders when the delivery history could not be read', () => {
|
|
const source = resolveInvoicePdfSource({
|
|
invoiceId: INVOICE_ID,
|
|
invoiceStatus: 'sent',
|
|
deliveriesLoaded: false,
|
|
deliveries: [],
|
|
})
|
|
|
|
expect(source).toEqual({ kind: 'unavailable', reason: 'delivery_history_unreadable' })
|
|
expect(source.kind).not.toBe('rerender')
|
|
})
|
|
|
|
it.each(['sent', 'paid', 'partially_paid', 'overdue', 'credited'])(
|
|
'reports unavailable rather than guessing for a %s invoice',
|
|
(invoiceStatus) => {
|
|
expect(
|
|
resolveInvoicePdfSource({
|
|
invoiceId: INVOICE_ID,
|
|
invoiceStatus,
|
|
deliveriesLoaded: false,
|
|
deliveries: [],
|
|
}).kind,
|
|
).toBe('unavailable')
|
|
},
|
|
)
|
|
|
|
it('labels the re-render when a sent invoice genuinely has no delivery row', () => {
|
|
const source = resolveInvoicePdfSource({
|
|
invoiceId: INVOICE_ID,
|
|
invoiceStatus: 'sent',
|
|
deliveriesLoaded: true,
|
|
deliveries: [],
|
|
})
|
|
|
|
expect(source).toEqual({
|
|
kind: 'rerender',
|
|
url: `/api/invoices/${INVOICE_ID}/pdf`,
|
|
reason: 'no_archived_copy',
|
|
})
|
|
expect(invoiceDocumentCaveat(source)).toBe('no_archived_copy')
|
|
})
|
|
|
|
it('labels the re-render when the latest delivery was marked sent manually', () => {
|
|
const source = resolveInvoicePdfSource({
|
|
invoiceId: INVOICE_ID,
|
|
invoiceStatus: 'overdue',
|
|
deliveriesLoaded: true,
|
|
deliveries: [
|
|
emailDelivery({ id: 'manual', status: 'marked_sent', document_attachment_id: null }),
|
|
emailDelivery({ id: 'older-email' }),
|
|
],
|
|
})
|
|
|
|
expect(source).toEqual({
|
|
kind: 'rerender',
|
|
url: `/api/invoices/${INVOICE_ID}/pdf`,
|
|
reason: 'sent_outside_accounted',
|
|
})
|
|
expect(invoiceDocumentCaveat(source)).toBe('sent_outside_accounted')
|
|
})
|
|
|
|
it('falls back to a labelled re-render when a sent row lost its attachment', () => {
|
|
const source = resolveInvoicePdfSource({
|
|
invoiceId: INVOICE_ID,
|
|
invoiceStatus: 'sent',
|
|
deliveriesLoaded: true,
|
|
deliveries: [emailDelivery({ document_attachment_id: null })],
|
|
})
|
|
|
|
expect(source).toMatchObject({ kind: 'rerender', reason: 'no_archived_copy' })
|
|
})
|
|
|
|
// A proforma can be emailed and makulerad afterwards, so `cancelled` does not
|
|
// prove nothing was sent. A readable history outranks the status: the
|
|
// archived copy exists and is what the recipient got.
|
|
it('serves the archived copy of a sent invoice that was cancelled afterwards', () => {
|
|
const source = resolveInvoicePdfSource({
|
|
invoiceId: INVOICE_ID,
|
|
invoiceStatus: 'cancelled',
|
|
deliveriesLoaded: true,
|
|
deliveries: [emailDelivery({ id: 'proforma-send' })],
|
|
})
|
|
|
|
expect(source).toMatchObject({ kind: 'archived', deliveryId: 'proforma-send' })
|
|
expect(invoiceDocumentCaveat(source)).toBeNull()
|
|
})
|
|
|
|
it.each(['draft', 'cancelled'])(
|
|
'serves the re-render unblocked for a %s invoice even if the history read failed',
|
|
(invoiceStatus) => {
|
|
const source = resolveInvoicePdfSource({
|
|
invoiceId: INVOICE_ID,
|
|
invoiceStatus,
|
|
deliveriesLoaded: false,
|
|
deliveries: [],
|
|
})
|
|
|
|
expect(source).toEqual({
|
|
kind: 'rerender',
|
|
url: `/api/invoices/${INVOICE_ID}/pdf`,
|
|
reason: 'not_sent_yet',
|
|
})
|
|
// Nothing was ever sent, so there is no "sent document" to mislabel.
|
|
expect(invoiceDocumentCaveat(source)).toBeNull()
|
|
},
|
|
)
|
|
|
|
it('encodes identifiers into both urls', () => {
|
|
const archived = resolveInvoicePdfSource({
|
|
invoiceId: 'inv/1',
|
|
invoiceStatus: 'sent',
|
|
deliveriesLoaded: true,
|
|
deliveries: [emailDelivery({ document_attachment_id: 'doc/1' })],
|
|
})
|
|
expect(archived).toMatchObject({ url: '/api/documents/doc%2F1/inline' })
|
|
|
|
const rerender = resolveInvoicePdfSource({
|
|
invoiceId: 'inv/1',
|
|
invoiceStatus: 'draft',
|
|
deliveriesLoaded: true,
|
|
deliveries: [],
|
|
})
|
|
expect(rerender).toMatchObject({ url: '/api/invoices/inv%2F1/pdf' })
|
|
})
|
|
})
|
|
|
|
describe('invoiceDocumentCaveat', () => {
|
|
it.each(['sent_outside_accounted', 'no_archived_copy', 'archive_unreachable'] as const)(
|
|
'refuses to present a %s re-render as the sent document',
|
|
(reason) => {
|
|
expect(
|
|
invoiceDocumentCaveat({
|
|
kind: 'rerender',
|
|
url: `/api/invoices/${INVOICE_ID}/pdf`,
|
|
reason,
|
|
}),
|
|
).toBe(reason)
|
|
},
|
|
)
|
|
|
|
// Nothing was downloaded, so there is nothing to caveat: the caller must
|
|
// open the dialog rather than report any kind of success.
|
|
it('returns no caveat for an unavailable source', () => {
|
|
expect(
|
|
invoiceDocumentCaveat({ kind: 'unavailable', reason: 'delivery_history_unreadable' }),
|
|
).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('invoiceRerenderUrl', () => {
|
|
it('defaults to the download endpoint', () => {
|
|
expect(invoiceRerenderUrl(INVOICE_ID)).toBe(`/api/invoices/${INVOICE_ID}/pdf`)
|
|
})
|
|
|
|
// #1190: previewing must not silently become a download, and the archived
|
|
// delivery URL above is already an inline proxy, so both source kinds can be
|
|
// opened in the browser the same way.
|
|
it('asks for an inline response when previewing', () => {
|
|
expect(invoiceRerenderUrl(INVOICE_ID, { inline: true })).toBe(
|
|
`/api/invoices/${INVOICE_ID}/pdf?disposition=inline`,
|
|
)
|
|
})
|
|
|
|
it('encodes the invoice id', () => {
|
|
expect(invoiceRerenderUrl('a/b', { inline: true })).toBe(
|
|
'/api/invoices/a%2Fb/pdf?disposition=inline',
|
|
)
|
|
})
|
|
})
|