Files
accounted/extensions/general/arcim-migration/lib/__tests__/complete-invoice-lines-count.test.ts
T
8313f527c9 fix(migration): complete-invoice-lines cron visits registers smallest first (#2341)
* fix(migration): complete-invoice-lines cron visits registers smallest first

The hourly pass ordered its work by consent recency, which says nothing
about work size: a 1 125-invoice register on the newest consent used two
runs in a row while a 384-invoice register three consents older was
skipped for budget both times. Each run now sizes every usable consent's
register on our side first (one indexed HEAD count of the non-draft
invoices without rows, no provider call) and then hands the registers
with anything left to the pass smallest first, each within its share of
the run. Shortest job first: a register that fits its share is done this
run whatever was accepted after it; the one that needs several runs takes
what is left of each. Nothing is stored between runs, and the budget
constants are unchanged.

What a run does not reach is by construction its largest registers; they
are logged and returned as `deferred` with their counts so a register
that is deferred hour after hour is visible. The count is proven against
a real PostgREST (tool-pg) because `invoice_items=is.null` on a to-many
embed is resolved there, not in Postgres or the type system.

Closes #2309

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* test(schema): teach the phantom-column guard PostgREST's embed-null filter

The static guard read `.is('invoice_items', null)` as a column of
`invoices` and failed CI on #2341. PostgREST's null filter on an embedded
resource (`?invoice_items=is.null`, the anti-join on a to-many embed:
the parents whose embed is empty) names the embed declared in the same
chain's select, not a column. The scanner already registers every embed
alias per chain for dotted filters; a bare name that is a registered
embed, used with `is` (or `not` / `filter` with the `is` operator, the
only operators that reach an embed), is now recognised and checked no
further. Any other operator on a bare embed name, and `is` on a name the
select never embedded, are still accused, with cases for both. The
grammar itself is proven on a real PostgREST by
complete-invoice-lines-count.tool.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:38:31 +02:00

75 lines
3.0 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* The count the cron sizes and orders its run by. It must be the pass's own
* predicate (non-draft sales invoices of this company with no rows) evaluated
* in the database as a HEAD count, so a company with thousands of complete
* invoices costs one indexed query and no rows. The grammar itself
* (`invoice_items=is.null` on a to-many embed) is proven against a real
* PostgREST in complete-invoice-lines-count.tool.test.ts; this file pins the
* query shape and the error contract.
*/
vi.mock('@/lib/providers/resolve-consent', () => ({ resolveConsent: vi.fn() }))
vi.mock('@/lib/providers/provider-data-fetcher', () => ({
fetchSalesInvoicesDirect: vi.fn(),
hydrateSalesInvoices: vi.fn(),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({ fetchAllRows: vi.fn() }))
import { countRowlessInvoices } from '../complete-invoice-lines'
interface Call { method: string; args: unknown[] }
/** Thenable query-builder stand-in that records the chain and answers with `response`. */
function makeSupabase(response: { count: number | null; error: { message: string } | null }) {
const calls: Call[] = []
const tables: string[] = []
const builder: Record<string, unknown> = {}
for (const method of ['select', 'eq', 'neq', 'is']) {
builder[method] = (...args: unknown[]) => {
calls.push({ method, args })
return builder
}
}
builder.then = (resolve: (v: unknown) => void, reject: (e: unknown) => void) =>
Promise.resolve(response).then(resolve, reject)
const from = vi.fn((table: string) => {
tables.push(table)
return builder
})
return { supabase: { from } as unknown as SupabaseClient, calls, tables }
}
describe('countRowlessInvoices', () => {
it('counts non-draft sales invoices with no rows in the database, loading none of them', async () => {
const { supabase, calls, tables } = makeSupabase({ count: 155, error: null })
await expect(countRowlessInvoices(supabase, 'co-1')).resolves.toBe(155)
expect(tables).toEqual(['invoices'])
expect(calls).toEqual([
{ method: 'select', args: ['id, invoice_items(id)', { count: 'exact', head: true }] },
{ method: 'eq', args: ['company_id', 'co-1'] },
{ method: 'eq', args: ['document_type', 'invoice'] },
{ method: 'neq', args: ['status', 'draft'] },
{ method: 'is', args: ['invoice_items', null] },
])
})
it('throws on a failed count rather than reporting the company as done', async () => {
const { supabase } = makeSupabase({ count: null, error: { message: 'canceling statement due to statement timeout' } })
await expect(countRowlessInvoices(supabase, 'co-1')).rejects.toThrow(
'invoices count failed: canceling statement due to statement timeout',
)
})
it('reads a missing count as nothing to do', async () => {
const { supabase } = makeSupabase({ count: null, error: null })
await expect(countRowlessInvoices(supabase, 'co-1')).resolves.toBe(0)
})
})