@strav/testing 0.4.30 → 1.0.0-alpha.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,82 +1,45 @@
1
1
  # @strav/testing
2
2
 
3
- Testing utilities for the [Strav](https://www.npmjs.com/package/@strav/core) framework. Provides HTTP testing helpers, authentication simulation, transaction-based test isolation, and model factories.
4
-
5
- ## Install
6
-
7
- ```bash
8
- bun add -d @strav/testing
9
- ```
10
-
11
- Requires `@strav/core` as a peer dependency.
12
-
13
- ## TestCase
14
-
15
- Boots the app, provides HTTP helpers, and wraps each test in a rolled-back transaction for full isolation.
3
+ Small, focused testing utilities for Strav apps and the framework itself.
16
4
 
17
5
  ```ts
18
- import { describe, test, expect } from 'bun:test'
19
- import { TestCase } from '@strav/testing'
20
-
21
- const t = await TestCase.boot({
22
- auth: true,
23
- routes: () => import('./start/api_routes'),
24
- })
25
-
26
- describe('Posts API', () => {
27
- test('list posts', async () => {
28
- const res = await t.get('/api/posts')
29
- expect(res.status).toBe(200)
30
- })
31
-
32
- test('create post as authenticated user', async () => {
33
- const user = await UserFactory.create()
34
- await t.actingAs(user)
35
-
36
- const res = await t.post('/api/posts', { title: 'Hello' })
37
- expect(res.status).toBe(201)
38
- })
39
- })
6
+ import {
7
+ bootTestApp,
8
+ isPostgresAvailable,
9
+ MemStream,
10
+ stubFetch,
11
+ } from '@strav/testing'
12
+
13
+ if (!await isPostgresAvailable()) {
14
+ test.skip('integration: ', () => {})
15
+ } else {
16
+ // real Postgres flow
17
+ }
18
+
19
+ const stdout = new MemStream()
20
+ const fetch = stubFetch(async (req) => Response.json({ ok: true }))
40
21
  ```
41
22
 
42
- ### HTTP Methods
23
+ Canonical docs live in [`docs/testing/README.md`](../../docs/testing/README.md).
43
24
 
44
- ```ts
45
- await t.get('/path')
46
- await t.post('/path', body)
47
- await t.put('/path', body)
48
- await t.patch('/path', body)
49
- await t.delete('/path')
50
- ```
51
-
52
- ### Auth Helpers
53
-
54
- ```ts
55
- await t.actingAs(user) // authenticate as user
56
- t.withHeaders({ 'X-Custom': 'value' })
57
- t.withoutAuth() // clear auth token
58
- ```
25
+ ## What ships
59
26
 
60
- ## Factory
27
+ | Surface | Notes |
28
+ |---|---|
29
+ | `bootTestApp({ config, schemas, migrations, providers })` | Replaces the ~50-line `beforeAll` boilerplate every e2e was rolling. Auto-supplies the standard four providers, applies schemas + migrations against `setupDb`, returns `{ app, setupDb, dispose }`. |
30
+ | `composeTestConfig(overrides)` | Merges logger + database defaults with per-test overrides. Used internally by `bootTestApp`; exported for tests that want the config tree without the orchestrator. |
31
+ | `TenantManagerProvider` | Standard 3-line wiring extracted from m5 / m6 / m7. |
32
+ | `MemStream` | In-memory `NodeJS.WritableStream` for asserting on stdout / stderr. Pairs with `ConsoleOutput`. |
33
+ | `stubFetch(handler)` | Typed `fetch` replacement. Confines the `as unknown as typeof fetch` cast to one place. |
34
+ | `isPostgresAvailable()` | Cached probe — returns `false` when env is missing or connection fails. |
35
+ | `createTestDatabase()` | Construct a fresh `PostgresDatabase` from `DB_HOST`/`DB_PORT`/etc. |
36
+ | `resetSchema(db)` | DROP + recreate `public` schema. |
37
+ | `connectedRoleBypassesRls(db)` | True for SUPERUSER / BYPASSRLS roles — tests use it to degrade RLS assertions. |
38
+ | `testDatabaseUrl()` | Returns the Postgres URL or `null` when env is missing. |
61
39
 
62
- Lightweight model factory for test data seeding.
63
-
64
- ```ts
65
- import { Factory } from '@strav/testing'
66
-
67
- const UserFactory = Factory.define(User, (seq) => ({
68
- pid: crypto.randomUUID(),
69
- name: `User ${seq}`,
70
- email: `user-${seq}@test.com`,
71
- passwordHash: 'hashed',
72
- }))
73
-
74
- const user = await UserFactory.create()
75
- const users = await UserFactory.createMany(5)
76
- const user = await UserFactory.create({ name: 'Custom' })
77
- const instance = UserFactory.make() // in-memory only, no DB
78
- ```
40
+ ## Subpaths
79
41
 
80
- ## License
42
+ - `@strav/testing/postgres` — narrow import for just the Postgres helpers.
43
+ - `@strav/testing/brain` — `stubBrainProvider({ embed, model? })`. Requires `@strav/brain` installed (peer-optional).
81
44
 
82
- MIT
45
+ Deferred to follow-up slices: `stubPaymentDriver`, `stubSocialDriver` — extract when the inline forms in `tests/e2e/m{6,7}-*/` show overlap.
package/package.json CHANGED
@@ -1,44 +1,37 @@
1
1
  {
2
2
  "name": "@strav/testing",
3
- "version": "0.4.30",
3
+ "version": "1.0.0-alpha.24",
4
+ "description": "Strav testing utilities — in-memory stream, typed fetch stub, Postgres availability probe + schema reset, bootTestApp orchestrator, stub providers.",
4
5
  "type": "module",
5
- "description": "Testing utilities for the Strav framework",
6
- "license": "MIT",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
7
8
  "exports": {
8
9
  ".": "./src/index.ts",
9
- "./*": "./src/*.ts"
10
+ "./brain": "./src/brain/index.ts",
11
+ "./postgres": "./src/postgres/index.ts"
10
12
  },
11
13
  "files": [
12
- "src/",
13
- "package.json",
14
- "tsconfig.json",
15
- "CHANGELOG.md"
14
+ "src",
15
+ "README.md"
16
16
  ],
17
+ "engines": {
18
+ "bun": ">=1.3.14"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@strav/database": "1.0.0-alpha.24",
25
+ "@strav/kernel": "1.0.0-alpha.24"
26
+ },
17
27
  "peerDependencies": {
18
- "@strav/kernel": "0.4.30",
19
- "@strav/http": "0.4.30",
20
- "@strav/view": "0.4.30",
21
- "@strav/database": "0.4.30",
22
- "@strav/signal": "0.4.30",
23
- "@strav/cli": "0.4.30",
24
- "playwright-core": "^1.45.0"
28
+ "@strav/brain": "1.0.0-alpha.24",
29
+ "@types/bun": ">=1.3.14"
25
30
  },
26
31
  "peerDependenciesMeta": {
27
- "@strav/signal": {
28
- "optional": true
29
- },
30
- "@strav/cli": {
31
- "optional": true
32
- },
33
- "playwright-core": {
32
+ "@strav/brain": {
34
33
  "optional": true
35
34
  }
36
35
  },
37
- "scripts": {
38
- "test": "bun test tests/",
39
- "typecheck": "tsc --noEmit"
40
- },
41
- "devDependencies": {
42
- "playwright-core": "^1.45.0"
43
- }
36
+ "devDependencies": null
44
37
  }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Boot a tested Application with the standard four providers
3
+ * (`ConfigProvider` → `LoggerProvider` → `DatabaseProvider` →
4
+ * `TenantManagerProvider`) pre-installed, real Postgres reset, schemas
5
+ * applied, optional migrations run, and the per-test providers wired
6
+ * on top. Returns the started `app`, the admin-level `setupDb` (kept
7
+ * open until `dispose()`), and the `dispose()` cleanup that pairs with
8
+ * `afterAll`.
9
+ *
10
+ * Replaces the ~50 LOC `beforeAll` boilerplate that every integration
11
+ * / e2e suite was rolling by hand. Per-test variation (custom config
12
+ * sub-trees, schemas, migrations, post-boot `useDriver` hand-wiring,
13
+ * HTTP server spin-up) lives where it always did.
14
+ *
15
+ * ```ts
16
+ * import { afterAll, beforeAll, describe } from 'bun:test'
17
+ * import {
18
+ * bootTestApp,
19
+ * isPostgresAvailable,
20
+ * type BootTestAppResult,
21
+ * } from '@strav/testing'
22
+ * import { RagProvider, ragVectorSchema, applyRagVectorMigration } from '@strav/rag'
23
+ *
24
+ * const PG = await isPostgresAvailable()
25
+ *
26
+ * describe.skipIf(!PG)('M5 e2e', () => {
27
+ * let booted: BootTestAppResult
28
+ * beforeAll(async () => {
29
+ * booted = await bootTestApp({
30
+ * config: { rag: { ... } },
31
+ * schemas: [tenantSchema, articleSchema, ragVectorSchema],
32
+ * migrations: [(db, registry) => applyRagVectorMigration(db, { dimension: 4, registry })],
33
+ * providers: [new StubBrainProvider(), new RagProvider()],
34
+ * })
35
+ * })
36
+ * afterAll(() => booted.dispose())
37
+ *
38
+ * // test cases pull from `booted.app.resolve(...)` and `booted.setupDb`.
39
+ * })
40
+ * ```
41
+ *
42
+ * The orchestration order, per spec from the e2e survey:
43
+ *
44
+ * 1. `createTestDatabase()` → admin connection for setup.
45
+ * 2. `resetSchema(setupDb)` → DROP + CREATE `public`.
46
+ * 3. Apply each schema's `emitCreateTable(schema, { registry }).sql`.
47
+ * 4. Run each migration with `(setupDb, registry)`.
48
+ * 5. Construct config via `composeTestConfig(config)` (auto-supplies
49
+ * logger + database.url unless overridden).
50
+ * 6. `new Application()`, `useProviders([Config, Logger, Database,
51
+ * TenantManager, ...userProviders])` unless `skipDefaultProviders`.
52
+ * 7. Bind `SchemaRegistry` as a singleton with `schemas` registered.
53
+ * 8. `await app.start({ signalHandlers: false })`.
54
+ */
55
+
56
+ import {
57
+ DatabaseProvider,
58
+ PostgresDatabase,
59
+ type Schema,
60
+ SchemaRegistry,
61
+ emitCreateTable,
62
+ } from '@strav/database'
63
+ import {
64
+ Application,
65
+ ConfigProvider,
66
+ LoggerProvider,
67
+ type ServiceProvider,
68
+ } from '@strav/kernel'
69
+ import { type ConfigOverrides, composeTestConfig } from './compose_test_config.ts'
70
+ import { createTestDatabase } from './postgres/create_test_database.ts'
71
+ import { resetSchema } from './postgres/reset_schema.ts'
72
+ import { TenantManagerProvider } from './tenant_manager_provider.ts'
73
+
74
+ /** Migration hook signature — runs against `setupDb` before `app.start`. */
75
+ export type TestMigration = (
76
+ db: PostgresDatabase,
77
+ registry: SchemaRegistry,
78
+ ) => Promise<void> | void
79
+
80
+ export interface BootTestAppOptions {
81
+ /**
82
+ * Per-test config sub-trees. `logger` and `database` keys are
83
+ * auto-supplied unless overridden here. Other keys (`rag`, `payment`,
84
+ * `social`, `encryption`, …) are passed through verbatim.
85
+ */
86
+ config?: ConfigOverrides
87
+ /**
88
+ * Schemas registered in the `SchemaRegistry` singleton AND applied
89
+ * via `emitCreateTable(schema, { registry }).sql` against `setupDb`
90
+ * before `app.start`. Order matters when there are FK dependencies.
91
+ */
92
+ schemas?: readonly Schema[]
93
+ /**
94
+ * Migrations to run after `schemas` are applied. Each receives the
95
+ * admin connection + the registry so it can compose SQL the same way
96
+ * the production migration helpers do.
97
+ */
98
+ migrations?: readonly TestMigration[]
99
+ /**
100
+ * Per-test service providers — appended after the standard four
101
+ * (`Config`, `Logger`, `Database`, `TenantManager`). Order within
102
+ * this list matters for the kernel's topological sort.
103
+ */
104
+ providers?: readonly ServiceProvider[]
105
+ /**
106
+ * Opt out of the standard four. Useful for tests that need a custom
107
+ * `LoggerProvider`, no `DatabaseProvider`, etc. When `true`, you own
108
+ * the entire provider list via `providers`. Default `false`.
109
+ */
110
+ skipDefaultProviders?: boolean
111
+ }
112
+
113
+ export interface BootTestAppResult {
114
+ /** Started Application — call `resolve(X)` from test cases. */
115
+ app: Application
116
+ /**
117
+ * Admin-level Postgres connection used to apply DDL + migrations and
118
+ * to run setup queries (seeding tenants, asserting schema state). Kept
119
+ * open until `dispose()`.
120
+ */
121
+ setupDb: PostgresDatabase
122
+ /** Cleanup: shutdown app + close setupDb. Use in `afterAll`. */
123
+ dispose(): Promise<void>
124
+ }
125
+
126
+ export async function bootTestApp(options: BootTestAppOptions = {}): Promise<BootTestAppResult> {
127
+ const setupDb = createTestDatabase()
128
+ await resetSchema(setupDb)
129
+
130
+ const schemas = options.schemas ?? []
131
+ const registry = new SchemaRegistry().registerAll(schemas)
132
+
133
+ for (const schema of schemas) {
134
+ await setupDb.execute(emitCreateTable(schema, { registry }).sql)
135
+ }
136
+
137
+ for (const migration of options.migrations ?? []) {
138
+ await migration(setupDb, registry)
139
+ }
140
+
141
+ const configData = composeTestConfig(options.config ?? {})
142
+
143
+ const app = new Application()
144
+ const defaults = options.skipDefaultProviders
145
+ ? []
146
+ : [
147
+ new ConfigProvider(configData),
148
+ new LoggerProvider(),
149
+ new DatabaseProvider(),
150
+ new TenantManagerProvider(),
151
+ ]
152
+ app.useProviders([...defaults, ...(options.providers ?? [])])
153
+
154
+ // Bind the SchemaRegistry singleton so repositories that need it can
155
+ // resolve through the container instead of getting hand-wired.
156
+ app.singleton(SchemaRegistry, () => registry)
157
+
158
+ await app.start({ signalHandlers: false })
159
+
160
+ let disposed = false
161
+ const dispose = async (): Promise<void> => {
162
+ if (disposed) return
163
+ disposed = true
164
+ await app.shutdown()
165
+ await setupDb.close({ timeout: 2 })
166
+ }
167
+
168
+ return { app, setupDb, dispose }
169
+ }
@@ -0,0 +1 @@
1
+ export { stubBrainProvider, type StubBrainOptions } from './stub_brain_provider.ts'
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Stub `BrainManager` wired as a `ServiceProvider` for tests that need
3
+ * deterministic embeddings without dialing an actual brain backend.
4
+ *
5
+ * Extracted from `tests/e2e/m5-rag/`. The provider declares
6
+ * `name: 'brain'` and `dependencies: ['config']`, matching the real
7
+ * `BrainProvider`'s shape — `RagProvider` (which declares
8
+ * `dependencies: ['config', 'brain']`) resolves the stub correctly
9
+ * when both are in the provider list.
10
+ *
11
+ * ```ts
12
+ * import { stubBrainProvider } from '@strav/testing/brain'
13
+ *
14
+ * const provider = stubBrainProvider({
15
+ * embed: (text) => bagOfWords(text), // returns number[]
16
+ * })
17
+ *
18
+ * app.useProviders([
19
+ * new ConfigProvider({ ... }),
20
+ * new LoggerProvider(),
21
+ * new DatabaseProvider(),
22
+ * provider, // ← stub registered here
23
+ * new RagProvider(), // ← resolves the stub for its embed calls
24
+ * ])
25
+ * ```
26
+ *
27
+ * The `embed` callback is per-text — the helper maps over the input
28
+ * array internally. Only `embed` is stubbed; other `BrainManager`
29
+ * surface (`chat`, `stream`, `runWithTools`, …) throws when called.
30
+ * V1 scope is rag-style tests; broader stubs land when a second
31
+ * use case appears.
32
+ */
33
+
34
+ import { BrainManager } from '@strav/brain'
35
+ import { type Application, ServiceProvider } from '@strav/kernel'
36
+
37
+ export interface StubBrainOptions {
38
+ /**
39
+ * Returns the embedding vector for a single text. The provider maps
40
+ * the user's `embed` over `texts` internally to produce `number[][]`.
41
+ */
42
+ embed: (text: string) => number[]
43
+ /** Model identifier surfaced on `embed` results. Default `'stub'`. */
44
+ model?: string
45
+ }
46
+
47
+ export function stubBrainProvider(options: StubBrainOptions): ServiceProvider {
48
+ const model = options.model ?? 'stub'
49
+ const userEmbed = options.embed
50
+ return new (class StubBrainProvider extends ServiceProvider {
51
+ override readonly name = 'brain'
52
+ override readonly dependencies = ['config']
53
+ override register(app: Application): void {
54
+ app.singleton(BrainManager, () => buildStub(userEmbed, model))
55
+ }
56
+ })()
57
+ }
58
+
59
+ function buildStub(
60
+ embed: (text: string) => number[],
61
+ model: string,
62
+ ): BrainManager {
63
+ const stub = {
64
+ embed: async (texts: readonly string[]) => ({
65
+ embeddings: texts.map(embed),
66
+ model,
67
+ usage: { inputTokens: 0 },
68
+ raw: null,
69
+ }),
70
+ }
71
+ return stub as unknown as BrainManager
72
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Merge per-test config overrides with the standard test defaults.
3
+ *
4
+ * Defaults supplied:
5
+ *
6
+ * - `logger`: silent + stderr channel — keeps `bun test` output clean.
7
+ * - `database.url`: composed from `DB_*` env vars via `testDatabaseUrl()`.
8
+ *
9
+ * Both are deep-merged with `overrides`. Pass `logger` or `database` in
10
+ * `overrides` to replace the default for that key. Other keys
11
+ * (`rag`, `payment`, `social`, `encryption`, etc.) are taken verbatim
12
+ * from `overrides`.
13
+ *
14
+ * Throws when `DB_*` env vars are missing AND no `database.url` override
15
+ * is supplied — `bootTestApp` callers gate via `isPostgresAvailable()`
16
+ * first, so the throw should only fire in misconfigured environments.
17
+ */
18
+
19
+ import { testDatabaseUrl } from './postgres/test_database_url.ts'
20
+
21
+ export type ConfigOverrides = Record<string, unknown>
22
+
23
+ const DEFAULT_LOGGER = {
24
+ default: 'main',
25
+ level: 'silent',
26
+ channels: { main: { driver: 'stderr' } },
27
+ } as const
28
+
29
+ export function composeTestConfig(overrides: ConfigOverrides = {}): Record<string, unknown> {
30
+ const merged: Record<string, unknown> = { ...overrides }
31
+
32
+ if (!('logger' in merged)) {
33
+ merged.logger = { ...DEFAULT_LOGGER }
34
+ }
35
+
36
+ if (!('database' in merged)) {
37
+ const url = testDatabaseUrl()
38
+ if (url === null) {
39
+ throw new Error(
40
+ 'composeTestConfig: missing DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_DATABASE env. Source .env.test, run docker-compose up, or pass `database` explicitly in overrides.',
41
+ )
42
+ }
43
+ merged.database = { url }
44
+ }
45
+
46
+ return merged
47
+ }
package/src/index.ts CHANGED
@@ -1,17 +1,29 @@
1
- export { TestCase } from './test_case.ts'
2
- export type { TestCaseOptions } from './test_case.ts'
3
- export { Factory } from './factory.ts'
1
+ // Public API of @strav/testing.
2
+ //
3
+ // V1 ships the small utilities that get re-implemented inline in every
4
+ // test: an in-memory writable stream, a typed fetch stub, and the
5
+ // Postgres availability + reset helpers used by integration suites.
6
+ // V2 (this slice) adds bootTestApp + composeTestConfig +
7
+ // TenantManagerProvider for the e2e boot-dance.
4
8
 
5
- // Database management
6
- export { TestDatabaseManager, cleanupTestDatabase } from './database_manager.ts'
9
+ export {
10
+ bootTestApp,
11
+ type BootTestAppOptions,
12
+ type BootTestAppResult,
13
+ type TestMigration,
14
+ } from './boot_test_app.ts'
15
+ export { composeTestConfig, type ConfigOverrides } from './compose_test_config.ts'
16
+ export { MemStream } from './mem_stream.ts'
17
+ export { stubFetch, type FetchHandler } from './stub_fetch.ts'
18
+ export { TenantManagerProvider } from './tenant_manager_provider.ts'
7
19
 
8
- // Browser-driven testing (Playwright). Lazy-loads playwright-core only when
9
- // BrowserTestCase / DemoFlow are actually instantiated.
10
- export { BrowserTestCase, DemoFlow, runFresh } from './browser/index.ts'
11
- export type {
12
- BrowserTestCaseOptions,
13
- BrowserName,
14
- MailMode,
15
- DemoFlowOptions,
16
- ServerHandle,
17
- } from './browser/index.ts'
20
+ // Postgres helpers also re-exported under `@strav/testing/postgres`
21
+ // for apps that want to import them without pulling in the rest of
22
+ // the barrel.
23
+ export {
24
+ connectedRoleBypassesRls,
25
+ createTestDatabase,
26
+ isPostgresAvailable,
27
+ resetSchema,
28
+ testDatabaseUrl,
29
+ } from './postgres/index.ts'
@@ -0,0 +1,50 @@
1
+ /**
2
+ * In-memory `WritableStream` for tests that assert on stdout/stderr.
3
+ *
4
+ * Pairs with `@strav/kernel`'s `ConsoleOutput` and `@strav/cli`'s
5
+ * command/`runCli` flows — both accept a `NodeJS.WritableStream`
6
+ * pair, and `MemStream` is the smallest possible double that lets
7
+ * tests inspect what was written.
8
+ *
9
+ * ```ts
10
+ * import { MemStream } from '@strav/testing'
11
+ * import { ConsoleOutput } from '@strav/kernel'
12
+ *
13
+ * const stdout = new MemStream()
14
+ * const out = new ConsoleOutput({ stdout: stdout.asWritable(), useColor: false })
15
+ * out.line('hello')
16
+ * expect(stdout.text()).toBe('hello\n')
17
+ * ```
18
+ *
19
+ * `asWritable()` exists so callers don't need the `as unknown as
20
+ * NodeJS.WritableStream` cast at every test site — the cast is
21
+ * confined to this module's boundary.
22
+ */
23
+
24
+ export class MemStream {
25
+ readonly chunks: string[] = []
26
+
27
+ write(chunk: string): boolean {
28
+ this.chunks.push(chunk)
29
+ return true
30
+ }
31
+
32
+ /** Concatenated written content. */
33
+ text(): string {
34
+ return this.chunks.join('')
35
+ }
36
+
37
+ /** Drop everything written so far. Useful between assertions in a single test. */
38
+ clear(): void {
39
+ this.chunks.length = 0
40
+ }
41
+
42
+ /**
43
+ * Cast to `NodeJS.WritableStream` for APIs that demand the full
44
+ * Node interface (`ConsoleOutput`, child-process stdio, etc.).
45
+ * Confines the boundary cast to this method.
46
+ */
47
+ asWritable(): NodeJS.WritableStream {
48
+ return this as unknown as NodeJS.WritableStream
49
+ }
50
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `true` when the connected role is a Postgres SUPERUSER or has the
3
+ * BYPASSRLS attribute. Such roles ignore `ENABLE ROW LEVEL SECURITY` —
4
+ * even with `FORCE ROW LEVEL SECURITY` set on a table — so
5
+ * RLS-isolation assertions can't pass under them. Tests use this to
6
+ * self-skip the RLS-scoping check while still exercising the rest of
7
+ * the tenancy path.
8
+ *
9
+ * Production setups should run the app under a non-privileged role and
10
+ * keep BYPASSRLS reserved for the admin pool (`TenantManager`'s
11
+ * `adminDb`). Local-dev / CI databases often share one superuser for
12
+ * convenience — this helper lets the suite degrade gracefully there.
13
+ */
14
+
15
+ import type { PostgresDatabase } from '@strav/database'
16
+
17
+ export async function connectedRoleBypassesRls(db: PostgresDatabase): Promise<boolean> {
18
+ const row = await db.queryOne<{ rolsuper: boolean; rolbypassrls: boolean }>(
19
+ `SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`,
20
+ )
21
+ return Boolean(row?.rolsuper) || Boolean(row?.rolbypassrls)
22
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Construct a fresh `PostgresDatabase` against the test connection. The
3
+ * caller owns the close — typically in an `afterAll` hook.
4
+ *
5
+ * Throws when env is missing; tests that don't want the throw should
6
+ * gate via `isPostgresAvailable()` first.
7
+ */
8
+
9
+ import { PostgresDatabase } from '@strav/database'
10
+ import { testDatabaseUrl } from './test_database_url.ts'
11
+
12
+ export function createTestDatabase(): PostgresDatabase {
13
+ const url = testDatabaseUrl()
14
+ if (url === null) {
15
+ throw new Error(
16
+ 'createTestDatabase: missing DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_DATABASE env. Source .env.test or run docker-compose up.',
17
+ )
18
+ }
19
+ return new PostgresDatabase({ url, max: 2 })
20
+ }
@@ -0,0 +1,5 @@
1
+ export { connectedRoleBypassesRls } from './connected_role_bypasses_rls.ts'
2
+ export { createTestDatabase } from './create_test_database.ts'
3
+ export { isPostgresAvailable } from './is_postgres_available.ts'
4
+ export { resetSchema } from './reset_schema.ts'
5
+ export { testDatabaseUrl } from './test_database_url.ts'
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Cheap connection probe with a short timeout. Cached for the lifetime
3
+ * of the test process — connection state doesn't flip mid-suite.
4
+ *
5
+ * Returns `false` if env is missing OR the connection attempt fails
6
+ * (Postgres down, wrong creds, network unreachable). Pair with
7
+ * `describe.skipIf(!await isPostgresAvailable())` so suites that need
8
+ * a real Postgres self-skip when it's not reachable.
9
+ */
10
+
11
+ import { PostgresDatabase } from '@strav/database'
12
+ import { testDatabaseUrl } from './test_database_url.ts'
13
+
14
+ let cachedAvailability: boolean | null = null
15
+
16
+ export async function isPostgresAvailable(): Promise<boolean> {
17
+ if (cachedAvailability !== null) return cachedAvailability
18
+ const url = testDatabaseUrl()
19
+ if (url === null) {
20
+ cachedAvailability = false
21
+ return false
22
+ }
23
+ try {
24
+ const probe = new PostgresDatabase({ url, max: 1 })
25
+ await probe.queryOne('SELECT 1 AS ok')
26
+ await probe.close({ timeout: 1 })
27
+ cachedAvailability = true
28
+ return true
29
+ } catch {
30
+ cachedAvailability = false
31
+ return false
32
+ }
33
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Drop + recreate the `public` schema on the connected database.
3
+ * Bulletproof isolation between integration test runs at the cost of
4
+ * a sledgehammer — the integration test database owns its state and
5
+ * shouldn't be pointed at anything precious.
6
+ */
7
+
8
+ import type { PostgresDatabase } from '@strav/database'
9
+
10
+ export async function resetSchema(db: PostgresDatabase): Promise<void> {
11
+ await db.execute('DROP SCHEMA IF EXISTS public CASCADE')
12
+ await db.execute('CREATE SCHEMA public')
13
+ await db.execute('GRANT ALL ON SCHEMA public TO public')
14
+ }