skills/constructive-io/constructive-skills/pgsql-test-scenario-setup

pgsql-test-scenario-setup

SKILL.md

Structuring complex test scenarios with proper isolation, transaction management, and multi-client patterns.

Overview

Complex RLS and database tests often require careful setup: creating users, seeding data, and testing access patterns. This skill covers the patterns for structuring these scenarios with proper test isolation.

The Two-Client Pattern

The getConnections() function returns multiple clients with different privilege levels:

import { getConnections, PgTestClient } from 'pgsql-test';

let db: PgTestClient;  // App-level client (RLS-enforced)
let pg: PgTestClient;  // Superuser client (bypasses RLS)
let teardown: () => Promise<void>;

beforeAll(async () => {
  ({ db, pg, teardown } = await getConnections());
});

afterAll(() => teardown());

When to Use Each Client

Use pg (superuser) for:

  • Test setup that needs to bypass RLS
  • Creating test users and seed data
  • Administrative operations
  • Verifying data exists regardless of RLS

Use db (app-level) for:

  • Testing actual RLS behavior
  • Simulating real application queries
  • Verifying access control works correctly

Example: RLS Test Setup

const TEST_USER_1 = '00000000-0000-0000-0000-000000000001';
const TEST_USER_2 = '00000000-0000-0000-0000-000000000002';

beforeAll(async () => {
  ({ db, pg, teardown } = await getConnections());

  // Use pg (superuser) to create test users - bypasses RLS
  await pg.query(
    `INSERT INTO users (id, username) VALUES ($1, 'user1') ON CONFLICT DO NOTHING`,
    [TEST_USER_1]
  );
  await pg.query(
    `INSERT INTO users (id, username) VALUES ($1, 'user2') ON CONFLICT DO NOTHING`,
    [TEST_USER_2]
  );

  // Create test data owned by user 1
  await pg.query(
    `INSERT INTO documents (owner_id, title) VALUES ($1, 'User 1 Doc')`,
    [TEST_USER_1]
  );
});

// Now test RLS with db client
it('user 1 can see their documents', async () => {
  db.setContext({ role: 'authenticated', 'jwt.claims.user_id': TEST_USER_1 });
  const docs = await db.any('SELECT * FROM documents');
  expect(docs.length).toBe(1);
});

it('user 2 cannot see user 1 documents', async () => {
  db.setContext({ role: 'authenticated', 'jwt.claims.user_id': TEST_USER_2 });
  const docs = await db.any('SELECT * FROM documents');
  expect(docs.length).toBe(0);
});

Transaction Management

Test Isolation with beforeEach/afterEach

Each test runs in its own transaction that rolls back after the test:

beforeEach(async () => {
  await db.beforeEach();  // BEGIN + SAVEPOINT
});

afterEach(async () => {
  await db.afterEach();   // ROLLBACK TO SAVEPOINT + COMMIT
});

This ensures tests don't affect each other - any data created during a test is rolled back.

What beforeEach/afterEach Do

// db.beforeEach() executes:
await this.begin();      // BEGIN transaction
await this.savepoint();  // SAVEPOINT "lqlsavepoint"

// db.afterEach() executes:
await this.rollback();   // ROLLBACK TO SAVEPOINT "lqlsavepoint"
await this.commit();     // COMMIT (the outer transaction)

The publish() Method

When you need data created in one client to be visible to another client (or to persist beyond the current transaction), use publish():

it('cross-connection visibility', async () => {
  // Create data with db client
  db.setContext({ role: 'authenticated', 'jwt.claims.user_id': USER_ID });
  await db.query(`INSERT INTO items (name) VALUES ('test item')`);

  // Data is not yet visible to pg client (different connection)
  let pgItems = await pg.any('SELECT * FROM items WHERE name = $1', ['test item']);
  expect(pgItems.length).toBe(0);

  // Publish makes data visible to other connections
  await db.publish();

  // Now pg can see it
  pgItems = await pg.any('SELECT * FROM items WHERE name = $1', ['test item']);
  expect(pgItems.length).toBe(1);
});

What publish() Does

