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>
This commit is contained in:
Jakob Wennberg
2026-04-24 11:24:45 +02:00
committed by GitHub
parent 0222e084bb
commit ab63da8324
11 changed files with 737 additions and 6 deletions
+66
View File
@@ -0,0 +1,66 @@
name: pg-real tests
on: [pull_request]
concurrency:
group: pg-real-${{ github.ref }}
cancel-in-progress: true
jobs:
pg-real:
runs-on: ubuntu-latest
services:
postgres:
# Supabase image ships the auth schema, auth.uid(), and the extensions
# (uuid-ossp, pg_cron, btree_gist, vector) this repo's migrations need.
# Plain postgres:15 would require manual bootstrap SQL.
image: supabase/postgres:15.8.1.060
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
PGPASSWORD: postgres
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Install psql client
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
- name: Bootstrap storage schema
# The supabase/postgres image ships a partial storage schema; the rest
# is provisioned by the storage-api service at runtime, which we do
# not run in CI. This aligns the schema with what migrations expect.
run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f tests/pg/bootstrap.sql
- name: Apply migrations
run: |
set -euo pipefail
shopt -s nullglob
files=(supabase/migrations/*.sql)
if [ ${#files[@]} -eq 0 ]; then
echo "No migration files found"
exit 1
fi
for f in "${files[@]}"; do
echo "Applying $f"
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f "$f"
done
- run: npm run test:pg
+10
View File
@@ -355,6 +355,16 @@ export async function POST(request: Request) {
**Patterns**: Always mock `@/lib/supabase/server`. Use `vi.clearAllMocks()` and `eventBus.clear()` in `beforeEach`. API route tests: mock `@/lib/init` and lib functions, test auth (401), validation (400), not found (404), errors (500), happy path.
### Testing database-level logic (pg-real)
Mocked Supabase clients cannot exercise Postgres triggers, RPCs, or RLS policies. A parallel Vitest project `pg-real` runs against a real Postgres instance in CI (GitHub Actions `supabase/postgres:15` service container, migrations replayed from `supabase/migrations/` before the suite runs). Locally: `npm run test:pg` against a DATABASE_URL pointing at any Postgres with the Supabase `auth` schema and migrations applied.
**File convention**: `*.pg.test.ts`. The `unit` project excludes this suffix; only `pg-real` picks it up.
**Helpers**: `tests/pg/setup.ts` exposes `getPool()` and `withUserContext(userId, fn)` (sets `ROLE authenticated` + `request.jwt.claims` for RLS tests). `tests/pg/fixtures.ts` has `seedCompany()`, `insertDraftJournalEntry()`, `insertBalancedLines()`, etc.
**When to add a pg-real test**: any PR that creates or modifies a trigger, RPC, RLS policy, or DEFERRABLE constraint must include or extend a `*.pg.test.ts` test covering the new behavior. Mock coverage is not sufficient — it will pass on a broken migration. The suite is intentionally small (compliance gate, not a rewrite of the mock suite); add only smoke-level tests for new DB-layer constructs.
---
## Database & Migrations
@@ -0,0 +1,37 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { insertAuthUser } from '@/tests/pg/fixtures'
describe('audit-log.pg — append-only immutability', () => {
it('rejects UPDATE on audit_log rows', async () => {
const userId = await insertAuthUser()
const auditId = randomUUID()
await getPool().query(
`INSERT INTO public.audit_log (id, user_id, action, description)
VALUES ($1, $2, 'SECURITY_EVENT', 'pg-real seed')`,
[auditId, userId],
)
await expect(
getPool().query(
`UPDATE public.audit_log SET description = 'tampered' WHERE id = $1`,
[auditId],
),
).rejects.toThrow(/cannot be modified or deleted/i)
})
it('rejects DELETE on audit_log rows', async () => {
const userId = await insertAuthUser()
const auditId = randomUUID()
await getPool().query(
`INSERT INTO public.audit_log (id, user_id, action, description)
VALUES ($1, $2, 'SECURITY_EVENT', 'pg-real seed')`,
[auditId, userId],
)
await expect(
getPool().query(`DELETE FROM public.audit_log WHERE id = $1`, [auditId]),
).rejects.toThrow(/cannot be modified or deleted/i)
})
})
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import {
insertBalancedLines,
insertDraftJournalEntry,
seedCompany,
} from '@/tests/pg/fixtures'
describe('engine.pg — triggers & RPCs that mocks cannot catch', () => {
it('rejects INSERT into journal_entries when the fiscal period is closed', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany({ isClosed: true })
await expect(
insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }),
).rejects.toThrow(/locked\/closed fiscal period/i)
})
it('commit_journal_entry assigns sequential voucher numbers under concurrency', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryA = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
const entryB = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await insertBalancedLines(entryA)
await insertBalancedLines(entryB)
// Two dedicated clients so the row-level lock on voucher_sequences is
// actually exercised — not just a single connection serialising calls.
const clientA = await getPool().connect()
const clientB = await getPool().connect()
try {
const [resA, resB] = await Promise.all([
clientA.query<{ voucher_number: number }>(
`SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`,
[companyId, entryA],
),
clientB.query<{ voucher_number: number }>(
`SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`,
[companyId, entryB],
),
])
const numbers = [resA.rows[0]!.voucher_number, resB.rows[0]!.voucher_number].sort(
(a, b) => a - b,
)
expect(numbers).toEqual([1, 2])
} finally {
clientA.release()
clientB.release()
}
})
it('rejects UPDATE to a posted journal entry (committed immutability)', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
// Bypass commit_journal_entry by inserting directly as 'posted'. The
// immutability trigger fires on UPDATE, not INSERT, so this is legal
// setup on the superuser connection.
const entryId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId,
status: 'posted',
voucherNumber: 1,
})
await expect(
getPool().query(
`UPDATE public.journal_entries SET description = 'tampered' WHERE id = $1`,
[entryId],
),
).rejects.toThrow(/Cannot modify a posted journal entry/i)
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { insertDraftJournalEntry, seedCompany } from '@/tests/pg/fixtures'
import { withUserContext } from '@/tests/pg/setup'
describe('rls.pg — tenant isolation on journal_entries', () => {
it('returns only the authenticated user\'s company rows', async () => {
const a = await seedCompany()
const b = await seedCompany()
// One entry in each tenant.
const entryA = await insertDraftJournalEntry({
userId: a.userId,
companyId: a.companyId,
fiscalPeriodId: a.fiscalPeriodId,
})
await insertDraftJournalEntry({
userId: b.userId,
companyId: b.companyId,
fiscalPeriodId: b.fiscalPeriodId,
})
// user A should see exactly entryA and nothing from tenant B.
const rows = await withUserContext(a.userId, async (client) => {
const res = await client.query<{ id: string; company_id: string }>(
`SELECT id, company_id FROM public.journal_entries`,
)
return res.rows
})
expect(rows).toHaveLength(1)
expect(rows[0]!.id).toBe(entryA)
expect(rows[0]!.company_id).toBe(a.companyId)
})
})
+174
View File
@@ -59,6 +59,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sharp": "^0.31.1",
@@ -66,6 +67,7 @@
"dotenv": "^17.2.3",
"eslint": "^9",
"eslint-config-next": "16.1.5",
"pg": "^8.20.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
@@ -5548,6 +5550,18 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
@@ -11593,6 +11607,103 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11679,6 +11790,49 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -12668,6 +12822,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
@@ -14157,6 +14321,16 @@
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+4 -1
View File
@@ -11,7 +11,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run"
"test": "vitest run --project unit",
"test:pg": "vitest run --project pg-real"
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1022.0",
@@ -64,6 +65,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sharp": "^0.31.1",
@@ -71,6 +73,7 @@
"dotenv": "^17.2.3",
"eslint": "^9",
"eslint-config-next": "16.1.5",
"pg": "^8.20.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
+48
View File
@@ -0,0 +1,48 @@
-- pg-real CI bootstrap.
--
-- The Supabase Postgres image ships a partial `storage` schema; the remaining
-- columns and functions are provisioned at runtime by the storage-api
-- service, which we do not run in CI. This bootstrap aligns the schema with
-- what our migrations expect so the replay loop succeeds. It is idempotent
-- and safe to run against a freshly-initialised container.
CREATE SCHEMA IF NOT EXISTS storage;
CREATE TABLE IF NOT EXISTS storage.buckets (
id text PRIMARY KEY,
name text NOT NULL,
owner uuid,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
ALTER TABLE storage.buckets
ADD COLUMN IF NOT EXISTS public boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS file_size_limit bigint,
ADD COLUMN IF NOT EXISTS allowed_mime_types text[];
CREATE TABLE IF NOT EXISTS storage.objects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
bucket_id text REFERENCES storage.buckets(id) ON DELETE CASCADE,
name text,
owner uuid,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
last_accessed_at timestamptz DEFAULT now(),
metadata jsonb,
version text,
owner_id text
);
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
-- storage.foldername(): splits a slash-delimited object name into segments.
-- Migrations use `(storage.foldername(name))[n]` to derive tenant scoping
-- from the object path.
CREATE OR REPLACE FUNCTION storage.foldername(name text)
RETURNS text[]
LANGUAGE sql
IMMUTABLE
AS $$
SELECT string_to_array(name, '/');
$$;
+139
View File
@@ -0,0 +1,139 @@
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],
)
}
+116
View File
@@ -0,0 +1,116 @@
import { Pool, type PoolClient } from 'pg'
import { afterAll, beforeAll } from 'vitest'
// Shared pool for the pg-real project. DATABASE_URL must point at a Postgres
// instance that already has every migration from supabase/migrations/ applied
// and that includes the Supabase `auth` schema (supabase/postgres image).
const databaseUrl =
process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/postgres'
let pool: Pool | null = null
export function getPool(): Pool {
if (!pool) {
pool = new Pool({ connectionString: databaseUrl, max: 8 })
}
return pool
}
// Acquire a fresh client. Caller must always .release().
export async function getClient(): Promise<PoolClient> {
return getPool().connect()
}
// Run `fn` inside a role/JWT context that auth.uid() / user_company_ids() will
// observe. Uses SET LOCAL inside a transaction so the role reverts on commit
// or rollback. Always rolls back so the test's writes do not persist — tests
// that need to seed data must do that on the superuser connection first.
export async function withUserContext<T>(
userId: string,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await getClient()
try {
await client.query('BEGIN')
// Set JWT claims BEFORE switching role: non-superuser set_config on a
// namespaced GUC is fine, but keeping order explicit avoids surprises.
// Set both the whole-claims object and the individual `sub` claim —
// different versions of Supabase's auth.uid() read one or the other.
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
JSON.stringify({ sub: userId, role: 'authenticated' }),
])
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
await client.query(`SET LOCAL ROLE authenticated`)
// Fail loudly and early if the JWT context did not land the way we
// expect — otherwise RLS policies return empty and the real test
// failure points at an unrelated assertion.
const authCheck = await client.query<{ uid: string | null }>(
`SELECT auth.uid()::text AS uid`,
)
if (authCheck.rows[0]?.uid !== userId) {
throw new Error(
`withUserContext: auth.uid() resolved to ${authCheck.rows[0]?.uid ?? 'NULL'}, ` +
`expected ${userId}. Check request.jwt.claims setup.`,
)
}
const result = await fn(client)
await client.query('ROLLBACK')
return result
} catch (err) {
await client.query('ROLLBACK').catch(() => {})
throw err
} finally {
client.release()
}
}
// Schema sanity: fail loud if migrations did not apply, rather than letting
// every test fail with a cryptic "relation does not exist". Runs once per
// test file (vitest invokes setupFiles per worker).
beforeAll(async () => {
const client = await getClient()
try {
const result = await client.query<{ name: string; kind: 'trigger' | 'function' | 'table' }>(`
SELECT 'trigger'::text AS kind, tgname AS name FROM pg_trigger
WHERE tgname IN ('enforce_period_lock', 'audit_log_no_update')
UNION ALL
SELECT 'function'::text, proname FROM pg_proc
WHERE proname IN ('commit_journal_entry', 'user_company_ids', 'audit_log_immutable')
UNION ALL
SELECT 'table'::text, tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename IN ('journal_entries', 'companies', 'company_members',
'fiscal_periods', 'audit_log', 'voucher_sequences')
`)
const found = new Set(result.rows.map((r) => `${r.kind}:${r.name}`))
const required = [
'trigger:enforce_period_lock',
'trigger:audit_log_no_update',
'function:commit_journal_entry',
'function:user_company_ids',
'function:audit_log_immutable',
'table:journal_entries',
'table:companies',
'table:company_members',
'table:fiscal_periods',
'table:audit_log',
'table:voucher_sequences',
]
const missing = required.filter((r) => !found.has(r))
if (missing.length > 0) {
throw new Error(
`pg-real schema sanity check failed. Missing: ${missing.join(', ')}. ` +
`Did every migration in supabase/migrations/ apply cleanly to ${databaseUrl}?`,
)
}
} finally {
client.release()
}
})
afterAll(async () => {
if (pool) {
await pool.end()
pool = null
}
})
+37 -5
View File
@@ -1,14 +1,46 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
const alias = { '@': path.resolve(__dirname, '.') }
const unitProject = {
resolve: { alias },
test: {
name: 'unit',
globals: true,
environment: 'node' as const,
include: ['**/*.test.ts'],
exclude: ['**/node_modules/**', '**/*.pg.test.ts'],
},
}
const pgRealProject = {
resolve: { alias },
test: {
name: 'pg-real',
globals: true,
environment: 'node' as const,
include: ['**/*.pg.test.ts'],
exclude: ['**/node_modules/**'],
setupFiles: ['tests/pg/setup.ts'],
// One-connection-at-a-time to avoid cross-file DB contention.
fileParallelism: false,
testTimeout: 15000,
},
}
// Only register the pg-real project when DATABASE_URL is set. Local devs
// running a bare `vitest run` would otherwise hit the schema sanity check
// against a non-existent DB. `npm run test:pg` is the opt-in entry point.
const projects = process.env.DATABASE_URL
? [unitProject, pgRealProject]
: [unitProject]
export default defineConfig({
resolve: { alias },
test: {
globals: true,
environment: 'node',
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
projects,
},
})