@voltro/testing 0.33.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/CHANGELOG.md +1801 -0
- package/README.md +1 -1
- package/dist/client.d.ts +49 -2
- package/dist/client.js +55 -45
- package/dist/dialect.js +192 -154
- package/dist/index.d.ts +378 -21
- package/dist/index.js +308 -112
- package/package.json +9 -9
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
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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, ... })`
|