* refactor: optimize page loading and data fetching * fix: resolve recurring production runtime errors * feat: add MCP company and customer updates * fix: handle year-end tax adjustments * feat: harden annual report compliance * fix: expand invoice logo and font support * fix: sanitize API route error responses * fix: sanitize user-facing error messages * feat: persist onboarding and tax assessment notices * fix: reduce cloud backup audit churn * feat: refine invoice editor layout * fix: show saved tax adjustments in INK2 * fix: complete annual report API mappings * docs: record operational safeguards and decisions * fix: harden annual report review findings * fix: adjust column span for description based on VAT registration * New css class name
176 lines
5.4 KiB
TypeScript
176 lines
5.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
|
|
|
const { supabase, 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'),
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
const requireWriteMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
|
}))
|
|
|
|
const storageBucket = {
|
|
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
|
list: vi.fn().mockResolvedValue({ data: [], error: null }),
|
|
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
|
|
}
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(),
|
|
createServiceClient: () => ({
|
|
storage: { from: vi.fn().mockReturnValue(storageBucket) },
|
|
}),
|
|
}))
|
|
|
|
import { DELETE, POST } from '../route'
|
|
|
|
function makeFontRequest(
|
|
size = 8,
|
|
signature: number[] = [0x00, 0x01, 0x00, 0x00],
|
|
name = 'brand.ttf',
|
|
): Request {
|
|
const bytes = new Uint8Array(size)
|
|
bytes.set(signature.slice(0, size))
|
|
const formData = new FormData()
|
|
formData.append('file', new File([bytes], name, { type: 'font/ttf' }))
|
|
return new Request('http://localhost/api/settings/invoice-font', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
}
|
|
|
|
describe('POST /api/settings/invoice-font', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
|
requireWriteMock.mockResolvedValue({ ok: true })
|
|
})
|
|
|
|
it('returns 401 when not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
|
|
|
|
expect(response.status).toBe(401)
|
|
})
|
|
|
|
it('returns 403 for a viewer without write permission', async () => {
|
|
requireWriteMock.mockResolvedValue({
|
|
ok: false,
|
|
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
|
})
|
|
|
|
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
it('returns 400 for a file with an invalid font signature', async () => {
|
|
const response = await POST(
|
|
makeFontRequest(8, [0x25, 0x50, 0x44, 0x46]),
|
|
{ params: Promise.resolve({}) },
|
|
)
|
|
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('returns 400 when the font exceeds 5 MB', async () => {
|
|
const response = await POST(
|
|
makeFontRequest(5 * 1024 * 1024 + 1),
|
|
{ params: Promise.resolve({}) },
|
|
)
|
|
const { body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error).toContain('5 MB')
|
|
})
|
|
|
|
it('returns 404 when company settings do not exist', async () => {
|
|
enqueue({ error: { code: 'PGRST116', message: 'No rows returned' } })
|
|
|
|
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(storageBucket.remove).toHaveBeenCalled()
|
|
})
|
|
|
|
it('uploads a valid TTF and selects it for invoice PDFs', async () => {
|
|
enqueue({ error: null })
|
|
|
|
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
|
|
const { body } = await parseJsonResponse<{
|
|
data: { invoice_font_family: string; invoice_custom_font_name: string }
|
|
}>(response)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data).toMatchObject({
|
|
invoice_font_family: 'Custom',
|
|
invoice_custom_font_name: 'brand.ttf',
|
|
})
|
|
expect(storageBucket.upload).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^company-1\/invoice-font-\d+\.ttf$/),
|
|
expect.any(Buffer),
|
|
expect.objectContaining({ contentType: 'font/ttf' }),
|
|
)
|
|
})
|
|
|
|
it('uploads a valid WOFF with its normalized content type', async () => {
|
|
enqueue({ error: null })
|
|
|
|
const response = await POST(
|
|
makeFontRequest(8, [0x77, 0x4f, 0x46, 0x46], 'brand.woff'),
|
|
{ params: Promise.resolve({}) },
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(storageBucket.upload).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^company-1\/invoice-font-\d+\.woff$/),
|
|
expect.any(Buffer),
|
|
expect.objectContaining({ contentType: 'font/woff' }),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('DELETE /api/settings/invoice-font', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
|
requireWriteMock.mockResolvedValue({ ok: true })
|
|
})
|
|
|
|
it('removes the custom font and restores Helvetica', async () => {
|
|
enqueue({ error: null })
|
|
|
|
const response = await DELETE(
|
|
new Request('http://localhost/api/settings/invoice-font', { method: 'DELETE' }),
|
|
{ params: Promise.resolve({}) },
|
|
)
|
|
const { body } = await parseJsonResponse<{
|
|
data: { invoice_font_family: string; invoice_custom_font_path: null }
|
|
}>(response)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data).toEqual({
|
|
invoice_font_family: 'Helvetica',
|
|
invoice_custom_font_path: null,
|
|
invoice_custom_font_name: null,
|
|
})
|
|
})
|
|
})
|