* fix: reconcile annual reports with final closing entries * test: cover annual report depreciation and VAT balances * Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch * fix: show exact invoice delivery details * fix: use currency account in invoice emails * fix: address invoice delivery review feedback * fix: harden invoice delivery and payment accounts * test: assert RLS-denied zero-row updates * fix: close remaining invoice compliance gaps * fix: harden invoice archive authorization * fix: close invoice delivery review findings * fix: verify delivery finalization results * fix: cap combined invoice email recipients * fix: close final invoice compliance findings * fix: prevent stale payment account saves * test: prove invoice delivery isolation * fix: close invoice privacy review findings * test: normalize delivery retention dates
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
/**
|
|
* Regression tests for issue #772: "Logotyp kommer inte med på fakturor".
|
|
*
|
|
* Root cause: @react-pdf/renderer's <Image> only decodes JPG/PNG, but the logo
|
|
* upload route and the `logos` bucket accept SVG and WebP. When a logo was an
|
|
* SVG/WebP, @react-pdf silently dropped it (it swallows the decode error in a
|
|
* try/catch), so invoices rendered with no logo and no error.
|
|
*
|
|
* Fix: prepareInvoicePdfRender fetches the stored logo and re-encodes it to a
|
|
* PNG data URL via sharp, so every supported upload format renders. These tests
|
|
* mock `fetch` and exercise the real sharp pipeline.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import sharp from 'sharp'
|
|
import { prepareInvoicePdfRender, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
|
import { makeCompanySettings, makeInvoice } from '@/tests/helpers'
|
|
|
|
const fontDownloadMock = vi.hoisted(() => vi.fn())
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createServiceClient: () => ({
|
|
storage: {
|
|
from: () => ({ download: fontDownloadMock }),
|
|
},
|
|
}),
|
|
}))
|
|
|
|
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
|
|
|
|
const SVG_LOGO = Buffer.from(
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40">' +
|
|
'<rect width="120" height="40" fill="#1a1a1a"/>' +
|
|
'<text x="8" y="26" fill="#fff" font-size="18">ACME</text></svg>',
|
|
)
|
|
|
|
/** Build a one-shot fetch mock that returns the given bytes + content-type. */
|
|
function mockFetchOnce(buf: Buffer, contentType: string) {
|
|
const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
|
const fn = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
headers: { get: () => contentType },
|
|
arrayBuffer: async () => arrayBuffer,
|
|
})
|
|
vi.stubGlobal('fetch', fn)
|
|
return fn
|
|
}
|
|
|
|
/** A data: URL whose payload decodes to a valid PNG via sharp. */
|
|
async function expectValidEmbeddedPng(logoUrl: string | null | undefined) {
|
|
expect(logoUrl).toMatch(new RegExp(`^${PNG_DATA_URL_PREFIX}`))
|
|
const base64 = (logoUrl as string).slice(PNG_DATA_URL_PREFIX.length)
|
|
const meta = await sharp(Buffer.from(base64, 'base64')).metadata()
|
|
expect(meta.format).toBe('png')
|
|
}
|
|
|
|
describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
|
|
beforeEach(() => {
|
|
vi.unstubAllGlobals()
|
|
fontDownloadMock.mockReset()
|
|
})
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
vi.unstubAllEnvs()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('renders only the payment account matching the invoice currency', async () => {
|
|
const company = makeCompanySettings({
|
|
iban: 'SE0011111111111111111111',
|
|
invoice_payment_accounts: {
|
|
EUR: {
|
|
bank_name: 'EUR Bank',
|
|
clearing_number: null,
|
|
account_number: null,
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: 'SE0022222222222222222222',
|
|
bic: 'EURRSESS',
|
|
},
|
|
},
|
|
})
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(company, 'EUR')
|
|
|
|
expect(resolved.bank_name).toBe('EUR Bank')
|
|
expect(resolved.iban).toBe('SE0022222222222222222222')
|
|
expect(resolved.bankgiro).toBeNull()
|
|
})
|
|
|
|
it('rejects a foreign-currency PDF without a usable payment account', async () => {
|
|
const company = makeCompanySettings({ invoice_payment_accounts: {} })
|
|
|
|
await expect(prepareInvoicePdfRender(company, 'EUR')).rejects.toMatchObject({
|
|
code: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
|
|
currency: 'EUR',
|
|
})
|
|
})
|
|
|
|
it('renders non-payable foreign documents without requiring an account or leaking SEK details', async () => {
|
|
const company = makeCompanySettings({
|
|
iban: 'SE0011111111111111111111',
|
|
invoice_payment_accounts: {},
|
|
})
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(
|
|
company,
|
|
'EUR',
|
|
{ paymentAccountRequired: false },
|
|
)
|
|
|
|
expect(resolved.iban).toBeNull()
|
|
expect(resolved.bankgiro).toBeNull()
|
|
})
|
|
|
|
it('embeds an SVG logo as a PNG data URL so @react-pdf can draw it', async () => {
|
|
const fetchMock = mockFetchOnce(SVG_LOGO, 'image/svg+xml')
|
|
const company = makeCompanySettings({
|
|
logo_url: 'https://example.test/svg-logo-1.svg',
|
|
})
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(company)
|
|
|
|
// Fetched with a timeout signal so a slow logo host can't hang the render.
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://example.test/svg-logo-1.svg',
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
)
|
|
await expectValidEmbeddedPng(resolved.logo_url)
|
|
})
|
|
|
|
it('embeds a WebP logo as a PNG data URL', async () => {
|
|
const webp = await sharp(SVG_LOGO).webp().toBuffer()
|
|
mockFetchOnce(webp, 'image/webp')
|
|
const company = makeCompanySettings({
|
|
logo_url: 'https://example.test/webp-logo-1.webp',
|
|
})
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(company)
|
|
|
|
await expectValidEmbeddedPng(resolved.logo_url)
|
|
})
|
|
|
|
it('re-encodes a PNG logo to an embedded data URL (no remote fetch at render time)', async () => {
|
|
const png = await sharp(SVG_LOGO).png().toBuffer()
|
|
mockFetchOnce(png, 'image/png')
|
|
const company = makeCompanySettings({
|
|
logo_url: 'https://example.test/png-logo-1.png',
|
|
})
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(company)
|
|
|
|
await expectValidEmbeddedPng(resolved.logo_url)
|
|
})
|
|
|
|
it('falls back to the original URL when the logo fetch is not ok', async () => {
|
|
const fn = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
headers: { get: () => null },
|
|
arrayBuffer: async () => new ArrayBuffer(0),
|
|
})
|
|
vi.stubGlobal('fetch', fn)
|
|
const url = 'https://example.test/missing-logo.png'
|
|
const company = makeCompanySettings({ logo_url: url })
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(company)
|
|
|
|
// Unchanged: never worse than before (@react-pdf still fetches PNG/JPEG).
|
|
expect(resolved.logo_url).toBe(url)
|
|
})
|
|
|
|
it('falls back to the original URL when the fetch throws', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockRejectedValue(new Error('network down')),
|
|
)
|
|
const url = 'https://example.test/network-error-logo.png'
|
|
const company = makeCompanySettings({ logo_url: url })
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(company)
|
|
|
|
expect(resolved.logo_url).toBe(url)
|
|
})
|
|
|
|
it('falls back to the original URL when the logo exceeds the size cap', async () => {
|
|
// Declared content-length over the cap is rejected before reading the body.
|
|
const fn = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
headers: {
|
|
get: (h: string) =>
|
|
h.toLowerCase() === 'content-length' ? String(11 * 1024 * 1024) : 'image/png',
|
|
},
|
|
arrayBuffer: async () => new ArrayBuffer(0),
|
|
})
|
|
vi.stubGlobal('fetch', fn)
|
|
const url = 'https://example.test/oversized-logo.png'
|
|
const company = makeCompanySettings({ logo_url: url })
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(company)
|
|
|
|
expect(fn).toHaveBeenCalled()
|
|
expect(resolved.logo_url).toBe(url)
|
|
})
|
|
|
|
it('does not fetch when no logo is configured', async () => {
|
|
const fetchMock = vi.fn()
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const company = makeCompanySettings({ logo_url: null })
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(company)
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
expect(resolved.logo_url).toBeNull()
|
|
})
|
|
|
|
it('passes through an already-embedded data: URL without fetching', async () => {
|
|
const fetchMock = vi.fn()
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const dataUrl = `${PNG_DATA_URL_PREFIX}iVBORw0KGgo=`
|
|
const company = makeCompanySettings({ logo_url: dataUrl })
|
|
|
|
const { company: resolved } = await prepareInvoicePdfRender(company)
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
expect(resolved.logo_url).toBe(dataUrl)
|
|
})
|
|
|
|
it('still returns branding alongside the resolved company', async () => {
|
|
mockFetchOnce(SVG_LOGO, 'image/svg+xml')
|
|
const company = makeCompanySettings({
|
|
logo_url: 'https://example.test/branding-logo.svg',
|
|
invoice_primary_color: '#c2410c',
|
|
})
|
|
|
|
const { branding } = await prepareInvoicePdfRender(company)
|
|
|
|
expect(branding.primaryColor).toBe('#c2410c')
|
|
})
|
|
})
|
|
|
|
describe('prepareInvoicePdfRender: invoice fonts', () => {
|
|
beforeEach(() => {
|
|
vi.unstubAllGlobals()
|
|
fontDownloadMock.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
vi.unstubAllEnvs()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('registers a bundled font before returning it to the PDF template', async () => {
|
|
const company = makeCompanySettings({ invoice_font_family: 'Source Sans 3' })
|
|
|
|
const { branding } = await prepareInvoicePdfRender(company)
|
|
|
|
expect(branding.fontFamily).toBe('Source Sans 3')
|
|
})
|
|
|
|
it('registers a valid stored custom font under an isolated render family', async () => {
|
|
const bytes = await readFile(
|
|
join(process.cwd(), 'public', 'fonts', 'invoice', 'SourceSans3-Regular.ttf'),
|
|
)
|
|
const arrayBuffer = bytes.buffer.slice(
|
|
bytes.byteOffset,
|
|
bytes.byteOffset + bytes.byteLength,
|
|
)
|
|
fontDownloadMock.mockResolvedValue({
|
|
data: new Blob([arrayBuffer]),
|
|
error: null,
|
|
})
|
|
const company = makeCompanySettings({
|
|
invoice_font_family: 'Custom',
|
|
invoice_custom_font_path: 'company-1/invoice-font-1.ttf',
|
|
})
|
|
|
|
const { branding } = await prepareInvoicePdfRender(company)
|
|
|
|
expect(branding.fontFamily).toMatch(/^InvoiceCustom-[0-9a-f]{12}$/)
|
|
})
|
|
|
|
it('falls back to Helvetica when a custom font cannot be parsed', async () => {
|
|
const corruptedFont = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0xff, 0xff])
|
|
fontDownloadMock.mockResolvedValue({
|
|
data: new Blob([corruptedFont]),
|
|
error: null,
|
|
})
|
|
const company = makeCompanySettings({
|
|
invoice_font_family: 'Custom',
|
|
invoice_custom_font_path: 'company-1/invoice-font-2.ttf',
|
|
})
|
|
|
|
const { branding } = await prepareInvoicePdfRender(company)
|
|
|
|
expect(branding.fontFamily).toBe('Helvetica')
|
|
})
|
|
|
|
it('does not download a custom font from another company path', async () => {
|
|
const company = makeCompanySettings({
|
|
invoice_font_family: 'Custom',
|
|
invoice_custom_font_path: 'company-2/invoice-font-3.ttf',
|
|
})
|
|
|
|
const { branding } = await prepareInvoicePdfRender(company)
|
|
|
|
expect(fontDownloadMock).not.toHaveBeenCalled()
|
|
expect(branding.fontFamily).toBe('Helvetica')
|
|
})
|
|
})
|
|
|
|
describe('buildPaymentLinkQrDataUrl', () => {
|
|
it('encodes the payment link as a PNG QR data URL for a real invoice', async () => {
|
|
const invoice = makeInvoice({ payment_link_url: 'https://buy.stripe.com/test_abc123' })
|
|
const qr = await buildPaymentLinkQrDataUrl(invoice)
|
|
expect(qr).toMatch(new RegExp(`^${PNG_DATA_URL_PREFIX}`))
|
|
})
|
|
|
|
it('returns null when the invoice has no payment link', async () => {
|
|
expect(await buildPaymentLinkQrDataUrl(makeInvoice())).toBeNull()
|
|
expect(await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: ' ' }))).toBeNull()
|
|
})
|
|
|
|
it('returns null for non-payable documents (proforma, delivery note, credit note)', async () => {
|
|
const url = 'https://buy.stripe.com/test_abc123'
|
|
expect(
|
|
await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: url, document_type: 'proforma' })),
|
|
).toBeNull()
|
|
expect(
|
|
await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: url, document_type: 'delivery_note' })),
|
|
).toBeNull()
|
|
expect(
|
|
await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: url, credited_invoice_id: 'inv-orig' })),
|
|
).toBeNull()
|
|
})
|
|
})
|