* feat: add sandbox infrastructure — migration, types, and middleware Add database migration for sandbox support: - Add `is_sandbox` boolean column to company_settings - Update 4 enforcement trigger functions (journal entry immutability, journal entry line immutability, retention enforcement, document deletion blocking) to bypass checks for sandbox users - Add `cleanup_sandbox_user()` SECURITY DEFINER function that handles FK-safe deletion order (document_attachments → journal_entry_lines → journal_entries → supplier_invoices → auth.users cascade) - Add `cleanup_expired_sandbox_users()` function that loops over sandbox users older than N hours with per-user error handling Update TypeScript types: - Add `is_sandbox: boolean` to CompanySettings interface - Add `is_sandbox: false` to makeCompanySettings() test factory Update middleware: - Add `/sandbox` to public routes so the landing page is accessible without authentication Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add sandbox landing page, seed API, cleanup cron, and banner Sandbox landing page (app/sandbox/page.tsx): - Client component matching the existing auth page aesthetic - Auth check: if logged in as real user, shows message to use incognito - Otherwise shows feature overview (invoices, transactions, bookkeeping, reports) with "Starta sandbox" button - On click: signInAnonymously() → POST /api/sandbox/seed → redirect - Uses window.location.href for full page load (ensures middleware picks up new session cookies) Seed API (app/api/sandbox/seed/route.ts): - POST handler gated to anonymous users only (403 for real users) - Idempotent: returns { seeded: false } if company_settings exists - Seeds ~40 rows: profile, company_settings (is_sandbox: true, onboarding_complete: true), chart of accounts (via RPC), fiscal period, 3 customers (Swedish business, EU business, individual), 4 invoices (paid/sent/overdue/draft), 4 invoice items, 2 posted journal entries with 5 lines, 8 transactions (3 categorized, 2 income, 3 uncategorized), 2 deadlines - Journal entries inserted directly (not via engine) to avoid event emission, using next_voucher_number() RPC Cleanup cron (app/api/sandbox/cleanup/cron/route.ts): - GET handler with CRON_SECRET Bearer token auth - Creates service role Supabase client - Calls cleanup_expired_sandbox_users RPC (24h default) Sandbox banner (components/dashboard/SandboxBanner.tsx): - Amber bar with dismiss button (client state, reappears on reload) - Text: "Sandlådemiljö — dina data raderas automatiskt efter 24 timmar" - "Skapa konto" link to /register Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: integrate sandbox into dashboard — banner, nav, settings safeguards Dashboard layout (app/(dashboard)/layout.tsx): - Fetch is_sandbox from company_settings - Render SandboxBanner at top of page for sandbox users - Pass isSandbox prop to DashboardNav - Hide RecaptIdentify analytics for sandbox users Root page (app/page.tsx): - Same sandbox banner and isSandbox prop treatment as dashboard layout (root page has its own layout, not wrapped by (dashboard)/layout) DashboardNav (components/dashboard/DashboardNav.tsx): - Add optional isSandbox prop - Change logout button text to "Avsluta sandbox" when isSandbox - Redirect to /sandbox instead of /login on logout for sandbox users - Applied to both desktop sidebar and mobile drawer logout buttons Settings page (app/(dashboard)/settings/page.tsx): - Hide "Bank (PSD2)" tab entirely for sandbox users — prevents connecting real bank accounts from a temporary anonymous session - Hide "Radera konto" card for sandbox users — account auto-deletes via cron, and the delete flow requires email confirmation Vercel config (vercel.json): - Add sandbox cleanup cron at 04:00 UTC daily (/api/sandbox/cleanup/cron) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove audit trigger for non-existent tax_codes table Migration 018 referenced public.tax_codes which was never created (migration 012 is a placeholder). This caused failures when running migrations from scratch on a fresh database. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove ALTER FUNCTION for 3 non-existent functions Removed search_path pinning for create_invoice_with_items, seed_asset_categories, and update_reconciliation_session_counts — none of these functions were ever created in any migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove ALTER for generate_invoice_number (created in later migration) The function is created in migration 20260306 with search_path already set, but migration 20260304 tried to ALTER it before it existed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fixed redirect issue * Update app/api/sandbox/seed/route.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update app/api/sandbox/seed/route.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update app/sandbox/page.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Fixed catch block issue --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
641 lines
16 KiB
TypeScript
641 lines
16 KiB
TypeScript
/**
|
|
* Shared test helpers — mock factories and fixture builders
|
|
*/
|
|
import { vi } from 'vitest'
|
|
import type {
|
|
Receipt,
|
|
Transaction,
|
|
FiscalPeriod,
|
|
JournalEntry,
|
|
JournalEntryLine,
|
|
DocumentAttachment,
|
|
TaxCode,
|
|
Invoice,
|
|
Customer,
|
|
Supplier,
|
|
SupplierInvoice,
|
|
CompanySettings,
|
|
InvoiceInboxItem,
|
|
} from '@/types'
|
|
import type { ExtensionToggle } from '@/lib/extensions/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 }),
|
|
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',
|
|
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',
|
|
bank_connection_id: null,
|
|
external_id: null,
|
|
date: '2024-06-15',
|
|
description: 'ICA MAXI STOCKHOLM',
|
|
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',
|
|
reconciliation_method: null,
|
|
receipt_id: null,
|
|
import_source: null,
|
|
reference: 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',
|
|
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',
|
|
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,
|
|
status: 'posted',
|
|
committed_at: '2024-06-15T14:30:00Z',
|
|
reversed_by_id: null,
|
|
reverses_id: null,
|
|
correction_of_id: null,
|
|
attachment_urls: 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',
|
|
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',
|
|
customer_id: 'customer-1',
|
|
invoice_number: 'F-2024001',
|
|
invoice_date: '2024-06-15',
|
|
due_date: '2024-07-15',
|
|
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,
|
|
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,
|
|
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',
|
|
name: 'Test AB',
|
|
customer_type: 'swedish_business',
|
|
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',
|
|
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',
|
|
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,
|
|
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',
|
|
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',
|
|
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,
|
|
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,
|
|
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',
|
|
entity_type: 'enskild_firma',
|
|
company_name: 'Test Firma',
|
|
org_number: '199001011234',
|
|
address_line1: 'Testgatan 1',
|
|
address_line2: null,
|
|
postal_code: '111 22',
|
|
city: 'Stockholm',
|
|
country: 'SE',
|
|
phone: null,
|
|
email: null,
|
|
f_skatt: true,
|
|
vat_registered: true,
|
|
vat_number: null,
|
|
moms_period: 'quarterly',
|
|
fiscal_year_start_month: 1,
|
|
preliminary_tax_monthly: null,
|
|
bank_name: null,
|
|
clearing_number: null,
|
|
account_number: null,
|
|
iban: null,
|
|
bic: null,
|
|
accounting_method: 'accrual',
|
|
invoice_prefix: 'F',
|
|
next_invoice_number: 1,
|
|
next_delivery_note_number: 1,
|
|
invoice_default_days: 30,
|
|
invoice_default_notes: 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',
|
|
status: 'pending',
|
|
source: 'upload',
|
|
email_from: null,
|
|
email_subject: null,
|
|
email_received_at: null,
|
|
document_id: null,
|
|
extracted_data: null,
|
|
confidence: null,
|
|
matched_supplier_id: null,
|
|
created_supplier_invoice_id: null,
|
|
error_message: null,
|
|
document_type: 'supplier_invoice',
|
|
linked_receipt_id: null,
|
|
raw_email_payload: null,
|
|
suggested_template_id: null,
|
|
suggested_template_confidence: null,
|
|
matched_transaction_id: null,
|
|
match_confidence: null,
|
|
match_method: null,
|
|
created_at: '2024-06-15T14:30:00Z',
|
|
updated_at: '2024-06-15T14:30:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeExtensionToggle(
|
|
overrides: Partial<ExtensionToggle> = {}
|
|
): ExtensionToggle {
|
|
return {
|
|
id: nextId(),
|
|
user_id: 'user-1',
|
|
sector_slug: 'general',
|
|
extension_slug: 'receipt-ocr',
|
|
enabled: true,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00: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>
|
|
}
|
|
): 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?.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)
|
|
}
|
|
}
|
|
|
|
const reset = () => {
|
|
queue.length = 0
|
|
}
|
|
|
|
const buildChain = (): 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[]) => buildChain2(result)
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
|
|
// Inner chain methods reuse the same result
|
|
const buildChain2 = (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[]) => buildChain2(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 }),
|
|
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,
|
|
auth: {
|
|
getUser: vi.fn(),
|
|
},
|
|
}
|
|
|
|
return { supabase, enqueue, enqueueMany, reset }
|
|
}
|