@voltro/testing 0.32.0 → 0.34.0

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/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AppContext } from '@voltro/runtime';
2
+ import { AuthStrategy } from '@voltro/protocol';
2
3
  import { changeDelete } from '@voltro/database';
3
4
  import { ChangeEvent } from '@voltro/database';
4
5
  import { changeInsert } from '@voltro/database';
@@ -13,18 +14,31 @@ import { InspectedWorkflow } from '@voltro/workflow';
13
14
  import { Layer } from 'effect';
14
15
  import { ParseResult } from 'effect';
15
16
  import { ProcedureDescriptor } from '@voltro/protocol';
17
+ import { PublicApiDescriptor } from '@voltro/protocol/rest';
16
18
  import { RelationsSpec } from '@voltro/database';
19
+ import { RestRouteDescriptor } from '@voltro/protocol/rest';
20
+ import { RestServeBindings } from '@voltro/protocol/rest';
17
21
  import { Row } from '@voltro/database';
18
22
  import { RowFilter } from '@voltro/runtime';
19
23
  import { RpcInterceptor } from '@voltro/protocol';
20
24
  import { RpcKind } from '@voltro/protocol';
21
25
  import { Schema } from 'effect';
26
+ import { Scope } from 'effect';
22
27
  import { Subject } from '@voltro/protocol';
23
28
  import { SubscribeContext } from '@voltro/runtime';
24
29
  import { SyncLogger } from '@voltro/logger';
25
30
  import { TableLike } from '@voltro/database';
26
31
  import { VoltroPlugin } from '@voltro/protocol';
27
32
 
33
+ /** An unauthenticated caller, optionally carrying a tenant (what the
34
+ * `x-tenant` header resolves to when no auth strategy matches). Delegates to
35
+ * the protocol's own constructor so the harness cannot drift from it. */
36
+ export declare const anonymous: (tenantId?: string | null) => Subject;
37
+
38
+ /** An API-key subject — what a `Authorization: Bearer <key>` call resolves to.
39
+ * Same shape as `user()`; the `type` is what an app's own branching reads. */
40
+ export declare const apiKey: (id: string, options?: TestSubjectOptions) => Subject;
41
+
28
42
  export { changeDelete }
29
43
 
30
44
  export { ChangeEvent }
@@ -35,6 +49,47 @@ export { changeSoftDelete }
35
49
 
36
50
  export { changeUpdate }
37
51
 
52
+ /**
53
+ * A named factory for `table`.
54
+ *
55
+ * ```ts
56
+ * const users = defineFactory(usersTable, {
57
+ * defaults: { email: (seq) => `user-${seq}@test.local`, role: 'member' },
58
+ * traits: { admin: { role: 'admin' } },
59
+ * })
60
+ * const posts = defineFactory(postsTable, { associations: { authorId: users } })
61
+ *
62
+ * const admin = await users.with('admin').create(ctx.store)
63
+ * const post = await posts.create(ctx.store, { authorId: admin.id }) // no extra user
64
+ * const orphanFree = await posts.create(ctx.store) // an author IS created
65
+ * ```
66
+ */
67
+ export declare const defineFactory: (table: TableLike, options?: DefineFactoryOptions) => Factory;
68
+
69
+ export declare interface DefineFactoryOptions {
70
+ /** Column defaults applied before the caller's per-call overrides. */
71
+ readonly defaults?: FactoryDefaults;
72
+ /** Named override bundles, selected with `.with('name')`. */
73
+ readonly traits?: Readonly<Record<string, FactoryDefaults>>;
74
+ /**
75
+ * Factories for this table's ancestors, keyed by the REFERENCE COLUMN name.
76
+ * `create()` uses one when it has to build a parent, so the parent gets its
77
+ * own defaults and its own ancestors rather than a bare `fixtureRow`.
78
+ *
79
+ * ```ts
80
+ * const posts = defineFactory(postsTable, {
81
+ * associations: { authorId: users }, // `users` is another factory
82
+ * })
83
+ * ```
84
+ *
85
+ * Omitted for a column → the parent is built with a plain `fixtureRow` (plus
86
+ * ITS own required ancestors, recursively). That is enough for a parent
87
+ * nothing asserts on, and the escape hatch when it is not is to pass the id
88
+ * explicitly, which always wins over both.
89
+ */
90
+ readonly associations?: Readonly<Record<string, Factory>>;
91
+ }
92
+
38
93
  /**
39
94
  * `describe` a suite whose dependency is checked by ITS OWN probe.
40
95
  *
@@ -82,6 +137,66 @@ export declare interface EmittedWebhook {
82
137
  * ergonomic without an `any` in the public type. */
