* 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
293 lines
9.1 KiB
TypeScript
293 lines
9.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
|
|
|
const { mockLogInfo, mockLogWarn, mockLogError } = vi.hoisted(() => ({
|
|
mockLogInfo: vi.fn(),
|
|
mockLogWarn: vi.fn(),
|
|
mockLogError: vi.fn(),
|
|
}))
|
|
vi.mock('@/lib/logger', () => ({
|
|
createLogger: () => ({
|
|
info: mockLogInfo,
|
|
warn: mockLogWarn,
|
|
error: mockLogError,
|
|
child: vi.fn().mockReturnThis(),
|
|
}),
|
|
}))
|
|
|
|
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
|
const {
|
|
supabase: archiveSupabase,
|
|
enqueue: enqueueArchive,
|
|
reset: resetArchive,
|
|
} = createQueuedMockSupabase()
|
|
const createServiceClientMock = vi.fn(() => archiveSupabase)
|
|
|
|
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'),
|
|
}))
|
|
|
|
vi.mock('@/lib/reports/full-archive-export', () => ({
|
|
generateFullArchive: vi.fn(),
|
|
estimateArchiveSize: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createServiceClient: () => createServiceClientMock(),
|
|
}))
|
|
|
|
import {
|
|
generateFullArchive,
|
|
estimateArchiveSize,
|
|
} from '@/lib/reports/full-archive-export'
|
|
import { GET } from '../route'
|
|
|
|
const mockGenerate = vi.mocked(generateFullArchive)
|
|
const mockEstimate = vi.mocked(estimateArchiveSize)
|
|
|
|
function authed() {
|
|
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
|
}
|
|
|
|
function unauthed() {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
resetArchive()
|
|
authed()
|
|
enqueue({ data: { role: 'admin' }, error: null })
|
|
enqueueArchive({ data: { role: 'admin' }, error: null })
|
|
})
|
|
|
|
describe('GET /api/reports/full-archive', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
unauthed()
|
|
const { status, body } = await parseJsonResponse(
|
|
await GET(createMockRequest('/api/reports/full-archive'))
|
|
)
|
|
expect(status).toBe(401)
|
|
expect(body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('returns 403 for a member without archive-audit access', async () => {
|
|
reset()
|
|
enqueue({ data: { role: 'member' }, error: null })
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
|
await GET(createMockRequest('/api/reports/full-archive')),
|
|
)
|
|
|
|
expect(status).toBe(403)
|
|
expect(body.error.code).toBe('FORBIDDEN')
|
|
expect(mockLogWarn).toHaveBeenCalledWith('full archive access denied', {
|
|
userId: 'user-1',
|
|
companyId: 'company-1',
|
|
role: 'member',
|
|
})
|
|
expect(createServiceClientMock).not.toHaveBeenCalled()
|
|
expect(mockEstimate).not.toHaveBeenCalled()
|
|
expect(mockGenerate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects when the verified user is not a member of the selected company', async () => {
|
|
reset()
|
|
resetArchive()
|
|
enqueue({ data: { role: 'admin' }, error: null })
|
|
enqueueArchive({ data: null, error: null })
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
|
await GET(createMockRequest('/api/reports/full-archive')),
|
|
)
|
|
|
|
expect(status).toBe(403)
|
|
expect(body.error.code).toBe('FORBIDDEN')
|
|
expect(mockEstimate).not.toHaveBeenCalled()
|
|
expect(mockGenerate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 500 when the service-role membership verification fails', async () => {
|
|
reset()
|
|
resetArchive()
|
|
enqueue({ data: { role: 'admin' }, error: null })
|
|
enqueueArchive({ data: null, error: new Error('database unavailable') })
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
|
await GET(createMockRequest('/api/reports/full-archive')),
|
|
)
|
|
|
|
expect(status).toBe(500)
|
|
expect(body.error.code).toBe('INTERNAL_ERROR')
|
|
expect(mockEstimate).not.toHaveBeenCalled()
|
|
expect(mockGenerate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns estimate-only response when ?estimate=1', async () => {
|
|
mockEstimate.mockResolvedValue({
|
|
total_bytes: 10_000_000,
|
|
document_bytes: 5_000_000,
|
|
document_count: 7,
|
|
})
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/reports/full-archive', {
|
|
searchParams: { estimate: '1', scope: 'all' },
|
|
}),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{
|
|
data: {
|
|
total_bytes: number
|
|
size_limit_bytes: number
|
|
within_limit: boolean
|
|
}
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data.total_bytes).toBe(10_000_000)
|
|
expect(body.data.within_limit).toBe(true)
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'company-1', 'all', undefined)
|
|
expect(mockGenerate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 413 archive_too_large when estimate exceeds limit', async () => {
|
|
mockEstimate.mockResolvedValue({
|
|
total_bytes: 200 * 1024 * 1024,
|
|
document_bytes: 195 * 1024 * 1024,
|
|
document_count: 200,
|
|
})
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/reports/full-archive', {
|
|
searchParams: { scope: 'all' },
|
|
})
|
|
)
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: string
|
|
size_bytes: number
|
|
size_limit_bytes: number
|
|
}>(response)
|
|
|
|
expect(status).toBe(413)
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(body.error).toBe('archive_too_large')
|
|
expect(body.size_bytes).toBe(200 * 1024 * 1024)
|
|
expect(body.size_limit_bytes).toBe(80 * 1024 * 1024)
|
|
expect(mockGenerate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips 413 when include_documents=false', async () => {
|
|
mockEstimate.mockResolvedValue({
|
|
total_bytes: 200 * 1024 * 1024,
|
|
document_bytes: 195 * 1024 * 1024,
|
|
document_count: 200,
|
|
})
|
|
mockGenerate.mockResolvedValue(new ArrayBuffer(1024))
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/reports/full-archive', {
|
|
searchParams: { scope: 'all', include_documents: 'false' },
|
|
})
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(mockLogInfo).toHaveBeenCalledWith('full archive generated', expect.objectContaining({
|
|
filename: expect.stringMatching(/^arkiv_full_company-1_\d{8}\.zip$/),
|
|
sizeBytes: 1024,
|
|
}))
|
|
expect(response.headers.get('Content-Type')).toBe('application/zip')
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(mockGenerate).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
expect.objectContaining({ scope: 'all', include_documents: false })
|
|
)
|
|
})
|
|
|
|
it('defaults to scope=all when no params given', async () => {
|
|
mockEstimate.mockResolvedValue({
|
|
total_bytes: 1_000_000,
|
|
document_bytes: 500_000,
|
|
document_count: 2,
|
|
})
|
|
mockGenerate.mockResolvedValue(new ArrayBuffer(1024))
|
|
|
|
const response = await GET(createMockRequest('/api/reports/full-archive'))
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(mockGenerate).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
expect.objectContaining({ scope: 'all' })
|
|
)
|
|
})
|
|
|
|
it('uses scope=period when period_id is provided without explicit scope', async () => {
|
|
mockEstimate.mockResolvedValue({
|
|
total_bytes: 1_000_000,
|
|
document_bytes: 500_000,
|
|
document_count: 2,
|
|
})
|
|
mockGenerate.mockResolvedValue(new ArrayBuffer(1024))
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/reports/full-archive', {
|
|
searchParams: { period_id: 'period-1' },
|
|
})
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(mockGenerate).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
expect.objectContaining({ scope: 'period', period_id: 'period-1' })
|
|
)
|
|
})
|
|
|
|
it('returns 400 when scope=period without period_id', async () => {
|
|
const response = await GET(
|
|
createMockRequest('/api/reports/full-archive', {
|
|
searchParams: { scope: 'period' },
|
|
}),
|
|
)
|
|
const { status, body } = await parseJsonResponse(response)
|
|
expect(status).toBe(400)
|
|
expect(body).toEqual({ error: 'period_id is required when scope=period' })
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(mockGenerate).not.toHaveBeenCalled()
|
|
expect(mockEstimate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 404 when generate throws "not found"', async () => {
|
|
mockEstimate.mockResolvedValue({
|
|
total_bytes: 1_000_000,
|
|
document_bytes: 500_000,
|
|
document_count: 2,
|
|
})
|
|
mockGenerate.mockRejectedValue(new Error('Fiscal period not found'))
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/reports/full-archive', {
|
|
searchParams: { scope: 'period', period_id: 'nope' },
|
|
}),
|
|
)
|
|
const { status, body } = await parseJsonResponse(response)
|
|
expect(status).toBe(404)
|
|
expect(body).toEqual({ error: 'Något gick fel. Försök igen.' })
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
})
|