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
102 changes: 102 additions & 0 deletions graphile/graphile-realtime-test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# graphile-realtime-test

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE">
<img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/>
</a>
<a href="https://www.npmjs.com/package/graphile-realtime-test">
<img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-realtime-test%2Fpackage.json"/>
</a>
</p>

Subscription testing utilities for `graphile-realtime-subscriptions`.

Provides smart tag injection, `grafast.subscribe()` helpers, and `pg_notify` simulation for integration testing realtime GraphQL subscriptions against a real PostgreSQL database.

## Usage

```typescript
import { createRealtimeTestContext, waitForEvent } from 'graphile-realtime-test';
import { seed } from 'pgsql-test';

const ctx = await createRealtimeTestContext(
{
schemas: ['my_schema'],
realtimeTables: ['items'],
useRoot: true,
authRole: 'postgres',
},
[seed.sqlfile(['./seed.sql'])]
);

// Start a subscription
const iterator = await ctx.subscribe(`
subscription {
onItemChanged {
event
rowId
overflow
}
}
`);

// Fire a NOTIFY
await ctx.notifyChange('items', 'INSERT', ['some-uuid']);

// Assert the event
const event = await waitForEvent(iterator);
expect(event.data.onItemChanged.event).toBe('INSERT');

// Clean up
await iterator.return?.();
await ctx.teardown();
```

## API

### `createRealtimeTestContext(input, seedAdapters?)`

Creates a fully wired test context with schema, subscriptions, and NOTIFY helpers.

### `makeRealtimeSmartTagsPlugin(tagsByTable)`

Creates a Graphile plugin that injects smart tags on table codecs during schema build.

### `subscribe(opts)`

Calls `grafast.subscribe()` and returns the raw async iterator.

### `waitForEvent(iterator, timeoutMs?)`

Waits for the next event from a subscription iterator with a timeout.

### `collectEvents(iterator, count, timeoutMs?)`

Collects multiple events from a subscription iterator.

### `notify(client, schema, table, payload)`

Fires a raw `pg_notify` on a realtime channel.

### `notifyChange(client, schema, table, operation, rowIds)`

Fires a DML NOTIFY with the standard payload format.

### `notifyInvalidate(client, schema, table)`

Fires an INVALIDATE (overflow) NOTIFY.

### `buildPayload(operation, rowIds)`

Builds a standard DML payload string.

### `buildInvalidatePayload()`

Returns the `"INVALIDATE"` payload string.
213 changes: 213 additions & 0 deletions graphile/graphile-realtime-test/__tests__/realtime.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* Integration tests for graphile-realtime-test utilities.
*
* These tests dogfood the package's own helpers against a real PostgreSQL
* database to verify that:
* - Smart tag injection generates subscription fields
* - grafast.subscribe() returns an async iterator
* - pg_notify payloads flow through to subscription events
* - INVALIDATE (overflow) payloads are handled correctly
* - Sparse set filtering (ids argument) works
*/

import { join } from 'path';
import { seed } from 'pgsql-test';
import {
createRealtimeTestContext,
waitForEvent,
buildPayload,
buildInvalidatePayload,
} from '../src';
import type { RealtimeTestContext } from '../src';
import type { ExecutionResult } from 'graphql';
import { randomUUID } from 'crypto';

// ─── Schema Discovery Tests ──────────────────────────────────────────────────

describe('schema discovery with @realtime smart tags', () => {
let ctx: RealtimeTestContext;

beforeAll(async () => {
ctx = await createRealtimeTestContext(
{
schemas: ['realtime_test'],
realtimeTables: ['items'],
useRoot: true,
authRole: 'postgres',
},
[seed.sqlfile([join(__dirname, '../sql/realtime-seed.sql')])]
);
});

afterAll(async () => {
if (ctx) await ctx.teardown();
});

it('generates onItemChanged subscription field from @realtime tag', () => {
const subscriptionType = ctx.schema.getSubscriptionType();
expect(subscriptionType).toBeDefined();

const fields = subscriptionType!.getFields();
expect(fields.onItemChanged).toBeDefined();
});

it('generates ItemSubscriptionPayload type with expected fields', () => {
const payloadType = ctx.schema.getType('ItemSubscriptionPayload');
expect(payloadType).toBeDefined();

// Verify the payload type has the expected fields
const fields = (payloadType as any).getFields();
expect(fields.event).toBeDefined();
expect(fields.rowId).toBeDefined();
expect(fields.overflow).toBeDefined();
expect(fields.item).toBeDefined();
});

it('accepts ids argument on subscription field', () => {
const subscriptionType = ctx.schema.getSubscriptionType()!;
const field = subscriptionType.getFields().onItemChanged;
const idsArg = field.args.find((a) => a.name === 'ids');
expect(idsArg).toBeDefined();
});
});

// ─── Notify Helper Tests ─────────────────────────────────────────────────────