// db.publish() executes:
await this.commit();     // Make data visible to other sessions
await this.begin();      // Start fresh transaction
await this.savepoint();  // Maintain rollback harness
await this.ctxQuery();   // Reapply setContext() settings

Setup Patterns

Pattern 1: Simple Setup in beforeAll

For tests that share the same seed data:

beforeAll(async () => {
  ({ db, pg, teardown } = await getConnections());

  // Seed data once
  await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Alice')`, [USER_ID]);
});

afterAll(() => teardown());

beforeEach(() => db.beforeEach());
afterEach(() => db.afterEach());

Pattern 2: Complex Setup with Transactions

For setup that requires multiple steps with intermediate commits:

beforeAll(async () => {
  ({ db, pg, teardown } = await getConnections());

  // Start transaction on pg for setup
  await pg.begin();
  await pg.savepoint();

  // Create users
  await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Alice')`, [USER_1]);
  await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Bob')`, [USER_2]);

  // Commit pg's work so db can see it
  await pg.commit();
  await pg.begin();
  await pg.savepoint();

  // Now db can work with the users
  await db.begin();
  await db.savepoint();

  db.setContext({ role: 'authenticated', 'jwt.claims.user_id': USER_1 });
  // ... additional setup with RLS context

  await db.commit();
  await db.begin();
  await db.savepoint();
});

Pattern 3: Per-Describe Setup

For describe blocks that need their own isolated setup:

describe('Admin scenarios', () => {
  let adminId: string;

  beforeAll(async () => {
    // Create admin user for this describe block
    const result = await pg.one<{ id: string }>(
      `INSERT INTO users (name, is_admin) VALUES ('Admin', true) RETURNING id`
    );
    adminId = result.id;
  });

  beforeEach(() => db.beforeEach());
  afterEach(() => db.afterEach());

  it('admin can see all data', async () => {
    db.setContext({ role: 'authenticated', 'jwt.claims.user_id': adminId });
    // ...
  });
});

Scenario Testing Pattern

For testing complex workflows with multiple actors:

describe('Organization membership scenarios', () => {
  const OWNER_ID = '00000000-0000-0000-0000-000000000001';
  const MEMBER_ID = '00000000-0000-0000-0000-000000000002';
  let orgId: string;

  beforeAll(async () => {
    ({ db, pg, teardown } = await getConnections());

    // Create test users
    await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Owner')`, [OWNER_ID]);
    await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Member')`, [MEMBER_ID]);
  });

  afterAll(() => teardown());
  beforeEach(() => db.beforeEach());
  afterEach(() => db.afterEach());

  it('owner creates organization', async () => {
    db.setContext({ role: 'authenticated', 'jwt.claims.user_id': OWNER_ID });

    const result = await db.one<{ id: string }>(
      `INSERT INTO organizations (name) VALUES ('Acme') RETURNING id`
    );
    orgId = result.id;

    expect(orgId).toBeDefined();
  });

  it('owner can add members', async () => {
    db.setContext({ role: 'authenticated', 'jwt.claims.user_id': OWNER_ID });

    // First recreate the org (previous test rolled back)
    const org = await db.one<{ id: string }>(
      `INSERT INTO organizations (name) VALUES ('Acme') RETURNING id`
    );

    await db.query(
      `INSERT INTO memberships (org_id, user_id) VALUES ($1, $2)`,
      [org.id, MEMBER_ID]
    );

    const members = await db.any(
      `SELECT * FROM memberships WHERE org_id = $1`,
      [org.id]
    );
    expect(members.length).toBe(1);
  });
});

Best Practices

  1. Use pg for setup, db for testing RLS behavior
  2. Always call beforeEach()/afterEach() for test isolation
  3. Use publish() when data needs to be visible across connections
  4. Keep test user IDs as constants for consistency
  5. Structure complex scenarios with clear beforeAll setup
  6. Remember that each test's changes are rolled back - don't depend on previous test state
  7. Use descriptive test names that explain the scenario being tested
Weekly Installs
3
First Seen
Feb 27, 2026
Installed on
mcpjam3
claude-code3
replit3
junie3
windsurf3
zencoder3