Files
accounted/extensions/general/arcim-migration/lib/__tests__/complete-invoice-lines-count.tool.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

95 lines
4.0 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, it, expect, beforeAll } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
import { createToolPgClient } from '@/tests/tool-pg/client'
import { countRowlessInvoices } from '../complete-invoice-lines'
/**
* `invoice_items=is.null` on a to-many embed is resolved by PostgREST, not by
* Postgres and not by the type system, and a HEAD count on top of it is one
* more thing a mocked client answers whatever the grammar means. The cron
* skips every register this count reports as empty, so a count that came
* back 0 for everyone would switch the whole cron off, and a count that
* ignored the embed filter would size registers by their whole history. Both
* pass unit tests. This asserts on the real thing that the count is the
* anti-join the pass's own `loadCandidates` evaluates client-side.
*/
async function insertInvoice(input: {
userId: string
companyId: string
status?: string
documentType?: string
rows?: number
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, invoice_number, invoice_date, due_date, status, document_type, total)
VALUES ($1, $2, $3, $4, '2026-03-14', '2026-04-13', $5, $6, 1250)`,
[id, input.userId, input.companyId, `F-${randomUUID()}`, input.status ?? 'sent', input.documentType ?? 'invoice'],
)
for (let i = 0; i < (input.rows ?? 0); i++) {
await getPool().query(
`INSERT INTO public.invoice_items (invoice_id, sort_order, description, quantity, unit_price, line_total)
VALUES ($1, $2, 'Konsulttid', 1, 1000, 1000)`,
[id, i + 1],
)
}
return id
}
describe('countRowlessInvoices against a real PostgREST', () => {
let client: ReturnType<typeof createToolPgClient>
let companyId: string
let otherCompanyId: string
beforeAll(async () => {
client = createToolPgClient()
const a = await seedCompany()
const b = await seedCompany()
companyId = a.companyId
otherCompanyId = b.companyId
// Three the pass would complete...
await insertInvoice({ userId: a.userId, companyId })
await insertInvoice({ userId: a.userId, companyId, status: 'paid' })
await insertInvoice({ userId: a.userId, companyId, status: 'overdue' })
// ...and four it would not: rows already there, a draft, a proforma.
await insertInvoice({ userId: a.userId, companyId, status: 'paid', rows: 1 })
await insertInvoice({ userId: a.userId, companyId, rows: 3 })
await insertInvoice({ userId: a.userId, companyId, status: 'draft' })
await insertInvoice({ userId: a.userId, companyId, documentType: 'proforma' })
// Another company's row-less invoice must not leak into the count.
await insertInvoice({ userId: b.userId, companyId: otherCompanyId })
}, 30_000)
it('counts exactly the non-draft sales invoices with no rows, per company', async () => {
expect(await countRowlessInvoices(client, companyId)).toBe(3)
expect(await countRowlessInvoices(client, otherCompanyId)).toBe(1)
})
it('agrees with the predicate the pass evaluates client-side', async () => {
const { data, error } = await client
.from('invoices')
.select('id, invoice_items(id)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.neq('status', 'draft')
expect(error).toBeNull()
const rows = (data ?? []) as { id: string; invoice_items: { id: string }[] | null }[]
const rowless = rows.filter((row) => (row.invoice_items?.length ?? 0) === 0)
expect(rows).toHaveLength(5)
expect(rowless).toHaveLength(await countRowlessInvoices(client, companyId))
})
it('reports zero for a company whose invoices all have rows', async () => {
const c = await seedCompany()
await insertInvoice({ userId: c.userId, companyId: c.companyId, rows: 2 })
await insertInvoice({ userId: c.userId, companyId: c.companyId, status: 'paid', rows: 1 })
expect(await countRowlessInvoices(client, c.companyId)).toBe(0)
})
})