* fix(providers): paginate Visma eAccounting with $page/$pagesize eAccounting silently ignores OData $top/$skip, so every request returned page 1 and getPaginated appended the first page TotalNumberOfPages times: customers were imported in triplicate and invoice chunks hit unique violations. Also stop on an empty page so a stale Meta can never loop or duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migration): survive bad rows in entity imports instead of failing whole chunks One PostgREST insert per 500-row chunk is all-or-nothing, so a single duplicate reported every row as failed ('300 misslyckades') with no cause shown. Now: dedupe repeats within the fetched data (paging faults, source duplicates), fall back to per-row inserts when a chunk is rejected, store empty invoice numbers as NULL instead of colliding '', surface the first DB error in the result UI, and mark all-failed steps with an error icon. Sales invoices also carry remaining_amount so open invoices no longer land as settled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migration): never per-row retry after a successful bulk insert with short read-back A succeeded statement whose .select() returns fewer rows than sent means the rows ARE in the table; retrying them one by one would duplicate every unreturned row. Pair what came back and report the tail instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migration): count stub-insert casualties as failed and sample enrichment errors Review follow-ups: invoices dropped because their customer/supplier stub insert errored are DB failures, not matching misses; classifying them as noMatch rendered a green result row with the database error hidden. Enrichment failures now also feed errorSample. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
84 lines
3.3 KiB
TypeScript
84 lines
3.3 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { insertWithPerRowFallback } from '../insert-fallback'
|
|
|
|
/**
|
|
* The chunked migration inserts are one PostgREST statement per chunk, so a
|
|
* single bad row used to reject the whole chunk and report every row as
|
|
* failed ("300 misslyckades"). The fallback must keep the bulk fast path,
|
|
* retry per row only after a bulk failure, and attribute results by index.
|
|
*/
|
|
|
|
type InsertResult = { data: Record<string, unknown>[] | null; error: { message: string } | null }
|
|
|
|
/** Minimal supabase stand-in: scripted responses per insert call, in order. */
|
|
function makeSupabase(script: InsertResult[]) {
|
|
let call = 0
|
|
const inserted: unknown[] = []
|
|
const from = vi.fn(() => ({
|
|
insert: (rows: unknown) => {
|
|
inserted.push(rows)
|
|
const result = script[Math.min(call, script.length - 1)]
|
|
call++
|
|
return { select: () => Promise.resolve(result) }
|
|
},
|
|
}))
|
|
return { supabase: { from } as unknown as SupabaseClient, inserted, calls: () => call }
|
|
}
|
|
|
|
const row = (n: number) => ({ invoice_number: `F-${n}` })
|
|
|
|
describe('insertWithPerRowFallback', () => {
|
|
it('bulk success: one statement, results paired by index', async () => {
|
|
const { supabase, calls } = makeSupabase([
|
|
{ data: [{ id: 'a' }, { id: 'b' }], error: null },
|
|
])
|
|
|
|
const outcome = await insertWithPerRowFallback(supabase, 'invoices', [row(1), row(2)], 'id')
|
|
|
|
expect(calls()).toBe(1)
|
|
expect(outcome.failedCount).toBe(0)
|
|
expect(outcome.firstError).toBeNull()
|
|
expect(outcome.returned).toEqual([{ id: 'a' }, { id: 'b' }])
|
|
})
|
|
|
|
it('bulk failure: retries per row, healthy rows land, offender is counted with its message', async () => {
|
|
const dup = 'duplicate key value violates unique constraint "idx_invoices_company_invoice_number"'
|
|
const { supabase, calls } = makeSupabase([
|
|
{ data: null, error: { message: dup } }, // bulk statement
|
|
{ data: [{ id: 'a' }], error: null }, // row 0
|
|
{ data: null, error: { message: dup } }, // row 1 (the offender)
|
|
{ data: [{ id: 'c' }], error: null }, // row 2
|
|
])
|
|
|
|
const outcome = await insertWithPerRowFallback(supabase, 'invoices', [row(1), row(2), row(3)], 'id')
|
|
|
|
expect(calls()).toBe(4)
|
|
expect(outcome.returned).toEqual([{ id: 'a' }, null, { id: 'c' }])
|
|
expect(outcome.failedCount).toBe(1)
|
|
expect(outcome.firstError).toBe(dup)
|
|
})
|
|
|
|
it('bulk success with short read-back: never retries (rows are already in the table)', async () => {
|
|
const { supabase, calls } = makeSupabase([
|
|
{ data: [{ id: 'a' }], error: null }, // succeeded, but returned 1 of 2
|
|
])
|
|
|
|
const outcome = await insertWithPerRowFallback(supabase, 'customers', [row(1), row(2)], 'id')
|
|
|
|
expect(calls()).toBe(1)
|
|
expect(outcome.returned).toEqual([{ id: 'a' }, null])
|
|
expect(outcome.failedCount).toBe(1)
|
|
expect(outcome.firstError).toMatch(/could not be paired/)
|
|
})
|
|
|
|
it('empty input: no statements at all', async () => {
|
|
const { supabase, calls } = makeSupabase([{ data: [], error: null }])
|
|
|
|
const outcome = await insertWithPerRowFallback(supabase, 'invoices', [], 'id')
|
|
|
|
expect(calls()).toBe(0)
|
|
expect(outcome).toEqual({ returned: [], failedCount: 0, firstError: null })
|
|
})
|
|
})
|