@strav/testing 0.4.31 → 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.
@@ -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
- }