* 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>
75 lines
3.0 KiB
TypeScript
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)
|
|
})
|
|
})
|