Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions .github/workflows/postgres.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# The main suite runs on a mock database. These jobs run against real Postgres: the
# suite with the specs that need one, and an upgrade from the previous release's schema
# with data in it.
name: postgres

on:
pull_request:
paths:
- 'src/migrations/**'
- 'src/models/**'
- 'src/config/config.cjs'
- 'src/config/database.cjs'
- '.sequelizerc'
# Services and controllers that send their own SQL.
- 'src/controllers/admin.ts'
- 'src/controllers/internalMetrics.ts'
- 'src/services/auditExport.ts'
- 'src/services/auditIntegrity.ts'
- 'src/services/auditRetention.ts'
- 'src/services/coverageReport.ts'
- 'src/services/enrollmentService.ts'
- 'src/services/funnelMetrics.ts'
- 'src/services/reviewAccountUsage.ts'
- 'src/services/schemaGuard.ts'
- 'src/services/signInMetrics.ts'
- 'tests/postgres/**'
- 'scripts/upgrade-check/**'
- 'vitest.config.ts'
- '.github/workflows/postgres.yml'
schedule:
- cron: '23 5 * * *'
workflow_dispatch:
inputs:
previous-ref:
description: 'Ref whose schema the upgrade starts from (default: the newest v* tag not on HEAD)'
required: false
default: ''

permissions:
contents: read

env:
DB_HOST: localhost
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: postgres
NODE_ENV: test

jobs:
suite:
name: Suite on Postgres
runs-on: ubuntu-latest

services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: seamless_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10

env:
DB_NAME: seamless_test
TEST_DB: postgres

steps:
- name: Checkout repo
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
cache: 'npm'

- name: Install dependencies
run: npm ci

# Migrates the database, then runs the unit project and the tests/postgres specs.
- name: Run tests against Postgres
run: npm run test:run

upgrade:
name: Upgrade from the previous release
runs-on: ubuntu-latest

services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: seamless_upgrade
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10

env:
DB_NAME: seamless_upgrade

steps:
- name: Checkout repo
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Resolve the previous release
id: previous
env:
REQUESTED: ${{ inputs.previous-ref }}
run: |
set -euo pipefail
ref="${REQUESTED:-$(git tag --list 'v*' --sort=-v:refname --no-contains HEAD | head -n1)}"
if [ -z "${ref}" ]; then
echo "No previous release tag found" >&2
exit 1
fi
echo "Upgrading from ${ref}"
echo "ref=${ref}" >> "${GITHUB_OUTPUT}"
{
echo "PREVIOUS_DIR=${RUNNER_TEMP}/previous"
echo "SNAPSHOT=${RUNNER_TEMP}/snapshot.json"
} >> "${GITHUB_ENV}"

- name: Check out the previous release
env:
REF: ${{ steps.previous.outputs.ref }}
run: git worktree add --detach "${PREVIOUS_DIR}" "${REF}"

- name: Migrate to the previous release's schema
run: |
set -euo pipefail
cd "${PREVIOUS_DIR}"
npm ci --ignore-scripts
npx sequelize-cli db:migrate

- name: Seed data on the previous schema
run: node scripts/upgrade-check/seed.mjs "${SNAPSHOT}"

- name: Run the current migrations
run: npx sequelize-cli db:migrate

- name: Verify the data survived
run: npx tsx scripts/upgrade-check/verify.mjs "${SNAPSHOT}"
14 changes: 12 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,22 @@ npx vitest run tests/unit/utils # a directory
npx vitest tests/unit/utils/redaction.spec.ts # watch mode
```

Only a few tests exercise real database behavior. To run those against a running Postgres:
The specs under [`tests/postgres/`](./tests/postgres) need a real database: they cover the
audit hash chain triggers and the services that send their own SQL. With `TEST_DB=postgres`
the run migrates the database named by the `DB_*` variables (default `seamless_test` on
localhost), then runs the unit suite and those specs. The schema is dropped first, so the
database name must contain `test`.

```bash
TEST_DB=postgres npm run test:run
TEST_DB=postgres DB_USER=postgres DB_PASSWORD=postgres npm run test:run
```

The `postgres` workflow also checks upgrades: it migrates an empty database to the previous
release's schema, seeds it with [`scripts/upgrade-check/seed.mjs`](./scripts/upgrade-check/seed.mjs),
runs the current migrations and compares every seeded row with
[`scripts/upgrade-check/verify.mjs`](./scripts/upgrade-check/verify.mjs). A migration that is
meant to change or remove existing data lists it in `EXPECTED_CHANGES` there.

### Writing a test

Use the shared factories in [`tests/factories/`](./tests/factories) to build valid domain
Expand Down
45 changes: 45 additions & 0 deletions scripts/upgrade-check/db.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
* See LICENSE file in the project root for full license information
*/

import { createRequire } from 'module';
import pg from 'pg';

const require = createRequire(import.meta.url);
const { buildDatabaseUrl } = require('../../src/config/database.cjs');

export async function connect() {
const client = new pg.Client({ connectionString: buildDatabaseUrl() });
await client.connect();
return client;
}

/**
* Every row of every table, keyed by table and then by primary key, as `row_to_json`
* renders it. `SequelizeMeta` is left out because migrating is meant to change it.
*/
export async function snapshot(client) {
const { rows: tables } = await client.query(`
SELECT c.relname AS name,
array_agg(a.attname::text ORDER BY array_position(i.indkey, a.attnum)) AS pk
FROM pg_index i
JOIN pg_class c ON c.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY (i.indkey)
WHERE i.indisprimary AND n.nspname = 'public' AND c.relname <> 'SequelizeMeta'
GROUP BY c.relname
ORDER BY c.relname`);

const result = {};

for (const { name, pk } of tables) {
const { rows } = await client.query(`SELECT row_to_json(t) AS row FROM public."${name}" t`);
result[name] = Object.fromEntries(
rows.map(({ row }) => [JSON.stringify(pk.map((column) => row[column])), row]),
);
}

return result;
}
Loading
Loading