describe('notify helpers', () => {
it('buildPayload formats DML payloads correctly', () => {
const id1 = randomUUID();
const id2 = randomUUID();

expect(buildPayload('INSERT', [id1])).toBe(`INSERT:${id1}`);
expect(buildPayload('UPDATE', [id1, id2])).toBe(`UPDATE:${id1},${id2}`);
expect(buildPayload('DELETE', [])).toBe('DELETE');
});

it('buildInvalidatePayload returns INVALIDATE', () => {
expect(buildInvalidatePayload()).toBe('INVALIDATE');
});
});

// ─── Subscription Flow Tests ─────────────────────────────────────────────────

describe('subscription event flow', () => {
let ctx: RealtimeTestContext;

beforeAll(async () => {
ctx = await createRealtimeTestContext(
{
schemas: ['realtime_test'],
realtimeTables: ['items'],
useRoot: true,
authRole: 'postgres',
},
[seed.sqlfile([join(__dirname, '../sql/realtime-seed.sql')])]
);
});

afterAll(async () => {
if (ctx) await ctx.teardown();
});

it('subscribe() returns an async iterator (not an error result)', async () => {
const result = await ctx.subscribe(`
subscription {
onItemChanged {
event
overflow
}
}
`);

// Should be an async iterator, not an ExecutionResult with errors
expect(Symbol.asyncIterator in (result as any)).toBe(true);

// Clean up the subscription
const iterator = result as AsyncIterableIterator<ExecutionResult>;
await iterator.return?.();
});

it('delivers INSERT event via pg_notify', async () => {
const iterator = (await ctx.subscribe(`
subscription {
onItemChanged {
event
rowId
overflow
}
}
`)) as AsyncIterableIterator<ExecutionResult>;

// Small delay to let the pgSubscriber establish the LISTEN
await new Promise((r) => setTimeout(r, 100));

const testId = randomUUID();
await ctx.notifyChange('items', 'INSERT', [testId]);

const event = await waitForEvent(iterator, 5000);
expect(event.data).toBeDefined();

const payload = (event.data as any).onItemChanged;
expect(payload.event).toBe('INSERT');
expect(payload.overflow).toBe(false);

await iterator.return?.();
});

it('delivers INVALIDATE (overflow) event', async () => {
const iterator = (await ctx.subscribe(`
subscription {
onItemChanged {
event
overflow
}
}
`)) as AsyncIterableIterator<ExecutionResult>;

await new Promise((r) => setTimeout(r, 100));

await ctx.notifyInvalidate('items');

const event = await waitForEvent(iterator, 5000);
expect(event.data).toBeDefined();

const payload = (event.data as any).onItemChanged;
expect(payload.event).toBe('INVALIDATE');
expect(payload.overflow).toBe(true);

await iterator.return?.();
});

it('sparse set: delivers only matching row IDs', async () => {
const watchedId = randomUUID();
const unwatchedId = randomUUID();

const iterator = (await ctx.subscribe(
`subscription($ids: [UUID!]) {
onItemChanged(ids: $ids) {
event
rowId
overflow
}
}`,
{ ids: [watchedId] }
)) as AsyncIterableIterator<ExecutionResult>;

await new Promise((r) => setTimeout(r, 100));

// Fire a NOTIFY for the unwatched ID — should be filtered out
await ctx.notifyChange('items', 'UPDATE', [unwatchedId]);

// Fire a NOTIFY for the watched ID — should be delivered
await ctx.notifyChange('items', 'UPDATE', [watchedId]);

const event = await waitForEvent(iterator, 5000);
expect(event.data).toBeDefined();

const payload = (event.data as any).onItemChanged;
expect(payload.event).toBe('UPDATE');
expect(payload.rowId).toBe(watchedId);

await iterator.return?.();
});
});
18 changes: 18 additions & 0 deletions graphile/graphile-realtime-test/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json'
}
]
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*']
};
59 changes: 59 additions & 0 deletions graphile/graphile-realtime-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"name": "graphile-realtime-test",
"version": "0.1.0",
"description": "Subscription testing utilities for graphile-realtime-subscriptions — smart tag injection, grafast.subscribe() helpers, and pg_notify simulation",
"author": "Constructive <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"scripts": {
"clean": "makage clean",
"prepack": "npm run build",
"build": "makage build",
"build:dev": "makage build --dev",
"lint": "eslint . --fix",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch"
},
"publishConfig": {
"access": "public",
"directory": "dist"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/constructive"
},
"keywords": [
"testing",
"graphql",
"graphile",
"constructive",
"subscriptions",
"realtime",
"notify",
"test"
],
"bugs": {
"url": "https://github.com/constructive-io/constructive/issues"
},
"dependencies": {
"graphile-test": "workspace:^",
"graphile-realtime-subscriptions": "workspace:^"
},
"peerDependencies": {
"grafast": "1.0.0",
"graphile-build": "5.0.0",
"graphile-build-pg": "5.0.0",
"graphile-config": "1.0.0",
"graphql": "16.13.0",
"pg": "^8.20.0",
"postgraphile": "5.0.0"
},
"devDependencies": {
"@types/pg": "^8.18.0",
"makage": "^0.3.0",
"pgsql-test": "workspace:^"
}
}
Loading
Loading