* feat(reminders): per-company reminder text overrides with per-field reset Add company_settings.reminder_text_overrides (JSONB, migration 20260830100000): optional subject/body per reminder level, storing only diffs from the defaults. Reminder templates now express their defaults as placeholder patterns and render stock and override mails through one substitution pipeline (placeholders, HTML escaping, subject sanitizing), so the settings prefill is exactly the sent mail. The level 3 default is strengthened into an explicit inkassovarning (8 days, handover to inkasso, costs per lag (1981:739)); text only, no fee or interest math changes. New ReminderEmailTextsSettings editor (per-level tabs, effective value prefilled, per-field reset, placeholder legend) mounted in the invoicing settings, strings in sv + en, and reminder_text_overrides added to UpdateSettingsSchema with schema and template tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * chore(migrations): bump reminder_text_overrides to 20260830120000 Main gained 20260830101500_seed_agent_atom_bodies after this branch cut its version, so the file moves to a fresh later timestamp to keep remote migration history append-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(reminders): serialize override saves and fix Swedish hint grammar CodeRabbit review: queue the whole-object PUTs in ReminderEmailTextsSettings so an older in-flight snapshot cannot replace a newer edit, and start the level 3 hint with "Den slutliga paminnelsen". The NOT VALID suggestion on the migration CHECK is declined: company_settings is one row per company, migration files run in a single transaction, and the invoice_email_texts precedent shipped the identical constraint shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
886 lines
24 KiB
TypeScript
886 lines
24 KiB
TypeScript
/**
|
|
* Shared test helpers: mock factories and fixture builders
|
|
*/
|
|
import { vi } from 'vitest'
|
|
import type {
|
|
Receipt,
|
|
Transaction,
|
|
FiscalPeriod,
|
|
JournalEntry,
|
|
JournalEntryLine,
|
|
DocumentAttachment,
|
|
TaxCode,
|
|
Invoice,
|
|
InvoicePayment,
|
|
Customer,
|
|
Supplier,
|
|
SupplierInvoice,
|
|
CompanySettings,
|
|
InvoiceInboxItem,
|
|
CategorizationTemplate,
|
|
Company,
|
|
CompanyMember,
|
|
} 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 makeCompany(overrides: Partial<Company> = {}): Company {
|
|
const { team_id = null, ...rest } = overrides
|
|
return {
|
|
id: 'company-1',
|
|
name: 'Test Company',
|
|
org_number: null,
|
|
entity_type: 'enskild_firma',
|
|
accounting_framework: 'k2',
|
|
created_by: 'user-1',
|
|
team_id,
|
|
archived_at: null,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
...rest,
|
|
}
|
|
}
|
|
|
|
export function makeCompanyMember(overrides: Partial<CompanyMember> = {}): CompanyMember {
|
|
return {
|
|
id: 'member-1',
|
|
company_id: 'company-1',
|
|
user_id: 'user-1',
|
|
role: 'owner',
|
|
invited_by: null,
|
|
joined_at: '2024-01-01T00:00:00Z',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
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 makeTaxCode(overrides: Partial<TaxCode> = {}): TaxCode {
|
|
return {
|
|
id: nextId(),
|
|
user_id: null,
|
|
code: 'MP1',
|
|
description: 'Utgående moms 25%',
|
|
rate: 25,
|
|
moms_basis_boxes: ['05'],
|
|
moms_tax_boxes: ['10'],
|
|
moms_input_boxes: [],
|
|
is_output_vat: true,
|
|
is_reverse_charge: false,
|
|
is_eu: false,
|
|
is_export: false,
|
|
is_oss: false,
|
|
is_system: true,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00: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 makeInvoicePayment(
|
|
overrides: Partial<InvoicePayment> = {}
|
|
): InvoicePayment {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
invoice_id: 'invoice-1',
|
|
payment_date: '2024-07-01',
|
|
amount: 12500,
|
|
currency: 'SEK',
|
|
exchange_rate: null,
|
|
exchange_rate_difference: 0,
|
|
journal_entry_id: null,
|
|
transaction_id: null,
|
|
notes: null,
|
|
created_at: '2024-07-01T00:00: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,
|
|
vat_registered: true,
|
|
vat_number: null,
|
|
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,
|
|
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',
|
|
},
|
|
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,
|
|
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,
|
|
}
|
|
}
|