Files
accounted/lib/reports/__tests__/full-archive-export.test.ts
T
c0b74d50e4 Fix/original sie export (#279)
* feat: cloud backup to Google Drive + full-archive all-scope

Adds a cloud-backup extension that uploads a full-company backup ZIP to
the user's own Google Drive via OAuth (drive.file scope only). Refresh
tokens are AES-256-GCM encrypted before being stored in extension_data.

The full-archive export gains a scope=all mode for whole-company
backups (per-period SIE under sie/, per-period rapporter/ subfolders,
flat dokument/ manifest tagged with fiscal_period_id). An 80 MB size
guard short-circuits generation before the platform response limit.

Also fixes a latent bug in lib/core/audit/audit-service.ts where the
parameter was named userId while the query filtered by company_id; the
audit-trail API route was passing user.id so audit queries returned
empty unless user and company shared a UUID.

Drive-by: scope the dashboard "fresh start" localStorage key per
companyId so dismissing the setup checklist in one company no longer
carries over to others.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review comments on cloud backup + archive export

- Extend audit trail to_date to end-of-day so last-day entries aren't
  silently excluded from period-scoped archives.
- Apply 413 size-limit guard regardless of include_documents, using the
  overhead-only figure when documents are excluded.
- Use crypto.randomUUID() for Drive multipart boundary to eliminate any
  collision risk with ZIP payload bytes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: migrate legacy setup-gate localStorage keys on dashboard

Users who previously dismissed the setup checklist via the old global
erp_setup_fresh_start or erp_checklist_dismissed keys were re-gated after
the switch to a company-scoped key. Fall back to the legacy keys on read
and migrate them to the scoped key on first hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: update customer email handling and anonymization rules in supportmail-to-ticket skill

* test: update audit trail to_date expectation for end-of-day timestamp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: enhance full archive generation to include SIE source files and master data dumps

* Update lib/reports/full-archive-export.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-20 12:33:58 +02:00

467 lines
16 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import JSZip from 'jszip'
import { generateFullArchive, estimateArchiveSize } from '../full-archive-export'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { getAuditLog } from '@/lib/core/audit/audit-service'
vi.mock('../sie-export', () => ({
generateSIEExport: vi.fn().mockResolvedValue('#FLAGGA 0\n#PROGRAM "ERPBase"'),
}))
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn().mockResolvedValue({
rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true,
}),
}))
vi.mock('../income-statement', () => ({
generateIncomeStatement: vi.fn().mockResolvedValue({
sections: [], netResult: 0, period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../balance-sheet', () => ({
generateBalanceSheet: vi.fn().mockResolvedValue({
asset_sections: [], equity_liability_sections: [],
total_assets: 0, total_equity_liabilities: 0,
period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../general-ledger', () => ({
generateGeneralLedger: vi.fn().mockResolvedValue({
accounts: [], period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../journal-register', () => ({
generateJournalRegister: vi.fn().mockResolvedValue({
entries: [], total_entries: 0, total_debit: 0, total_credit: 0,
period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../vat-declaration', () => ({
calculateVatDeclaration: vi.fn().mockResolvedValue({
period: { type: 'yearly', year: 2024, period: 1, start: '2024-01-01', end: '2024-12-31' },
rutor: {
ruta05: 0, ruta06: 0, ruta07: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta39: 0, ruta40: 0, ruta48: 0, ruta49: 0,
},
invoiceCount: 0, transactionCount: 0,
breakdown: {
invoices: { ruta05: 0, ruta06: 0, ruta07: 0, ruta10: 0, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 0, base12: 0, base6: 0 },
transactions: { ruta48: 0 },
receipts: { ruta48: 0 },
},
}),
}))
vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn().mockResolvedValue({ data: [], count: 0 }),
}))
const mockGetAuditLog = vi.mocked(getAuditLog)
const COMPANY_ROW = {
company_name: 'Test AB',
trade_name: null,
org_number: '5566778899',
moms_period: 'quarterly',
}
const PERIOD_2024 = {
id: 'period-2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
opening_balance_entry_id: null,
}
const PERIOD_2023 = {
id: 'period-2023',
period_start: '2023-01-01',
period_end: '2023-12-31',
opening_balance_entry_id: null,
}
describe('generateFullArchive', () => {
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
beforeEach(() => {
vi.clearAllMocks()
mockGetAuditLog.mockResolvedValue({ data: [], count: 0 })
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
})
describe('scope: period', () => {
it('generates a ZIP with expected file structure', async () => {
enqueueMany([
{ data: COMPANY_ROW }, // company_settings
{ data: PERIOD_2024 }, // fiscal_periods (single)
{ data: [] }, // document_attachments
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('bokforing.se')).not.toBeNull()
expect(zip.file('rapporter/saldobalans.json')).not.toBeNull()
expect(zip.file('rapporter/resultatrakning.json')).not.toBeNull()
expect(zip.file('rapporter/balansrakning.json')).not.toBeNull()
expect(zip.file('rapporter/huvudbok.json')).not.toBeNull()
expect(zip.file('rapporter/grundbok.json')).not.toBeNull()
expect(zip.file('rapporter/momsdeklaration.json')).not.toBeNull()
expect(zip.file('dokument/manifest.json')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
expect(zip.file('revision/systemdokumentation.json')).not.toBeNull()
})
it('handles missing documents gracefully', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: PERIOD_2024 },
{
data: [
{
id: 'doc-1',
file_name: 'receipt.pdf',
storage_path: 'documents/user-1/receipt.pdf',
journal_entry_id: 'entry-1',
},
],
},
{ data: [{ id: 'entry-1', fiscal_period_id: PERIOD_2024.id }] },
])
supabase.storage.from = vi.fn().mockReturnValue({
download: vi.fn().mockResolvedValue({
data: null,
error: { message: 'File not found' },
}),
})
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
})
const zip = await JSZip.loadAsync(buffer)
const manifestFile = zip.file('dokument/manifest.json')
expect(manifestFile).not.toBeNull()
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest).toHaveLength(1)
expect(manifest[0].status).toBe('error')
expect(manifest[0].error).toBe('File not found')
expect(manifest[0].fiscal_period_id).toBe(PERIOD_2024.id)
})
it('skips documents when include_documents is false', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: PERIOD_2024 },
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
include_documents: false,
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('dokument/manifest.json')).toBeNull()
expect(zip.file('bokforing.se')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
})
it('throws when fiscal period not found', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: null },
])
await expect(
generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: 'nonexistent',
})
).rejects.toThrow('Fiscal period not found')
})
it('filters audit trail by period dates', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: PERIOD_2024 },
{ data: [] },
])
await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
})
expect(mockGetAuditLog).toHaveBeenCalledWith(
expect.anything(),
'company-1',
expect.objectContaining({
from_date: PERIOD_2024.period_start,
to_date: `${PERIOD_2024.period_end}T23:59:59.999Z`,
})
)
})
})
describe('scope: all', () => {
it('generates per-period SIE files and report subfolders', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2023, PERIOD_2024] }, // fiscal_periods (list for fetchAllPeriods)
{ data: [] }, // document_attachments
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('sie/2023-01-01_2023-12-31.se')).not.toBeNull()
expect(zip.file('sie/2024-01-01_2024-12-31.se')).not.toBeNull()
expect(zip.file('rapporter/2023-01-01_2023-12-31/saldobalans.json')).not.toBeNull()
expect(zip.file('rapporter/2024-01-01_2024-12-31/saldobalans.json')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
expect(zip.file('revision/systemdokumentation.json')).not.toBeNull()
// No root bokforing.se in all-mode
expect(zip.file('bokforing.se')).toBeNull()
})
it('does not filter audit trail by date in all-mode', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{ data: [] },
])
await generateFullArchive(supabase as any, 'company-1', { scope: 'all' })
const call = mockGetAuditLog.mock.calls[0]
expect(call[2]).not.toHaveProperty('from_date')
expect(call[2]).not.toHaveProperty('to_date')
})
it('tags each document with its fiscal_period_id across periods', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2023, PERIOD_2024] },
{
data: [
{ id: 'doc-2023', file_name: 'r23.pdf', storage_path: 'p/r23.pdf', journal_entry_id: 'e-2023' },
{ id: 'doc-2024', file_name: 'r24.pdf', storage_path: 'p/r24.pdf', journal_entry_id: 'e-2024' },
],
},
{
data: [
{ id: 'e-2023', fiscal_period_id: PERIOD_2023.id },
{ id: 'e-2024', fiscal_period_id: PERIOD_2024.id },
],
},
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const manifestFile = zip.file('dokument/manifest.json')
expect(manifestFile).not.toBeNull()
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest).toHaveLength(2)
const byId = Object.fromEntries(
(manifest as Array<{ document_id: string; fiscal_period_id: string | null }>).map((m) => [
m.document_id,
m.fiscal_period_id,
])
)
expect(byId['doc-2023']).toBe(PERIOD_2023.id)
expect(byId['doc-2024']).toBe(PERIOD_2024.id)
})
it('throws when no fiscal periods exist', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [] },
])
await expect(
generateFullArchive(supabase as any, 'company-1', { scope: 'all' })
).rejects.toThrow('No fiscal periods found')
})
it('includes imported SIE source files and master-data dumps in all-mode', async () => {
const importRow = {
id: 'import-1',
filename: 'original.se',
file_hash: 'abc123',
file_storage_path: 'company-1/import-1.se',
org_number: '5560000000',
company_name: 'Test AB',
sie_type: 4,
fiscal_year_start: '2024-01-01',
fiscal_year_end: '2024-12-31',
accounts_count: 42,
transactions_count: 120,
status: 'completed',
fiscal_period_id: PERIOD_2024.id,
imported_at: '2024-11-01T10:00:00Z',
created_at: '2024-11-01T09:55:00Z',
}
enqueueMany([
{ data: COMPANY_ROW }, // fetchCompany
{ data: [PERIOD_2024] }, // fetchAllPeriods
{ data: [] }, // document_attachments
{ data: [importRow] }, // sie_imports
{ data: [{ source_account: '9999', target_account: '1510' }] }, // sie_account_mappings
{ data: [{ id: 'cust-1', name: 'Acme AB' }] }, // customers
{ data: [{ id: 'sup-1', name: 'Supplier AB' }] }, // suppliers
{ data: [{ id: 'inv-1', invoice_number: 'F-001' }] }, // invoices
{ data: [] }, // invoice_items
{ data: [] }, // invoice_payments
{ data: [] }, // supplier_invoices
{ data: [] }, // supplier_invoice_items
{ data: [] }, // receipts
{ data: [] }, // receipt_line_items
{ data: [] }, // transactions
{ data: [] }, // mapping_rules
{ data: [] }, // categorization_templates
{ data: [] }, // bank_file_imports
{ data: [COMPANY_ROW] }, // company_settings
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const originalFile = zip.file('sie/original/import-1_original.se')
expect(originalFile).not.toBeNull()
const manifestFile = zip.file('sie/original/manifest.json')
expect(manifestFile).not.toBeNull()
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest[0].import_id).toBe('import-1')
expect(manifest[0].status).toBe('downloaded')
const imports = JSON.parse(await zip.file('sie/imports.json')!.async('text'))
expect(imports[0].filename).toBe('original.se')
expect(zip.file('sie/account_mappings.json')).not.toBeNull()
expect(zip.file('data/customers.json')).not.toBeNull()
expect(zip.file('data/suppliers.json')).not.toBeNull()
expect(zip.file('data/invoices.json')).not.toBeNull()
expect(zip.file('data/invoice_items.json')).not.toBeNull()
expect(zip.file('data/invoice_payments.json')).not.toBeNull()
expect(zip.file('data/supplier_invoices.json')).not.toBeNull()
expect(zip.file('data/supplier_invoice_items.json')).not.toBeNull()
expect(zip.file('data/receipts.json')).not.toBeNull()
expect(zip.file('data/receipt_line_items.json')).not.toBeNull()
expect(zip.file('data/transactions.json')).not.toBeNull()
expect(zip.file('data/mapping_rules.json')).not.toBeNull()
expect(zip.file('data/categorization_templates.json')).not.toBeNull()
expect(zip.file('data/bank_file_imports.json')).not.toBeNull()
expect(zip.file('data/company_settings.json')).not.toBeNull()
const customers = JSON.parse(await zip.file('data/customers.json')!.async('text'))
expect(customers).toEqual([{ id: 'cust-1', name: 'Acme AB' }])
})
it('skips raw SIE blobs when include_documents is false but keeps metadata', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{
data: [
{
id: 'import-1',
filename: 'x.se',
file_hash: 'h',
file_storage_path: 'company-1/import-1.se',
status: 'completed',
imported_at: '2024-11-01T10:00:00Z',
created_at: '2024-11-01T09:55:00Z',
},
],
}, // sie_imports
{ data: [] }, // sie_account_mappings
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
include_documents: false,
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('sie/imports.json')).not.toBeNull()
expect(zip.file('sie/account_mappings.json')).not.toBeNull()
expect(zip.file('sie/original/import-1_x.se')).toBeNull()
expect(zip.file('sie/original/manifest.json')).toBeNull()
expect(zip.file('data/customers.json')).not.toBeNull()
})
})
})
describe('estimateArchiveSize', () => {
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
beforeEach(() => {
vi.clearAllMocks()
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
})
it('sums document file_size_bytes in all-mode plus overhead', async () => {
enqueueMany([
{
data: [
{ file_size_bytes: 1_000_000, journal_entry_id: 'e1' },
{ file_size_bytes: 2_500_000, journal_entry_id: 'e2' },
],
count: 2,
},
])
const result = await estimateArchiveSize(supabase as any, 'company-1', 'all')
expect(result.document_bytes).toBe(3_500_000)
expect(result.document_count).toBe(2)
// overhead is +8 MB
expect(result.total_bytes).toBe(3_500_000 + 8 * 1024 * 1024)
})
it('returns overhead only when no documents in scope', async () => {
enqueueMany([
{ data: [], count: 0 }, // journal_entries for periodEntryIds
{ data: [], count: 0 }, // document_attachments
])
const result = await estimateArchiveSize(supabase as any, 'company-1', 'period', 'p-1')
expect(result.document_bytes).toBe(0)
expect(result.document_count).toBe(0)
expect(result.total_bytes).toBe(8 * 1024 * 1024)
})
})