0c854ac54f
* feat(settings): let a company name each verifikationsserie letter
The series pickers show a fixed preset label next to every letter (A
Redovisning ... M Momsrapport, Fortnox's layout). A byrå that lays its
series out differently sees a wrong or missing name in every dropdown: a
partner running löner on L saw "Kontantfaktura" in the verifikat form and
asked for the series name.
- company_settings.voucher_series_labels JSONB ({"L": "Lön"}), keys A-Z,
values 1 to 40 chars, CHECK on the JSON shape. Display only; the engine
never reads it.
- UpdateSettingsSchema validates the map, trims names and strips empty
values so a cleared field removes the name.
- voucherSeriesLabel(letter, labels) is the one place that decides what a
letter is called: company name, then preset, then empty.
buildVoucherSeriesOptions replaces the three near-identical option
builders in the verifikat form and the two settings pickers.
- The Verifikationsserier list in settings edits the names: rows are the
union of used, configured and named letters, one save button.
- The SIE import review's two series pickers show the name too.
Migration applied to staging (metjnjrhvujscngnpzdv) as 20260906131300.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* fix(settings): keep imported series in the list and unsaved names through a refetch
Skeptic pass on the series-name editor refuted two things:
- The rewritten list filtered voucher_sequences to single letters, dropping
multi-character series (FT, LB, SKV, ...) that 54 production companies
carry over from Fortnox and Bokio imports; the old list showed them with
their highest number. Rows are now every used series plus the configured
and named letters; only single-letter series get a name input, since
those are what the pickers offer and the schema accepts.
- The draft re-seeded on the identity of settings.voucher_series_labels,
and the settings hook revalidates on window focus with a fresh object, so
unsaved typing was wiped after any earlier save on the page. The re-seed
is now keyed on the serialized content of the saved names.
Also folds the "new series are created on first use" footnote back into
the group help, which the rewrite had dropped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* fix(settings): name the default-series options and enforce the label shape in the database
Review pass on #2336:
- CodeRabbit: the Standardserie selector under Bokföring still rendered
bare letters; it now shows the same name the other pickers do, through
voucherSeriesLabel.
- Compliance swarm (SOC 2 PI1.1, low): the key and length rules for
voucher_series_labels lived only in UpdateSettingsSchema. Migration
20260906134700 adds voucher_series_labels_valid(jsonb) and swaps the
object-only CHECK for one that mirrors the Zod rules (keys A-Z, values
non-blank strings of at most 40 characters), so a write that bypasses
/api/settings cannot store a map the pickers cannot handle. Applied to
staging with its schema_migrations row; verified against good, empty,
lowercase, blank, over-long, numeric and array inputs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* test(pg): cover the voucher_series_labels CHECK against real Postgres
The coverage gate refuses a migration that adds a function without a
*.pg.test.ts. voucher_series_labels_valid(jsonb) and the constraint that
wraps it now have one: accepts the empty map and single-letter keys with
names of 1 to 40 characters, rejects lowercase and multi-letter keys,
blank, over-long, numeric and null values, arrays and scalars, and leaves
the row untouched after a refused write.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
814 lines
22 KiB
TypeScript
814 lines
22 KiB
TypeScript
/**
|
|
* Shared test helpers: mock factories and fixture builders
|
|
*/
|
|
import { vi } from 'vitest'
|
|
import type {
|
|
Receipt,
|
|
Transaction,
|
|
FiscalPeriod,
|
|
JournalEntry,
|
|
JournalEntryLine,
|
|
DocumentAttachment,
|
|
Invoice,
|
|
Customer,
|
|
Supplier,
|
|
SupplierInvoice,
|
|
CompanySettings,
|
|
InvoiceInboxItem,
|
|
CategorizationTemplate,
|
|
} from '@/types'
|
|
import type { SIEVoucher, SIETransactionLine } from '@/lib/import/types'
|
|
|
|
// ============================================================
|
|
// Chainable Supabase mock
|
|
// ============================================================
|
|
|
|
/**
|
|
* Creates a deeply chainable mock that mirrors the Supabase client API.
|
|
*
|
|
* Usage:
|
|
* const { supabase, mockResult } = createMockSupabase()
|
|
* mockResult({ data: [...], error: null })
|
|
* const { data } = await supabase.from('table').select('*').eq('id', '1').single()
|
|
*/
|
|
export function createMockSupabase() {
|
|
// The value that terminal calls (.single(), .maybeSingle(), or the chain itself) resolve to
|
|
let pendingResult: { data: unknown; error: unknown; count?: number | null } = {
|
|
data: null,
|
|
error: null,
|
|
}
|
|
|
|
const mockResult = (result: {
|
|
data?: unknown
|
|
error?: unknown
|
|
count?: number | null
|
|
}) => {
|
|
pendingResult = {
|
|
data: result.data ?? null,
|
|
error: result.error ?? null,
|
|
count: result.count ?? null,
|
|
}
|
|
}
|
|
|
|
// Build a proxy that returns itself for any chained method call,
|
|
// and resolves to pendingResult when awaited.
|
|
const buildChain = (): unknown => {
|
|
const handler: ProxyHandler<object> = {
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
// Make the chain thenable: resolves to pendingResult
|
|
return (resolve: (v: unknown) => void) => resolve(pendingResult)
|
|
}
|
|
// Return a function that returns a new chain
|
|
return (..._args: unknown[]) => buildChain()
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
|
|
// Storage mock
|
|
const storageMock = {
|
|
from: vi.fn().mockReturnValue({
|
|
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
|
download: vi.fn().mockResolvedValue({
|
|
data: new Blob(['test']),
|
|
error: null,
|
|
}),
|
|
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
|
|
createSignedUrl: vi.fn().mockResolvedValue({
|
|
data: { signedUrl: 'https://example.com/signed' },
|
|
error: null,
|
|
}),
|
|
getPublicUrl: vi.fn().mockReturnValue({
|
|
data: { publicUrl: 'https://example.com/file.jpg' },
|
|
}),
|
|
}),
|
|
}
|
|
|
|
const supabase = {
|
|
from: vi.fn().mockImplementation(() => buildChain()),
|
|
rpc: vi.fn().mockImplementation(() => buildChain()),
|
|
storage: storageMock,
|
|
}
|
|
|
|
return { supabase, mockResult }
|
|
}
|
|
|
|
// ============================================================
|
|
// Fixture factories
|
|
// ============================================================
|
|
|
|
let _counter = 0
|
|
const nextId = () => `test-${++_counter}`
|
|
|
|
export function makeReceipt(overrides: Partial<Receipt> = {}): Receipt {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
image_url: 'https://example.com/receipt.jpg',
|
|
image_thumbnail_url: null,
|
|
status: 'confirmed',
|
|
extraction_confidence: 0.95,
|
|
merchant_name: 'ICA Maxi',
|
|
merchant_org_number: null,
|
|
merchant_vat_number: null,
|
|
receipt_date: '2024-06-15',
|
|
receipt_time: '14:30',
|
|
total_amount: 299.0,
|
|
currency: 'SEK',
|
|
vat_amount: 59.8,
|
|
is_restaurant: false,
|
|
is_systembolaget: false,
|
|
is_foreign_merchant: false,
|
|
representation_persons: null,
|
|
representation_purpose: null,
|
|
representation_business_connection: null,
|
|
source: 'upload',
|
|
email_from: null,
|
|
matched_transaction_id: null,
|
|
match_confidence: null,
|
|
raw_extraction: null,
|
|
created_at: '2024-06-15T14:30:00Z',
|
|
updated_at: '2024-06-15T14:30:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeTransaction(overrides: Partial<Transaction> = {}): Transaction {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
bank_connection_id: null,
|
|
cash_account_id: null,
|
|
external_id: null,
|
|
date: '2024-06-15',
|
|
description: 'ICA MAXI STOCKHOLM',
|
|
original_description: 'ICA MAXI STOCKHOLM',
|
|
title_edited_at: null,
|
|
amount: -299.0,
|
|
currency: 'SEK',
|
|
amount_sek: null,
|
|
exchange_rate: null,
|
|
exchange_rate_date: null,
|
|
category: 'uncategorized',
|
|
is_business: null,
|
|
invoice_id: null,
|
|
supplier_invoice_id: null,
|
|
potential_invoice_id: null,
|
|
potential_supplier_invoice_id: null,
|
|
journal_entry_id: null,
|
|
mcc_code: null,
|
|
merchant_name: 'ICA Maxi',
|
|
transaction_method: null,
|
|
bank_transaction_code: null,
|
|
proprietary_bank_transaction_code: null,
|
|
reconciliation_method: null,
|
|
is_ignored: false,
|
|
receipt_id: null,
|
|
document_id: null,
|
|
import_source: null,
|
|
reference: null,
|
|
counterparty_iban: null,
|
|
counterparty_account: null,
|
|
notes: null,
|
|
created_at: '2024-06-15T14:30:00Z',
|
|
updated_at: '2024-06-15T14:30:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeFiscalPeriod(overrides: Partial<FiscalPeriod> = {}): FiscalPeriod {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
name: 'FY 2024',
|
|
period_start: '2024-01-01',
|
|
period_end: '2024-12-31',
|
|
is_closed: false,
|
|
closed_at: null,
|
|
locked_at: null,
|
|
retention_expires_at: null,
|
|
opening_balances_set: false,
|
|
closing_entry_id: null,
|
|
opening_balance_entry_id: null,
|
|
previous_period_id: null,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeJournalEntry(overrides: Partial<JournalEntry> = {}): JournalEntry {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
fiscal_period_id: 'period-1',
|
|
voucher_number: 1,
|
|
voucher_series: 'A',
|
|
entry_date: '2024-06-15',
|
|
description: 'Test entry',
|
|
source_type: 'manual',
|
|
source_id: null,
|
|
source_voucher_series: null,
|
|
source_voucher_number: null,
|
|
status: 'posted',
|
|
committed_at: '2024-06-15T14:30:00Z',
|
|
reversed_by_id: null,
|
|
reverses_id: null,
|
|
correction_of_id: null,
|
|
attachment_urls: null,
|
|
notes: null,
|
|
commit_method: null,
|
|
committed_actor_type: null,
|
|
committed_actor_label: null,
|
|
rubric_version: null,
|
|
created_at: '2024-06-15T14:30:00Z',
|
|
updated_at: '2024-06-15T14:30:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeJournalEntryLine(
|
|
overrides: Partial<JournalEntryLine> = {}
|
|
): JournalEntryLine {
|
|
return {
|
|
id: nextId(),
|
|
journal_entry_id: 'entry-1',
|
|
account_number: '1930',
|
|
account_id: null,
|
|
debit_amount: 0,
|
|
credit_amount: 0,
|
|
currency: 'SEK',
|
|
amount_in_currency: null,
|
|
exchange_rate: null,
|
|
line_description: null,
|
|
tax_code: null,
|
|
cost_center: null,
|
|
project: null,
|
|
sort_order: 0,
|
|
created_at: '2024-06-15T14:30:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeDocumentAttachment(
|
|
overrides: Partial<DocumentAttachment> = {}
|
|
): DocumentAttachment {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
storage_path: 'documents/user-1/file.pdf',
|
|
file_name: 'file.pdf',
|
|
file_size_bytes: 1024,
|
|
mime_type: 'application/pdf',
|
|
sha256_hash: 'abc123',
|
|
version: 1,
|
|
original_id: null,
|
|
superseded_by_id: null,
|
|
is_current_version: true,
|
|
uploaded_by: 'user-1',
|
|
upload_source: 'file_upload',
|
|
digitization_date: '2024-06-15T14:30:00Z',
|
|
journal_entry_id: null,
|
|
journal_entry_line_id: null,
|
|
prev_version_hash: null,
|
|
last_integrity_check_at: null,
|
|
created_at: '2024-06-15T14:30:00Z',
|
|
updated_at: '2024-06-15T14:30:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeInvoice(overrides: Partial<Invoice> = {}): Invoice {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
customer_id: 'customer-1',
|
|
invoice_number: 'F-2024001',
|
|
invoice_date: '2024-06-15',
|
|
due_date: '2024-07-15',
|
|
delivery_date: null,
|
|
status: 'draft',
|
|
currency: 'SEK',
|
|
exchange_rate: null,
|
|
exchange_rate_date: null,
|
|
subtotal: 10000,
|
|
subtotal_sek: null,
|
|
vat_amount: 2500,
|
|
vat_amount_sek: null,
|
|
total: 12500,
|
|
total_sek: null,
|
|
ore_rounding: null,
|
|
vat_treatment: 'standard_25',
|
|
vat_rate: 25,
|
|
moms_ruta: '10',
|
|
your_reference: null,
|
|
our_reference: null,
|
|
notes: null,
|
|
reverse_charge_text: null,
|
|
credited_invoice_id: null,
|
|
document_type: 'invoice',
|
|
converted_from_id: null,
|
|
paid_at: null,
|
|
paid_amount: null,
|
|
remaining_amount: 12500,
|
|
created_at: '2024-06-15T14:30:00Z',
|
|
updated_at: '2024-06-15T14:30:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeCustomer(overrides: Partial<Customer> = {}): Customer {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
name: 'Test AB',
|
|
customer_type: 'swedish_business',
|
|
customer_number: null,
|
|
email: 'kontakt@test.se',
|
|
phone: null,
|
|
address_line1: 'Storgatan 1',
|
|
address_line2: null,
|
|
postal_code: '111 22',
|
|
city: 'Stockholm',
|
|
country: 'SE',
|
|
org_number: '5566778899',
|
|
vat_number: 'SE556677889901',
|
|
vat_number_validated: true,
|
|
vat_number_validated_at: '2024-01-01T00:00:00Z',
|
|
personal_number: null,
|
|
contact_person: null,
|
|
invoice_email_cc_addresses: null,
|
|
invoice_email_bcc_addresses: null,
|
|
language: 'sv',
|
|
default_payment_terms: 30,
|
|
notes: null,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeSupplier(overrides: Partial<Supplier> = {}): Supplier {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
name: 'Leverantör AB',
|
|
supplier_type: 'swedish_business',
|
|
email: 'info@leverantor.se',
|
|
phone: null,
|
|
address_line1: 'Industrivägen 5',
|
|
address_line2: null,
|
|
postal_code: '123 45',
|
|
city: 'Göteborg',
|
|
country: 'SE',
|
|
org_number: '5599887766',
|
|
vat_number: null,
|
|
bankgiro: '123-4567',
|
|
plusgiro: null,
|
|
bank_account: null,
|
|
iban: null,
|
|
bic: null,
|
|
clearing_number: null,
|
|
account_number: null,
|
|
default_expense_account: '6200',
|
|
default_payment_terms: 30,
|
|
default_currency: 'SEK',
|
|
notes: null,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeSupplierInvoice(
|
|
overrides: Partial<SupplierInvoice> = {}
|
|
): SupplierInvoice {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
supplier_id: 'supplier-1',
|
|
arrival_number: 1,
|
|
supplier_invoice_number: 'LF-001',
|
|
invoice_date: '2024-06-01',
|
|
due_date: '2024-07-01',
|
|
received_date: '2024-06-02',
|
|
delivery_date: null,
|
|
status: 'registered',
|
|
approved_at: null,
|
|
currency: 'SEK',
|
|
exchange_rate: null,
|
|
exchange_rate_date: null,
|
|
subtotal: 8000,
|
|
subtotal_sek: null,
|
|
vat_amount: 2000,
|
|
vat_amount_sek: null,
|
|
total: 10000,
|
|
total_sek: null,
|
|
ore_rounding: null,
|
|
vat_treatment: 'standard_25',
|
|
reverse_charge: false,
|
|
payment_reference: null,
|
|
paid_at: null,
|
|
paid_amount: 0,
|
|
remaining_amount: 10000,
|
|
is_credit_note: false,
|
|
credited_invoice_id: null,
|
|
registration_journal_entry_id: null,
|
|
payment_journal_entry_id: null,
|
|
transaction_id: null,
|
|
document_id: null,
|
|
paid_with_private_funds: false,
|
|
notes: null,
|
|
created_at: '2024-06-02T00:00:00Z',
|
|
updated_at: '2024-06-02T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeCompanySettings(
|
|
overrides: Partial<CompanySettings> = {}
|
|
): CompanySettings {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
entity_type: 'enskild_firma',
|
|
company_name: 'Test Firma',
|
|
default_our_reference: null,
|
|
org_number: '199001011234',
|
|
address_line1: 'Testgatan 1',
|
|
address_line2: null,
|
|
postal_code: '111 22',
|
|
city: 'Stockholm',
|
|
country: 'SE',
|
|
phone: null,
|
|
email: null,
|
|
website: null,
|
|
pays_salaries: false,
|
|
f_skatt: true,
|
|
// A coherent momsregistrerad company: registered implies a number on file
|
|
// (ML 17 kap. 24 §). Tests exercising the missing-number state override
|
|
// vat_number to null explicitly.
|
|
vat_registered: true,
|
|
vat_number: 'SE556012579001',
|
|
moms_period: 'quarterly',
|
|
periodisk_sammanstallning_period: 'quarterly',
|
|
vat_taxable_base_over_40m: false,
|
|
vat_has_eu_trade: false,
|
|
vat_filing_method: 'electronic',
|
|
periodisk_sammanstallning_enabled: false,
|
|
periodisk_sammanstallning_filing_method: 'electronic',
|
|
kontrolluppgifter_enabled: false,
|
|
rot_rut_enabled: false,
|
|
oss_enabled: false,
|
|
ioss_enabled: false,
|
|
intrastat_enabled: false,
|
|
punktskatt_enabled: false,
|
|
fyllnadsinbetalning_enabled: false,
|
|
tax_contact_name: null,
|
|
tax_contact_phone: null,
|
|
tax_contact_email: null,
|
|
fiscal_year_start_month: 1,
|
|
preliminary_tax_monthly: null,
|
|
bank_name: null,
|
|
clearing_number: null,
|
|
account_number: null,
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: null,
|
|
bic: null,
|
|
accounting_method: 'accrual',
|
|
invoice_prefix: 'F',
|
|
next_invoice_number: 1,
|
|
next_arrival_number: 1,
|
|
next_delivery_note_number: 1,
|
|
next_quote_number: 1,
|
|
invoice_default_days: 30,
|
|
invoice_default_notes: null,
|
|
bookkeeping_locked_through: null,
|
|
auto_lock_period_days: null,
|
|
default_voucher_series: 'A',
|
|
default_voucher_series_per_source_type: {
|
|
manual: 'A',
|
|
invoice_created: 'A',
|
|
invoice_paid: 'A',
|
|
invoice_cash_payment: 'A',
|
|
credit_note: 'A',
|
|
supplier_invoice_registered: 'A',
|
|
supplier_invoice_paid: 'A',
|
|
supplier_invoice_cash_payment: 'A',
|
|
supplier_invoice_privately_paid: 'A',
|
|
supplier_credit_note: 'A',
|
|
salary_payment: 'A',
|
|
bank_transaction: 'A',
|
|
reminder_fee: 'A',
|
|
opening_balance: 'A',
|
|
year_end: 'A',
|
|
currency_revaluation: 'A',
|
|
inbox_item: 'A',
|
|
import: 'A',
|
|
system: 'A',
|
|
storno: 'A',
|
|
correction: 'A',
|
|
},
|
|
voucher_series_labels: {},
|
|
last_supplier_payment_account: null,
|
|
ore_rounding: true,
|
|
invoice_show_ocr: true,
|
|
invoice_show_bankgiro: true,
|
|
invoice_show_plusgiro: true,
|
|
invoice_show_swish: true,
|
|
invoice_show_logo: true,
|
|
invoice_show_company_name: true,
|
|
invoice_company_name_position: 'header',
|
|
invoice_late_fee_text: null,
|
|
invoice_credit_terms_text: null,
|
|
invoice_primary_color: '#1a1a1a',
|
|
invoice_accent_color: '#666666',
|
|
invoice_font_family: 'Helvetica',
|
|
invoice_custom_font_path: null,
|
|
invoice_custom_font_name: null,
|
|
invoice_header_text: null,
|
|
invoice_footer_text: null,
|
|
invoice_email_texts: null,
|
|
invoice_payment_links_enabled: false,
|
|
send_invoice_reminders: true,
|
|
reminder_text_overrides: null,
|
|
reminder_days_level_1: 15,
|
|
reminder_days_level_2: 30,
|
|
reminder_days_level_3: 45,
|
|
reminder_fee_enabled: true,
|
|
reminder_fee_amount: 60,
|
|
reminder_interest_rate_override: null,
|
|
dimensions_enabled: false,
|
|
mileage_enabled: false,
|
|
sales_orders_enabled: false,
|
|
data_analysis_opt_in: false,
|
|
preferred_payment_format: 'pain001',
|
|
salary_pay_day: 25,
|
|
salary_default_bank: null,
|
|
salary_net_rounding: false,
|
|
logo_url: null,
|
|
onboarding_step: 6,
|
|
onboarding_complete: true,
|
|
sector_slug: null,
|
|
is_sandbox: false,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeInvoiceInboxItem(
|
|
overrides: Partial<InvoiceInboxItem> = {}
|
|
): InvoiceInboxItem {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
status: 'received',
|
|
source: 'upload',
|
|
email_from: null,
|
|
email_subject: null,
|
|
email_received_at: null,
|
|
email_body_text: null,
|
|
resend_email_id: null,
|
|
resend_attachment_id: null,
|
|
document_id: null,
|
|
extracted_data: null,
|
|
matched_supplier_id: null,
|
|
created_supplier_invoice_id: null,
|
|
matched_transaction_id: null,
|
|
created_journal_entry_id: null,
|
|
error_message: null,
|
|
raw_email_payload: null,
|
|
correlation_id: null,
|
|
created_at: '2024-06-15T14:30:00Z',
|
|
updated_at: '2024-06-15T14:30:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// API Route Test Helpers
|
|
// ============================================================
|
|
|
|
/**
|
|
* Build a Request object for testing Next.js API route handlers.
|
|
*/
|
|
export function createMockRequest(
|
|
url: string,
|
|
options?: {
|
|
method?: string
|
|
body?: unknown
|
|
searchParams?: Record<string, string>
|
|
/** Extra request headers, e.g. `cookie` for routes that read one. */
|
|
headers?: Record<string, string>
|
|
}
|
|
): Request {
|
|
const fullUrl = new URL(url, 'http://localhost:3000')
|
|
if (options?.searchParams) {
|
|
for (const [key, value] of Object.entries(options.searchParams)) {
|
|
fullUrl.searchParams.set(key, value)
|
|
}
|
|
}
|
|
return new Request(fullUrl.toString(), {
|
|
method: options?.method || 'GET',
|
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
|
...(options?.body ? { body: JSON.stringify(options.body) } : {}),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Parse NextResponse to {status, body}.
|
|
*/
|
|
export async function parseJsonResponse<T = unknown>(
|
|
response: Response
|
|
): Promise<{ status: number; body: T }> {
|
|
const body = (await response.json()) as T
|
|
return { status: response.status, body }
|
|
}
|
|
|
|
/**
|
|
* Build Promise-based params for Next.js 16 dynamic routes.
|
|
*/
|
|
export function createMockRouteParams<T extends Record<string, string>>(
|
|
params: T
|
|
): { params: Promise<T> } {
|
|
return { params: Promise.resolve(params) }
|
|
}
|
|
|
|
/**
|
|
* Queue-based Supabase mock for routes with multiple sequential DB calls.
|
|
*
|
|
* Each call to `.from()` or `.rpc()` consumes the next result in the queue.
|
|
*/
|
|
export function createQueuedMockSupabase() {
|
|
const queue: { data: unknown; error: unknown; count?: number | null }[] = []
|
|
|
|
const enqueue = (result: {
|
|
data?: unknown
|
|
error?: unknown
|
|
count?: number | null
|
|
}) => {
|
|
queue.push({
|
|
data: result.data ?? null,
|
|
error: result.error ?? null,
|
|
count: result.count ?? null,
|
|
})
|
|
}
|
|
|
|
const enqueueMany = (
|
|
results: { data?: unknown; error?: unknown; count?: number | null }[]
|
|
) => {
|
|
for (const r of results) {
|
|
enqueue(r)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every chained builder call, in order: { table, method, args }. The proxy
|
|
* otherwise swallows its arguments, so filters and update payloads were
|
|
* invisible to assertions. Recording is passive: it changes nothing about
|
|
* what a chain resolves to.
|
|
*/
|
|
const calls: { table: string; method: string; args: unknown[] }[] = []
|
|
|
|
/** Args of the first `method` call made against `table`, or undefined. */
|
|
const findCall = (table: string, method: string): unknown[] | undefined =>
|
|
calls.find((c) => c.table === table && c.method === method)?.args
|
|
|
|
/** Args of every `method` call made against `table`. */
|
|
const findCalls = (table: string, method: string): unknown[][] =>
|
|
calls.filter((c) => c.table === table && c.method === method).map((c) => c.args)
|
|
|
|
const reset = () => {
|
|
queue.length = 0
|
|
calls.length = 0
|
|
}
|
|
|
|
const buildChain = (table: string): unknown => {
|
|
// Capture the result at chain creation (when from/rpc is called)
|
|
const result = queue.shift() || { data: null, error: null, count: null }
|
|
|
|
const handler: ProxyHandler<object> = {
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) => resolve(result)
|
|
}
|
|
return (...args: unknown[]) => {
|
|
calls.push({ table, method: String(prop), args })
|
|
return buildChain2(table, result)
|
|
}
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
|
|
// Inner chain methods reuse the same result
|
|
const buildChain2 = (
|
|
table: string,
|
|
result: {
|
|
data: unknown
|
|
error: unknown
|
|
count?: number | null
|
|
},
|
|
): unknown => {
|
|
const handler: ProxyHandler<object> = {
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) => resolve(result)
|
|
}
|
|
return (...args: unknown[]) => {
|
|
calls.push({ table, method: String(prop), args })
|
|
return buildChain2(table, result)
|
|
}
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
|
|
const storageMock = {
|
|
from: vi.fn().mockReturnValue({
|
|
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
|
download: vi.fn().mockResolvedValue({
|
|
data: new Blob(['test']),
|
|
error: null,
|
|
}),
|
|
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
|
|
createSignedUrl: vi.fn().mockResolvedValue({
|
|
data: { signedUrl: 'https://example.com/signed' },
|
|
error: null,
|
|
}),
|
|
getPublicUrl: vi.fn().mockReturnValue({
|
|
data: { publicUrl: 'https://example.com/file.jpg' },
|
|
}),
|
|
}),
|
|
}
|
|
|
|
const supabase = {
|
|
from: vi.fn().mockImplementation((table: string) => buildChain(table)),
|
|
rpc: vi.fn().mockImplementation((fn: string) => buildChain(`rpc:${fn}`)),
|
|
storage: storageMock,
|
|
auth: {
|
|
getUser: vi.fn(),
|
|
},
|
|
}
|
|
|
|
return { supabase, enqueue, enqueueMany, reset, calls, findCall, findCalls }
|
|
}
|
|
|
|
export function makeCategorizationTemplate(
|
|
overrides: Partial<CategorizationTemplate> = {}
|
|
): CategorizationTemplate {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
counterparty_name: 'telia',
|
|
counterparty_aliases: ['telia sverige ab'],
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
vat_treatment: 'standard_25',
|
|
vat_account: '2641',
|
|
category: 'expense_telecom',
|
|
occurrence_count: 5,
|
|
confidence: 0.7,
|
|
last_seen_date: '2024-06-15',
|
|
source: 'user_approved',
|
|
line_pattern: null,
|
|
is_active: true,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-06-15T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeSIEVoucher(
|
|
overrides: Partial<Omit<SIEVoucher, 'lines'>> & { lines?: SIETransactionLine[] } = {}
|
|
): SIEVoucher {
|
|
return {
|
|
series: 'A',
|
|
number: 1,
|
|
date: new Date(2024, 5, 15), // June 15, 2024
|
|
description: 'Faktura',
|
|
lines: [
|
|
{ account: '1930', amount: -1000 },
|
|
{ account: '6200', amount: 800 },
|
|
{ account: '2641', amount: 200 },
|
|
],
|
|
...overrides,
|
|
}
|
|
}
|