Files
accounted/tests/pg/fixtures.ts
T
Jakob WennbergandClaude Opus 4.7 ab63da8324 test: add real-Postgres smoke gate (pg-real) (#357)
* test: add real-Postgres smoke gate (pg-real)

Mocked Supabase tests cannot exercise triggers, RPCs, or RLS policies —
a migration that drops enforce_period_lock, mangles user_company_ids(),
or weakens an RLS policy ships green today. Closes that gap with a
small Vitest project `pg-real` running 5 smoke tests against a real
supabase/postgres:15 container in CI.

Covers: closed-period INSERT rejection, commit_journal_entry voucher
atomicity under concurrency, posted-entry immutability, RLS tenant
isolation on journal_entries, and audit_log UPDATE/DELETE rejection.

Also lands the bankid anonymization migration that was sitting
untracked from a prior task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(pg-real): fix storage schema bootstrap + de-scope + PR review fixes

- Drop bankid anonymization migration from this PR. That change is
  separate scope (and has open compliance questions flagged by the
  Swedish review bot on #357); it will land in its own PR.
- Add tests/pg/bootstrap.sql to align storage.buckets/objects/foldername
  with what migrations expect before the replay loop. The supabase/postgres
  image ships only a partial storage schema; the rest comes from the
  storage-api service at runtime, which CI does not run. First pg-real run
  failed at migration 24 on "column public of relation buckets does not exist".
- Add concurrency group to the workflow so stacked PR commits cancel
  in-progress runs instead of queueing.
- Gate the pg-real vitest project on DATABASE_URL so a bare `vitest run`
  with no DB configured runs only the unit project. npm run test:pg is
  the opt-in entry point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(pg-real): widen JWT claim setup so auth.uid() resolves under RLS

The rls.pg test came back with 0 rows instead of 1 — user_company_ids()
returned empty because auth.uid() didn't resolve to the seeded user.
Two fixes:
- Set both request.jwt.claims (whole object) and request.jwt.claim.sub
  (individual claim). Different Supabase auth.uid() versions read one or
  the other.
- Assert auth.uid() = expected userId immediately after the context
  switch, so the next failure points at the right layer instead of an
  unrelated empty-result assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:24:45 +02:00

140 lines
4.3 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { getPool } from './setup'
// Minimal fixture inserters for pg-real tests. All inserts go through the
// pool (superuser `postgres`), which bypasses RLS — that is intentional for
// seeding. RLS is exercised only where a test explicitly opens a user
// context via withUserContext().
export async function insertAuthUser(id: string = randomUUID()): Promise<string> {
// auth.users has many columns but most default. We only need `id` and a
// non-conflicting `email`. Everything else (role, aud, timestamps, etc.)
// has a default or is nullable in the supabase/postgres image.
await getPool().query(
`INSERT INTO auth.users (id, email, instance_id)
VALUES ($1, $2, '00000000-0000-0000-0000-000000000000'::uuid)`,
[id, `pg-real-${id}@test.invalid`],
)
return id
}
export async function insertCompany(params: {
createdBy: string
name?: string
entityType?: 'enskild_firma' | 'aktiebolag'
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.companies (id, name, entity_type, created_by)
VALUES ($1, $2, $3, $4)`,
[id, params.name ?? 'Test AB', params.entityType ?? 'aktiebolag', params.createdBy],
)
return id
}
export async function insertCompanyMember(params: {
companyId: string
userId: string
role?: 'owner' | 'admin' | 'member' | 'viewer'
}): Promise<void> {
await getPool().query(
`INSERT INTO public.company_members (company_id, user_id, role)
VALUES ($1, $2, $3)`,
[params.companyId, params.userId, params.role ?? 'owner'],
)
}
export async function insertFiscalPeriod(params: {
userId: string
companyId: string
isClosed?: boolean
periodStart?: string
periodEnd?: string
name?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.fiscal_periods
(id, user_id, company_id, name, period_start, period_end, is_closed, closed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
id,
params.userId,
params.companyId,
params.name ?? '2026',
params.periodStart ?? '2026-01-01',
params.periodEnd ?? '2026-12-31',
params.isClosed ?? false,
params.isClosed ? new Date() : null,
],
)
return id
}
// One-call helper: creates user + company + owner membership + open fiscal
// period. Returns the IDs tests need.
export async function seedCompany(overrides: { isClosed?: boolean } = {}): Promise<{
userId: string
companyId: string
fiscalPeriodId: string
}> {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({
userId,
companyId,
isClosed: overrides.isClosed,
})
return { userId, companyId, fiscalPeriodId }
}
// Insert a draft journal entry and return its id. Uses a placeholder
// voucher_number=0 which commit_journal_entry() will overwrite on commit.
export async function insertDraftJournalEntry(params: {
userId: string
companyId: string
fiscalPeriodId: string
entryDate?: string
description?: string
voucherSeries?: string
status?: 'draft' | 'posted'
voucherNumber?: number
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'manual', $9)`,
[
id,
params.userId,
params.companyId,
params.fiscalPeriodId,
params.voucherNumber ?? 0,
params.voucherSeries ?? 'A',
params.entryDate ?? '2026-06-01',
params.description ?? 'Test entry',
params.status ?? 'draft',
],
)
return id
}
// Insert a balanced pair of journal entry lines (1 debit row + 1 credit row
// at the given amount). Needed before commit_journal_entry() because the
// balance constraint trigger fires on draft→posted.
export async function insertBalancedLines(
journalEntryId: string,
amount: number = 1000,
): Promise<void> {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', $2, 0),
($1, '3001', 0, $2)`,
[journalEntryId, amount],
)
}