import { describe, it, expect, vi } from 'vitest' import type { SupabaseClient } from '@supabase/supabase-js' import { replaceInvoiceItems } from '../replace-invoice-items' import type { InvoiceWriteItemRow } from '@/lib/invoices/build-invoice-write' /** * Delete + insert over PostgREST is not atomic: an insert failure after a * successful delete used to leave the draft with ZERO items (the user's line * content gone). These tests pin the snapshot/restore added for that: the * pre-delete rows are best-effort reinserted and the result says whether the * restore worked. */ function makeItem(overrides: Partial = {}): InvoiceWriteItemRow { return { sort_order: 0, line_type: 'product', description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250, article_id: null, revenue_account: null, deduction_type: null, deduction_amount: 0, labor_hours: null, work_type: null, housing_designation: null, apartment_number: null, brf_org_number: null, accrual_period_start: null, accrual_period_end: null, accrual_balance_account: null, dimensions: {}, ...overrides, } } /** A stored invoice_items row as SELECT * returns it (server columns included). */ function storedRow(overrides: Record = {}): Record { return { id: 'item-old-1', invoice_id: 'inv-1', created_at: '2026-07-01T00:00:00Z', sort_order: 0, line_type: 'product', description: 'Gammal rad', quantity: 2, unit: 'st', unit_price: 500, line_total: 1000, vat_rate: 25, vat_amount: 250, article_id: null, revenue_account: null, ...overrides, } } function createHarness(opts: { snapshot?: unknown[] | null snapshotError?: unknown deleteError?: unknown /** Error returned by the n:th insert call (index 0 = the replace insert). */ insertErrors?: (unknown | null)[] }) { const inserts: Record[][] = [] let insertCall = 0 const supabase = { from: vi.fn(() => ({ select: vi.fn(() => ({ eq: vi.fn(() => Promise.resolve({ data: opts.snapshot ?? [], error: opts.snapshotError ?? null }), ), })), delete: vi.fn(() => ({ eq: vi.fn(() => Promise.resolve({ error: opts.deleteError ?? null })), })), insert: vi.fn((rows: Record[]) => { inserts.push(rows) const error = opts.insertErrors?.[insertCall] ?? null insertCall += 1 return Promise.resolve({ error }) }), })), } return { supabase: supabase as unknown as SupabaseClient, inserts } } const insertBoom = { message: 'insert boom', code: '23502' } describe('replaceInvoiceItems', () => { it('replaces the rows and stamps invoice_id on the happy path', async () => { const { supabase, inserts } = createHarness({ snapshot: [storedRow()] }) const result = await replaceInvoiceItems(supabase, 'inv-1', [ makeItem({ description: 'Ny rad' }), ]) expect(result).toEqual({ ok: true }) expect(inserts).toHaveLength(1) expect(inserts[0][0]).toMatchObject({ description: 'Ny rad', invoice_id: 'inv-1' }) }) it('restores the snapshotted rows when the insert fails', async () => { const { supabase, inserts } = createHarness({ snapshot: [storedRow(), storedRow({ id: 'item-old-2', sort_order: 1, description: 'Rad 2' })], insertErrors: [insertBoom, null], }) const result = await replaceInvoiceItems(supabase, 'inv-1', [makeItem()]) expect(result).toEqual({ ok: false, stage: 'insert', error: insertBoom, restored: true, }) // Two inserts: the failed replace, then the restore of the snapshot. expect(inserts).toHaveLength(2) const restore = inserts[1] expect(restore).toHaveLength(2) expect(restore.map((r) => r.description)).toEqual(['Gammal rad', 'Rad 2']) // Server-generated columns stripped, invoice_id re-stamped. for (const row of restore) { expect(row.id).toBeUndefined() expect(row.created_at).toBeUndefined() expect(row.invoice_id).toBe('inv-1') } }) it('reports restored: false when the restore insert also fails', async () => { const { supabase, inserts } = createHarness({ snapshot: [storedRow()], insertErrors: [insertBoom, { message: 'restore boom' }], }) const result = await replaceInvoiceItems(supabase, 'inv-1', [makeItem()]) expect(result).toMatchObject({ ok: false, stage: 'insert', restored: false }) expect(inserts).toHaveLength(2) }) it('reports restored: false when the snapshot itself could not be read', async () => { // With no snapshot there is nothing to reinsert, and claiming the draft is // intact would be a guess: the delete may well have removed real rows. const { supabase, inserts } = createHarness({ snapshot: null, snapshotError: { message: 'select boom' }, insertErrors: [insertBoom], }) const result = await replaceInvoiceItems(supabase, 'inv-1', [makeItem()]) expect(result).toMatchObject({ ok: false, stage: 'insert', restored: false }) expect(inserts).toHaveLength(1) }) it('reports restored: true when the draft had no items to begin with', async () => { const { supabase, inserts } = createHarness({ snapshot: [], insertErrors: [insertBoom], }) const result = await replaceInvoiceItems(supabase, 'inv-1', [makeItem()]) expect(result).toMatchObject({ ok: false, stage: 'insert', restored: true }) // No restore insert: the prior state (empty) already holds. expect(inserts).toHaveLength(1) }) it('stops at the delete stage without touching inserts when the delete fails', async () => { const deleteBoom = { message: 'delete boom' } const { supabase, inserts } = createHarness({ snapshot: [storedRow()], deleteError: deleteBoom, }) const result = await replaceInvoiceItems(supabase, 'inv-1', [makeItem()]) expect(result).toEqual({ ok: false, stage: 'delete', error: deleteBoom }) expect(inserts).toHaveLength(0) }) })