83
138
  declare type ErasedWorkflowEffect = Effect.Effect<unknown, unknown, never>;
84
139
 
140
+ export declare interface Factory {
141
+ /** The table this factory builds rows for. */
142
+ readonly table: TableLike;
143
+ /** A complete row — defaults, then traits, then `overrides`, then
144
+ * `fixtureRow` for whatever is still required. Pure: writes nothing, and
145
+ * therefore creates NO parent rows (a `reference()` column with no override
146
+ * gets `fixtureRow`'s placeholder). Use `create` when the relations matter. */
147
+ readonly build: (overrides?: Row) => Row;
148
+ /** `count` built rows. `overrides` may be a function of the index when the
149
+ * rows must differ. */
150
+ readonly buildList: (count: number, overrides?: Row | ((index: number) => Row)) => ReadonlyArray<Row>;
151
+ /**
152
+ * Insert a row AND every ancestor it requires, in dependency order, and
153
+ * return the inserted row (the store's, so auto-stamped ids/audit columns are
154
+ * on it).
155
+ *
156
+ * An ancestor is created only for a required `reference()` column the merged
157
+ * overrides leave unset — pass `{ authorId: existing.id }` and nothing extra
158
+ * is written.
159
+ */
160
+ readonly create: (store: FactoryStore, overrides?: Row) => Promise<Row>;
161
+ /** `count` created rows. Each gets its OWN ancestors unless the override
162
+ * pins them, which is usually what a list test wants — pass the parent id in
163
+ * `overrides` to share one. */
164
+ readonly createList: (store: FactoryStore, count: number, overrides?: Row | ((index: number) => Row)) => Promise<ReadonlyArray<Row>>;
165
+ /** A derived factory with these traits' defaults merged in, in the order
166
+ * named (later wins). Throws for a trait this factory does not declare —
167
+ * a mistyped trait name silently building the BASE row is the failure mode
168
+ * this rules out. */
169
+ readonly with: (...traits: ReadonlyArray<string>) => Factory;
170
+ /** A derived factory with extra defaults merged in — the ad-hoc form of a
171
+ * trait, for a one-off variation not worth naming. */
172
+ readonly extend: (defaults: FactoryDefaults) => Factory;
173
+ }
174
+
175
+ /** Column → default. Every key is optional; anything omitted falls through to
176
+ * `fixtureRow`'s placeholder or, for a `reference()`, to an association. */
177
+ export declare type FactoryDefaults = Readonly<Record<string, FactoryValue>>;
178
+
179
+ /** The narrow slice of a store a factory writes through — `ctx.store` and any
180
+ * transactional view of it satisfy it structurally. Typed as a shape rather
181
+ * than as `DataStore` so a factory can also be handed the mixin-wrapped store
182
+ * a handler sees, which is the one a test actually holds. */
183
+ export declare interface FactoryStore {
184
+ insert(table: string, row: Row): Promise<Row>;
185
+ }
186
+
187
+ /**
188
+ * One column's default: a function handed a monotonic sequence number
189
+ * (`(seq) => \`user-${seq}@test.local\``) for a unique column, or a plain value.
190
+ *
191
+ * The arms are spelled out rather than written `unknown | ((seq: number) => …)`,
192
+ * which is the obvious version and is silently WRONG: `unknown` absorbs every
193
+ * other member of a union, so that type collapses to `unknown`, the function
194
+ * arm disappears, and a caller writing `(seq) => …` gets `seq: any` with no
195
+ * contextual type at all. The function arm is FIRST so it wins contextual
196
+ * typing for an arrow literal.
197
+ */
198
+ export declare type FactoryValue = ((sequence: number) => unknown) | string | number | boolean | bigint | Date | null | undefined | ReadonlyArray<unknown> | Readonly<Record<string, unknown>>;
199
+
85
200
  /**
86
201
  * Complete a partial row for `table` so it satisfies the required-column insert
87
202
  * validation. Fills every NOT-NULL, no-default, non-auto-stamped column that
@@ -91,6 +206,20 @@ declare type ErasedWorkflowEffect = Effect.Effect<unknown, unknown, never>;
91
206
  */
