@strav/testing 0.4.31 → 1.0.0-alpha.25

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.
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Reads the standard env-var contract (`DB_HOST` / `DB_PORT` / `DB_USER` /
3
+ * `DB_PASSWORD` / `DB_DATABASE`) shared with CI and `.env.test` and
4
+ * assembles a Postgres URL. Returns `null` when any required variable
5
+ * is missing — callers self-skip rather than throw, so `bun test` is a
6
+ * no-op for integration tests in environments without local Postgres.
7
+ */
8
+
9
+ interface PgEnv {
10
+ host: string
11
+ port: string
12
+ user: string
13
+ password: string
14
+ database: string
15
+ }
16
+
17
+ function readPgEnv(): PgEnv | null {
18
+ const host = process.env.DB_HOST
19
+ const port = process.env.DB_PORT
20
+ const user = process.env.DB_USER
21
+ const password = process.env.DB_PASSWORD
22
+ const database = process.env.DB_DATABASE
23
+ if (!host || !port || !user || !password || !database) return null
24
+ return { host, port, user, password, database }
25
+ }
26
+
27
+ export function testDatabaseUrl(): string | null {
28
+ const env = readPgEnv()
29
+ if (env === null) return null
30
+ // `encodeURIComponent` so a password containing `@` or `:` doesn't
31
+ // corrupt the URL.
32
+ return `postgres://${env.user}:${encodeURIComponent(env.password)}@${env.host}:${env.port}/${env.database}`
33
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Typed `fetch` stub for driver tests that need to assert on outgoing
3
+ * HTTP without a network round-trip.
4
+ *
5
+ * Adapters that take a `fetch` injection point (e.g. `LineSocialDriver`,
6
+ * pure-fetch brain drivers) typically end up with `as unknown as typeof
7
+ * fetch` boilerplate in tests because the standard `fetch` signature
8
+ * (with `preconnect`, etc.) doesn't match a plain async function.
9
+ * `stubFetch` confines that cast to one place.
10
+ *
11
+ * ```ts
12
+ * import { stubFetch } from '@strav/testing'
13
+ *
14
+ * const captured: Request[] = []
15
+ * const driver = new LineSocialDriver({
16
+ * config: { ... },
17
+ * fetch: stubFetch(async (req) => {
18
+ * captured.push(req)
19
+ * if (req.url.includes('/token')) {
20
+ * return Response.json({ access_token: 'AT_1', expires_in: 3600 })
21
+ * }
22
+ * return new Response('not found', { status: 404 })
23
+ * }),
24
+ * })
25
+ * ```
26
+ *
27
+ * The handler receives a `Request` regardless of how the caller invoked
28
+ * `fetch` (URL + init, URL string, existing Request). Normalization
29
+ * happens here so the handler doesn't have to branch on input shape.
30
+ */
31
+
32
+ export type FetchHandler = (request: Request) => Response | Promise<Response>
33
+
34
+ export function stubFetch(handler: FetchHandler): typeof fetch {
35
+ const fn = async (
36
+ input: Parameters<typeof fetch>[0],
37
+ init?: Parameters<typeof fetch>[1],
38
+ ): Promise<Response> => {
39
+ const request = normalizeToRequest(input, init)
40
+ return handler(request)
41
+ }
42
+ return fn as unknown as typeof fetch
43
+ }
44
+
45
+ function normalizeToRequest(
46
+ input: Parameters<typeof fetch>[0],
47
+ init: Parameters<typeof fetch>[1],
48
+ ): Request {
49
+ if (input instanceof Request) {
50
+ // If `init` is provided, the spec says we layer it on; new Request
51
+ // accepts (Request, RequestInit) and merges accordingly.
52
+ return init === undefined ? input : new Request(input, init)
53
+ }
54
+ // string | URL → construct a Request. URL converts via its toString.
55
+ return new Request(typeof input === 'string' ? input : input.toString(), init)
56
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Standard `TenantManager` wiring for tests.
3
+ *
4
+ * `DatabaseProvider` doesn't auto-register `TenantManager` — apps wire
5
+ * it themselves so the binding is explicit (and so apps that don't use
6
+ * tenancy don't pay for it). Every integration / e2e suite that uses
7
+ * `TenantManager` defined an identical 3-line `ServiceProvider` to do
8
+ * the wiring; this is the extracted version.
9
+ *
10
+ * ```ts
11
+ * import { TenantManagerProvider } from '@strav/testing'
12
+ *
13
+ * app.useProviders([
14
+ * new ConfigProvider({ ... }),
15
+ * new LoggerProvider(),
16
+ * new DatabaseProvider(),
17
+ * new TenantManagerProvider(),
18
+ * // your provider here
19
+ * ])
20
+ * ```
21
+ *
22
+ * The class implements the same shape every e2e was rolling by hand:
23
+ * declares `database` as a dependency, binds `TenantManager` as a
24
+ * singleton built from the resolved `PostgresDatabase` + `EventBus`.
25
+ */
26
+
27
+ import { PostgresDatabase, TenantManager } from '@strav/database'
28
+ import { type Application, EventBus, ServiceProvider } from '@strav/kernel'
29
+
30
+ export class TenantManagerProvider extends ServiceProvider {
31
+ override readonly name = 'tenant'
32
+ override readonly dependencies = ['database']
33
+
34
+ override register(app: Application): void {
35
+ app.singleton(
36
+ TenantManager,
37
+ (c) => new TenantManager(c.resolve(PostgresDatabase), c.resolve(EventBus)),
38
+ )
39
+ }
40
+ }
package/CHANGELOG.md DELETED
@@ -1,7 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.1
4
-
5
- ### Changed
6
-
7
- - Applied consistent code formatting across all source files
@@ -1,38 +0,0 @@
1
- /**
2
- * Programmatic equivalent of `bun strav fresh`. Refuses to run unless the
3
- * caller has set APP_ENV to 'test' or 'local' — the CLI command requires
4
- * 'local'; we additionally accept 'test' so test runners don't need a flag
5
- * dance. Lazy-imports `@strav/cli` so packages that don't use BrowserTestCase
6
- * don't pay the dependency cost.
7
- */
8
- export async function runFresh(): Promise<void> {
9
- const env = process.env.APP_ENV
10
- if (env !== 'test' && env !== 'local') {
11
- throw new Error(
12
- `runFresh refused: APP_ENV must be 'test' or 'local' (got '${env ?? '<unset>'}'). ` +
13
- `This is a safety guard — fresh wipes all tables.`
14
- )
15
- }
16
-
17
- let freshDatabase: typeof import('@strav/cli/commands/migration_fresh').freshDatabase
18
- let bootstrap: typeof import('@strav/cli/cli/bootstrap').bootstrap
19
- let shutdown: typeof import('@strav/cli/cli/bootstrap').shutdown
20
- let getDatabasePaths: typeof import('@strav/cli/config/loader').getDatabasePaths
21
- try {
22
- ;({ freshDatabase } = await import('@strav/cli/commands/migration_fresh'))
23
- ;({ bootstrap, shutdown } = await import('@strav/cli/cli/bootstrap'))
24
- ;({ getDatabasePaths } = await import('@strav/cli/config/loader'))
25
- } catch (err) {
26
- throw new Error(
27
- `runFresh requires @strav/cli to be installed in this workspace. Add it as a peer dependency. (${(err as Error).message})`
28
- )
29
- }
30
-
31
- const paths = await getDatabasePaths()
32
- const { db, registry, introspector } = await bootstrap()
33
- try {
34
- await freshDatabase(db, registry, introspector, paths.migrations)
35
- } finally {
36
- await shutdown(db)
37
- }
38
- }
@@ -1,89 +0,0 @@
1
- import type { Page } from 'playwright-core'
2
- import { BrowserTestCase } from './test_case.ts'
3
- import type { BrowserTestCaseOptions } from './test_case.ts'
4
-
5
- export interface DemoFlowOptions extends BrowserTestCaseOptions {
6
- /** Default: '/auth/magic' — the conventional magic-link endpoint. */
7
- signInEndpoint?: string
8
- }
9
-
10
- /**
11
- * Opinionated wrapper around {@link BrowserTestCase} that ships the AGON
12
- * "demo flow" surface: magic-link sign-in via captured mail, fixture
13
- * composition for slice-by-slice extension, and stricter defaults
14
- * (`fresh: true`, `mail: 'capture'`).
15
- *
16
- * Apps doing general browser testing should reach for `BrowserTestCase`
17
- * directly; `DemoFlow` exists for the AGON slice-DoD use case.
18
- */
19
- export class DemoFlow {
20
- readonly tc: BrowserTestCase
21
- private readonly signInEndpoint: string
22
-
23
- constructor(tc: BrowserTestCase, options: DemoFlowOptions = {}) {
24
- this.tc = tc
25
- this.signInEndpoint = options.signInEndpoint ?? '/auth/magic'
26
- }
27
-
28
- /** Boot a DemoFlow — defaults `fresh: true`, `mail: 'capture'`. */
29
- static async boot(options: DemoFlowOptions = {}): Promise<DemoFlow> {
30
- const tc = await BrowserTestCase.boot({
31
- fresh: options.fresh ?? true,
32
- mail: options.mail ?? 'capture',
33
- ...options,
34
- })
35
- return new DemoFlow(tc, options)
36
- }
37
-
38
- /**
39
- * Build a fixture function that returns a fresh DemoFlow with `setup`
40
- * applied. Fixture flows compose: `withWorkspace` can call
41
- * `signedInUser()` and extend it.
42
- */
43
- static fixture<T extends DemoFlow>(setup: (flow: DemoFlow) => Promise<T>): () => Promise<T> {
44
- return async () => {
45
- const flow = await DemoFlow.boot()
46
- return await setup(flow)
47
- }
48
- }
49
-
50
- // Direct passthrough to the Playwright page for escape hatches.
51
- get page(): Page { return this.tc.page }
52
-
53
- // ---------------------------------------------------------------------------
54
- // Delegated DSL
55
- // ---------------------------------------------------------------------------
56
-
57
- goto(path: string): Promise<void> { return this.tc.goto(path) }
58
- click(selector: string): Promise<void> { return this.tc.click(selector) }
59
- fill(selector: string, value: string): Promise<void> { return this.tc.fill(selector, value) }
60
- expectUrl(url: string | RegExp): Promise<void> { return this.tc.expectUrl(url) }
61
- expectVisible(selector: string, text?: string | RegExp): Promise<void> { return this.tc.expectVisible(selector, text) }
62
- expectComputedStyle(
63
- selector: string,
64
- property: string,
65
- matcher: Parameters<BrowserTestCase['expectComputedStyle']>[2],
66
- ): Promise<void> {
67
- return this.tc.expectComputedStyle(selector, property, matcher)
68
- }
69
-
70
- // ---------------------------------------------------------------------------
71
- // Auth
72
- // ---------------------------------------------------------------------------
73
-
74
- /**
75
- * Sign in via the captured magic-link flow. Posts to the configured
76
- * endpoint, waits for the captured email, follows the link.
77
- */
78
- signIn(opts: { email: string; endpoint?: string; subject?: string | RegExp; tokenParam?: string }): Promise<void> {
79
- return this.tc.signInWithMagicLink({
80
- email: opts.email,
81
- endpoint: opts.endpoint ?? this.signInEndpoint,
82
- subject: opts.subject,
83
- tokenParam: opts.tokenParam,
84
- })
85
- }
86
-
87
- /** Skip the email loop — mint a session directly for `user`. */
88
- signInAs(user: unknown): Promise<void> { return this.tc.signInAs(user) }
89
- }
@@ -1,11 +0,0 @@
1
- export { BrowserTestCase } from './test_case.ts'
2
- export type {
3
- BrowserTestCaseOptions,
4
- BrowserName,
5
- MailMode,
6
- } from './test_case.ts'
7
- export { DemoFlow } from './demo_flow.ts'
8
- export type { DemoFlowOptions } from './demo_flow.ts'
9
- export { runFresh } from './db_fresh.ts'
10
- export { startListener, stopListener } from './server_lifecycle.ts'
11
- export type { ServerHandle } from './server_lifecycle.ts'
@@ -1,42 +0,0 @@
1
- import { app, Configuration } from '@strav/kernel'
2
- import { Router, Server } from '@strav/http'
3
-
4
- export interface ServerHandle {
5
- server: Server
6
- router: Router
7
- baseUrl: string
8
- port: number
9
- hostname: string
10
- }
11
-
12
- /**
13
- * Bind a Bun.serve listener for a router that has already had its routes
14
- * registered. Forces an ephemeral port (port=0) unless the caller specifies
15
- * one. The caller owns the lifecycle: call {@link stopListener} to tear down.
16
- */
17
- export function startListener(router: Router, options: { port?: number; hostname?: string } = {}): ServerHandle {
18
- const config = app.resolve(Configuration)
19
- const requestedPort = options.port ?? 0
20
- const requestedHost = options.hostname ?? '127.0.0.1'
21
-
22
- // Override config so Server.start() picks up our port/host without the
23
- // caller having to mutate their config files.
24
- config.set('http.port', requestedPort)
25
- config.set('http.host', requestedHost)
26
-
27
- if (!app.has(Server)) app.singleton(Server)
28
- const server = app.resolve(Server)
29
- server.start(router)
30
-
31
- return {
32
- server,
33
- router,
34
- port: server.port,
35
- hostname: server.hostname,
36
- baseUrl: `http://${server.hostname}:${server.port}`,
37
- }
38
- }
39
-
40
- export function stopListener(handle: ServerHandle): void {
41
- handle.server.stop()
42
- }