92
207
  export declare const fixtureRow: (table: TableLike, overrides?: Row) => Row;
93
208
 
209
+ /**
210
+ * The Effect-native form: global time is frozen for the SCOPE's lifetime and
211
+ * restored on release — on success, on failure, and on interruption.
212
+ *
213
+ * ```ts
214
+ * Effect.scoped(Effect.gen(function* () {
215
+ * const clock = yield* frozenTime('2026-03-01T12:00:00Z')
216
+ * yield* handler // every `new Date()` inside reads the mock
217
+ * clock.advance('1d')
218
+ * }))
219
+ * ```
220
+ */
221
+ export declare const frozenTime: (at: Date | number | string | MockClock) => Effect.Effect<MockClock, never, Scope.Scope>;
222
+
94
223
  /**
95
224
  * What `invoke` resolves to for a handler returning `Result`: an `Effect`'s
96
225
  * SUCCESS value, an awaited `Promise`, or the value itself.
@@ -121,6 +250,7 @@ export declare type HandlerOutput<Result> = Result extends Effect.Effect<infer A
121
250
  * `makeTestContext({ plugins })` wrap the whole thing from outside.
122
251
  *
123
252
  * ```ts
253
+ * import { invoke, makeTestContext, user } from '@voltro/testing'
124
254
  * import { createNote } from './notes.mutation' // descriptor
125
255
  * import { createNoteHandler } from './notes.mutation.server' // executor
126
256
  *
@@ -171,6 +301,50 @@ export declare interface MakeSubscribeContextOptions {
171
301
  readonly log?: SyncLogger;
172
302
  }
173
303
 
304
+ /**
305
+ * Build a request-level harness over `ctx`.
306
+ *
307
+ * The subject a request runs under is resolved PER REQUEST — by the strategy
308
+ * chain from the request's own headers, or by `actingAs` — and the procedure
309
+ * then runs on `ctx.withSubject(resolved, …)`. That is the whole point of the
310
+ * layer: two calls to the same app with different headers are two different
311
+ * callers reading through the same store, exactly as two HTTP requests are.
312
+ */
313
+ export declare const makeTestApp: (options: MakeTestAppOptions) => TestApp;
314
+
315
+ export declare interface MakeTestAppOptions {
316
+ /** The context every request runs against. Its store is the app's store; the
317
+ * request's RESOLVED subject re-scopes it per call via `withSubject`, so a
318
+ * request genuinely reads through the identity the transport produced rather
319
+ * than the one the context was built with. */
320
+ readonly ctx: TestContext;
321
+ /**
322
+ * Hand-authored `defineRestRoute` descriptors (an app's `restRoutes`).
323
+ *
324
+ * Typed `<any, any>` to match `app.config.ts`'s own `restRoutes` field and
325
+ * `restRoutesToHttpRoutes`'s parameter: each descriptor fixes its OWN input
326
+ * and output types at `defineRestRoute`, and a heterogeneous array of them
327
+ * has no single narrower element type — `<never, never>` rejects every real
328
+ * route (the handler's return type is contravariant into it).
329
+ */
330
+ readonly restRoutes?: ReadonlyArray<RestRouteDescriptor<any, any>>;
331
+ /** Procedures carrying a `publicApi:` annotation, with their executors. */
332
+ readonly publicApi?: ReadonlyArray<PublicApiBinding>;
333
+ /**
334
+ * The app's auth strategies, composed by the framework's own
335
+ * `composeAuthStrategies`. With none supplied the composer's DEFAULT applies:
336
+ * an anonymous subject carrying the `x-tenant` header's tenant — which is
337
+ * itself the behaviour most worth testing, and the one `invoke` cannot reach.
338
+ */
339
+ readonly strategies?: ReadonlyArray<AuthStrategy>;
340
+ /** Refuse an anonymous caller that carries no tenant (`Unauthenticated`).
341
+ * Passed straight to `composeAuthStrategies`. */
342
+ readonly anonymousTenantRequired?: boolean;
343
+ /** HTTP idempotency binding — the same `{ store, header, ttlMs }` the serve
344
+ * path builds from `app.config.ts`'s `idempotency` field. */
345
+ readonly idempotency?: RestServeBindings['idempotency'];
346
+ }
347
+
174
348
  export declare const makeTestContext: <RowFilterCtx = unknown>(options?: MakeTestContextOptions<RowFilterCtx>) => TestContext;
175
349
 
176
350
  export declare interface MakeTestContextOptions<RowFilterCtx = unknown> {
@@ -238,11 +412,40 @@ export declare interface MakeTestContextOptions<RowFilterCtx = unknown> {
238
412
  * the CONTEXT, not on `invoke`, because production composes the chain once
239
413
  * at boot — an app cannot vary its plugin set per call, so neither can this.
240
414
  *
241
- * Only the rpc interceptors are wired. Lifecycle hooks (`onActivate`),
242
- * `schema`, routes and dashboard mounts are boot concerns with no meaning
243
- * for a single handler call, and are ignored.
415
+ * Their `services:` LAYER is provided too `invoke` builds the same handler
416
+ * service stack the serve pipeline does, so an Effect-mode handler that
417
+ * `yield* MailService`s (or `StorageService`, or any tag a plugin owns)
418
+ * resolves it under test instead of dying with "Service not found". That is
419
+ * the whole reason the mail assertion surface is the plugin's own memory
420
+ * provider rather than a mock this package invents:
421
+ *
422
+ * ```ts
423
+ * import { mailPlugin, readMailBuffer, clearMailBuffer } from '@voltro/plugin-mail'
424
+ *
425
+ * const ctx = makeTestContext({ plugins: [mailPlugin({ provider: 'memory', from: 'a@b.c' })] })
426
+ * await invoke(sendWelcome, sendWelcomeHandler, { to: 'x@y.z' }, ctx)
427
+ * expect(readMailBuffer().map((m) => m.to)).toEqual(['x@y.z'])
428
+ * ```
429
+ *
430
+ * Lifecycle hooks (`onActivate`), `schema`, routes and dashboard mounts are
431
+ * boot concerns with no meaning for a single handler call, and are ignored —
432
+ * so a plugin whose service is built INSIDE `onActivate` will not resolve
433
+ * here. Every first-party plugin builds its service eagerly in the factory.
244
434
  */
245
435
  readonly plugins?: ReadonlyArray<VoltroPlugin>;
436
+ /**
437
+ * The app's own `layers:` from `app.config.ts` — user-defined Effect
438
+ * services a handler `yield*`s.
439
+ *
440
+ * Passed EXPLICITLY, never discovered: the harness runs no boot, so it
441
+ * cannot read `app.config.ts`, and inventing a stand-in service would assert
442
+ * against the stand-in. Handing over the real layer is not faking — it is the
443
+ * same object the boot path merges.
444
+ *
445
+ * Merged LAST, so a user layer overrides a plugin layer declaring the same
446
+ * Tag — the ordering `makeHandlerServiceLayer` uses on both boot paths.
447
+ */
448
+ readonly layers?: ReadonlyArray<Layer.Layer<unknown, never, never>>;
246
449
  /**
247
450
  * Row-level security for this context, WITHOUT touching the process global.
248
451
  *
@@ -280,21 +483,35 @@ export declare interface MockAi {
280
483
 
281
484
  export declare class MockClock {
282
485
  private currentMs;
283
- constructor(initial?: Date | number);
486
+ constructor(initial?: Date | number | string);
284
487
  /** Current mock time as an instant in ms. */
285
488
  now(): number;
286
489
  /** Current mock time as a Date. */
287
490
  date(): Date;
288
491
  /** Advance time by a duration (ms / seconds / minutes / hours / days). */
289
492
  advance(amount: number | string): void;
290
- }
291
-
292
- export declare class MockEmail {
293
- readonly sent: SentEmail[];
294
- send(email: Omit<SentEmail, 'sentAt'>): void;
295
- /** Find the most recent email to a recipient, or undefined. */
296
- lastTo(recipient: string): SentEmail | undefined;
297
- clear(): void;
493
+ /** Jump to an ABSOLUTE instant. `advance` moves relative; this is `travel_to`. */
494
+ set(at: Date | number | string): void;
495
+ /** Is THIS clock the one currently faking global time? */
496
+ get installed(): boolean;
497
+ /**
498
+ * Take over `Date.now()` / `new Date()` for the whole realm. Returns the
499
+ * uninstall call it, or prefer `withFrozenTime` / `frozenTime`, which call
500
+ * it for you even when the body throws.
501
+ *
502
+ * Throws if any clock is already installed. See the header: an inner
503
+ * uninstall would otherwise restore the outer FAKE and leave the realm frozen
504
+ * with nothing naming the cause.
505
+ */
506
+ install(): () => void;
507
+ /**
508
+ * Give the realm its real clock back.
509
+ *
510
+ * A no-op when this clock is not the installed one, so an uninstall in a
511
+ * `finally` is safe to run twice. The loud direction is the INSTALL — that is
512
+ * the one that corrupts the next test.
513
+ */
514
+ uninstall(): void;
298
515
  }
299
516
 
300
517
  export declare class MockLLM {
@@ -348,6 +565,19 @@ export declare class MockWebhooks {
348
565
  clear(): void;
349
566
  }
350
567
 
568
+ /**
569
+ * The next value of that same counter — for a caller writing their OWN unique
570
+ * default (`email: () => \`user-${nextSequence()}@test.local\``).
571
+ *
572
+ * Sharing ONE counter with the auto-filler is the point, not an implementation
573
+ * detail: two sources of "unique enough" numbers in one process will collide
574
+ * eventually, and the collision surfaces as a constraint violation in a test
575
+ * that looks unrelated to either. Monotonic per process, never reset — a reset
576
+ * between tests would re-issue values that rows from an earlier test in the
577
+ * same worker still hold.
578
+ */
579
+ export declare const nextSequence: () => number;
580
+
351
581
  /** Delivery nudges emitted through `ctx.outbox` on this context. */
352
582
  export declare const outboxNudgesOf: (ctx: TestContext) => ReadonlyArray<string>;
353
583
 
@@ -359,8 +589,22 @@ export { ParseResult }
359
589
  * framework's contract is "async or Effect, your choice per handler", and the
360
590
  * dispatcher runs both (`Effect.isEffect(result) ? … : …` in `servePipeline`).
361
591
  * `E` is the handler's typed error channel; it defaults to `never` for the
362
- * common `Effect.gen` that only succeeds. */
363
- export declare type ProcedureExecutor<Input, Output, E = never> = (input: Input, ctx: TestContext) => Output | Promise<Output> | Effect.Effect<Output, E>;
592
+ * common `Effect.gen` that only succeeds. `R` is the services the handler
593
+ * `yield*`s `MailService`, a plugin Tag, one of the app's own `layers:`.
594
+ * `invoke` provides those from the context's `plugins:` / `layers:`, so a
595
+ * handler with a non-`never` `R` is callable; it defaults to `never` for the
596
+ * common handler that resolves nothing. */
597
+ export declare type ProcedureExecutor<Input, Output, E = never, R = never> = (input: Input, ctx: TestContext) => Output | Promise<Output> | Effect.Effect<Output, E, R>;
598
+
599
+ /** One `publicApi`-annotated procedure plus the executor that implements it —
600
+ * the same descriptor/handler pair `invoke` takes. The harness runs the
601
+ * handler THROUGH `invoke`, so the procedure's `guards:`, input decode,
602
+ * mutation transaction and plugin interceptors all still apply underneath the
603
+ * REST hop. */
604
+ export declare interface PublicApiBinding {
605
+ readonly descriptor: PublicApiDescriptor;
606
+ readonly handler: (input: never, ctx: TestContext) => unknown;
607
+ }
364
608
 
365
609
  export declare interface ReachableTarget {
366
610
  readonly host: string;
@@ -430,12 +674,67 @@ export declare const runAfterCommit: (ctx: TestContext) => Promise<void>;
430
674
  */
431
675
  export declare const runInStoreTransaction: <T>(ctx: TestContext, work: (txCtx: TestContext) => Promise<T>) => Promise<T>;
432
676
 
433
- export declare interface SentEmail {
434
- readonly to: string;
435
- readonly template: string;
436
- readonly props?: Readonly<Record<string, unknown>>;
437
- readonly locale?: string;
438
- readonly sentAt: Date;
677
+ /** A machine subject scoped to ONE tenant (a per-tenant integration, a
678
+ * `storeForTenant` view). NOT the cross-tenant `system()` below. */
679
+ export declare const serviceAccount: (id: string, options?: TestSubjectOptions) => Subject;
680
+
681
+ /**
682
+ * The handler service layer for this context — every registered plugin's
683
+ * `services:` layer, then the app's own `layers:`, merged in that order.
684
+ *
685
+ * The ORDER is the serve pipeline's (`makeHandlerServiceLayer`): user layers
686
+ * last, so an app layer declaring the same Tag as a plugin's wins here exactly
687
+ * as it does in production. `undefined` when the context registered neither —
688
+ * a handler that `yield*`s a Tag nobody provided must still die with "Service
689
+ * not found", because that is what it would do at runtime.
690
+ *
691
+ * What is deliberately NOT in here: `Cache` / `Kv` / `AnalyticsSink` / the
692
+ * aggregate + standing-primitive registries / the outbound `HttpClient`. Those
693
+ * are built by the CLI at boot from an app's config, and `@voltro/testing`
694
+ * cannot depend on the CLI. Nothing about that is faked — a handler needing one
695
+ * gets the honest "Service not found" instead of a stand-in whose behaviour the
696
+ * test would then be asserting.
697
+ *
698
+ * Exported so a test can assert the layer set a context carries without going
699
+ * through a handler.
700
+ */
701
+ export declare const serviceLayerFor: (ctx: TestContext) => Layer.Layer<never, never, never> | undefined;
702
+
703
+ /**
704
+ * The CROSS-TENANT machine actor — cron, workflows, backfills. `tenantId` is
705
+ * `null` by construction and the tenant read-scope mixin reads that as "every
706
+ * tenant" rather than "no rows", so this is not a tenant-less `user()`.
707
+ * Defaults to `['admin:full']`, matching `systemSubject`.
708
+ */
709
+ export declare const system: (id?: string, scopes?: ReadonlyArray<string>) => Subject;
710
+
711
+ /** The tenant every `user()` / `apiKey()` / `serviceAccount()` belongs to
712
+ * unless told otherwise. Exported so a test can seed rows under the same
713
+ * tenant the default subject reads through. */
714
+ export declare const TEST_TENANT_ID = "test-tenant";
715
+
716
+ export declare interface TestApp {
717
+ /** A view of this app that resolves EVERY request to `subject`, bypassing the
718
+ * strategy chain — the same `resolveSubject` seam the serve pipeline fills
719
+ * with its own auth resolver. Use it to test what a given identity may do;
720
+ * use `withHeaders({ 'x-tenant': … })` (and no `actingAs`) to test how an
721
+ * identity is RESOLVED. */
722
+ readonly actingAs: (subject: Subject) => TestApp;
723
+ /** A view of this app that sends `headers` on every request. Merged over
724
+ * whatever is already set; later calls win per key. */
725
+ readonly withHeaders: (headers: Readonly<Record<string, string>>) => TestApp;
726
+ readonly get: (path: string, options?: TestRequestOptions) => Promise<TestResponse>;
727
+ readonly delete: (path: string, options?: TestRequestOptions) => Promise<TestResponse>;
728
+ readonly post: (path: string, body?: unknown, options?: TestRequestOptions) => Promise<TestResponse>;
729
+ readonly put: (path: string, body?: unknown, options?: TestRequestOptions) => Promise<TestResponse>;
730
+ readonly patch: (path: string, body?: unknown, options?: TestRequestOptions) => Promise<TestResponse>;
731
+ /** The general form the five verbs above are sugar over. */
732
+ readonly request: (method: string, path: string, options?: TestRequestOptions & {
733
+ readonly body?: unknown;
734
+ }) => Promise<TestResponse>;
735
+ /** Every path this app mounts, in mount order. A 404 names them, and a test
736
+ * asserting the mounted surface can read them without one. */
737
+ readonly paths: ReadonlyArray<string>;
439
738
  }
440
739
 
441
740
  /** The test context IS an `AppContext` (store + request + cache), plus the
@@ -444,7 +743,6 @@ export declare interface SentEmail {
444
743
  * `(input, ctx: AppContext) => …`. */
445
744
  export declare interface TestContext extends AppContext {
446
745
  readonly clock: MockClock;
447
- readonly email: MockEmail;
448
746
  /** Outgoing webhooks, recorded. `ctx.webhooks` is a field production supplies
449
747
  * and the harness did not, so a mutation written the documented way —
450
748
  * `useWebhooks(ctx).emit(...)` — threw in every unit test. */
@@ -517,6 +815,38 @@ export declare interface TestEventBusOptions {
517
815
 
518
816
  export declare const TESTING_PRESET_VERSION: 1;
519
817
 
818
+ export declare interface TestRequestOptions {
819
+ /** Extra headers for this call, merged over the app's (`actingAs` /
820
+ * `withHeaders`) headers. Names are lowercased, as the transport delivers
821
+ * them. */
822
+ readonly headers?: Readonly<Record<string, string>>;
823
+ }
824
+
825
+ export declare interface TestResponse {
826
+ readonly status: number;
827
+ /** Response headers, names lowercased. */
828
+ readonly headers: Readonly<Record<string, string>>;
829
+ /** The raw response body as text (empty string when there was none). */
830
+ readonly text: string;
831
+ /** The body parsed as JSON when the route answered with a JSON content-type,
832
+ * otherwise `undefined`. Every framework REST response is JSON; a streaming
833
+ * (SSE) route answers with neither — see `stream`. */
834
+ readonly body: unknown;
835
+ /** `true` for a streaming (SSE) route: the response has no buffered body, so
836
+ * `text` is empty and `body` is `undefined`. Stated rather than silently
837
+ * looking like an empty 200. */
838
+ readonly stream: boolean;
839
+ }
840
+
841
+ export declare interface TestSubjectOptions {
842
+ /** Default `TEST_TENANT_ID`. */
843
+ readonly tenantId?: string;
844
+ /** Default `[]` — no authority. Guards deny. */
845
+ readonly scopes?: ReadonlyArray<string>;
846
+ /** Strategy-specific claims (`metadata.provider`, org ids, raw JWT claims). */
847
+ readonly metadata?: Record<string, unknown>;
848
+ }
849
+
520
850
  export declare interface TestSubscriber<P = unknown> {
521
851
  /** Payloads delivered so far, in order. */
522
852
  readonly received: ReadonlyArray<P>;
@@ -532,6 +862,33 @@ export declare interface TestSubscriber<P = unknown> {
532
862
  readonly stop: () => void;
533
863
  }
534
864
 
865
+ /**
866
+ * An end-user subject — what a signed-in session resolves to.
867
+ *
868
+ * ```ts
869
+ * const ctx = makeTestContext({ subject: user('u1', { scopes: ['notes:write'] }) })
870
+ * const outsider = makeTestContext({ subject: user('u2') }) // no scopes → guards deny
871
+ * ```
872
+ */
873
+ export declare const user: (id: string, options?: TestSubjectOptions) => Subject;
874
+
875
+ /**
876
+ * Run `body` with global time frozen at `at`, then restore — whether the body
877
+ * returns, throws, resolves or rejects.
878
+ *
879
+ * ```ts
880
+ * withFrozenTime('2026-03-01T12:00:00Z', (clock) => {
881
+ * expect(new Date().toISOString()).toBe('2026-03-01T12:00:00.000Z')
882
+ * clock.advance('1h') // global time moves with it
883
+ * })
884
+ * ```
885
+ *
886
+ * An ASYNC body is awaited before the restore. A plain `finally` around a
887
+ * promise-returning call would put the clock back while the body was still
888
+ * running, which is the failure this helper exists to make impossible.
889
+ */
890
+ export declare const withFrozenTime: <A>(at: Date | number | string | MockClock, body: (clock: MockClock) => A) => A;
891
+
535
892
  /** A workflow + its execute function, as the framework pairs them via
536
893
  * `workflow.toLayer(execute)`. Structural (no `@effect/workflow` generic
537
894
  * signature) so callers can pass the value from `workflow({ name, ... })`