@ontrails/testing 1.0.0-beta.10 → 1.0.0-beta.12

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.
@@ -1,6 +1,6 @@
1
1
  import { describe, test } from 'bun:test';
2
2
 
3
- import { NotFoundError, Result, trail, topo } from '@ontrails/core';
3
+ import { NotFoundError, Result, service, trail, topo } from '@ontrails/core';
4
4
  import { z } from 'zod';
5
5
 
6
6
  import { testExamples } from '../examples.js';
@@ -67,6 +67,136 @@ const noExamplesTrail = trail('noexamples', {
67
67
  run: (input: { x: number }) => Result.ok(input.x * 2),
68
68
  });
69
69
 
70
+ const mockDbService = service('db.mock.examples', {
71
+ create: () => Result.ok({ source: 'factory' }),
72
+ mock: () => ({ source: 'mock' }),
73
+ });
74
+
75
+ const mockServiceTrail = trail('service.mocked', {
76
+ description: 'Trail that reads from a mocked service',
77
+ examples: [
78
+ {
79
+ expected: { source: 'mock' },
80
+ input: {},
81
+ name: 'Uses auto-resolved service mock',
82
+ },
83
+ ],
84
+ input: z.object({}),
85
+ output: z.object({ source: z.string() }),
86
+ run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
87
+ services: [mockDbService],
88
+ });
89
+
90
+ const explicitOverrideTrail = trail('service.override', {
91
+ description: 'Trail whose service mock can be overridden explicitly',
92
+ examples: [
93
+ {
94
+ expected: { source: 'override' },
95
+ input: {},
96
+ name: 'Explicit service override wins over mock factory',
97
+ },
98
+ ],
99
+ input: z.object({}),
100
+ output: z.object({ source: z.string() }),
101
+ run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
102
+ services: [mockDbService],
103
+ });
104
+
105
+ const transformedInputTrail = trail('example.transformed', {
106
+ description: 'Trail whose input schema transforms once',
107
+ examples: [
108
+ {
109
+ expected: { value: 2 },
110
+ input: { value: '1' },
111
+ name: 'Raw example input is only transformed once',
112
+ },
113
+ ],
114
+ input: z
115
+ .object({ value: z.string() })
116
+ .transform(({ value }) => ({ value: Number(value) + 1 })),
117
+ output: z.object({ value: z.number() }),
118
+ run: (input: { value: number }) => Result.ok({ value: input.value }),
119
+ });
120
+
121
+ const ctxOverrideTrail = trail('service.ctx-override', {
122
+ description: 'Trail whose service mock can be overridden by ctx.extensions',
123
+ examples: [
124
+ {
125
+ expected: { source: 'ctx' },
126
+ input: {},
127
+ name: 'Context extensions beat auto-resolved mock services',
128
+ },
129
+ ],
130
+ input: z.object({}),
131
+ output: z.object({ source: z.string() }),
132
+ run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
133
+ services: [mockDbService],
134
+ });
135
+
136
+ const undeclaredDbService = service('db.undeclared.examples', {
137
+ create: () => Result.ok({ source: 'factory' }),
138
+ mock: () => ({ source: 'mock' }),
139
+ });
140
+
141
+ const undeclaredServiceTrail = trail('service.undeclared.examples', {
142
+ description: 'Trail that uses a service without declaring it',
143
+ examples: [
144
+ {
145
+ error: 'InternalError',
146
+ input: {},
147
+ name: 'Undeclared services stay unavailable during example execution',
148
+ },
149
+ ],
150
+ input: z.object({}),
151
+ output: z.object({ source: z.string() }),
152
+ run: (_input, ctx) =>
153
+ Result.ok({ source: undeclaredDbService.from(ctx).source }),
154
+ });
155
+ const followedDbService = service('db.mock.examples.follow', {
156
+ create: () => Result.ok({ source: 'factory' }),
157
+ mock: () => ({ source: 'mock' }),
158
+ });
159
+
160
+ const followedLeafTrail = trail('service.follow.leaf', {
161
+ description: 'Leaf trail that resolves a service inside a follow chain',
162
+ input: z.object({}),
163
+ output: z.object({ childSource: z.string() }),
164
+ run: (_input, ctx) =>
165
+ Result.ok({ childSource: followedDbService.from(ctx).source }),
166
+ services: [followedDbService],
167
+ });
168
+
169
+ const followedRootTrail = trail('service.follow.root', {
170
+ description: 'Root trail that follows a child trail using services',
171
+ examples: [
172
+ {
173
+ expected: { childSource: 'mock', rootSource: 'mock' },
174
+ input: {},
175
+ name: 'Propagates service mocks through follow execution',
176
+ },
177
+ ],
178
+ follow: ['service.follow.leaf'],
179
+ input: z.object({}),
180
+ output: z.object({ childSource: z.string(), rootSource: z.string() }),
181
+ run: async (_input, ctx) => {
182
+ if (!ctx.follow) {
183
+ return Result.err(new Error('follow not available'));
184
+ }
185
+ const childResult = await ctx.follow<{ childSource: string }>(
186
+ 'service.follow.leaf',
187
+ {}
188
+ );
189
+ if (childResult.isErr()) {
190
+ return childResult;
191
+ }
192
+ return Result.ok({
193
+ childSource: childResult.value.childSource,
194
+ rootSource: followedDbService.from(ctx).source,
195
+ });
196
+ },
197
+ services: [followedDbService],
198
+ });
199
+
70
200
  // ---------------------------------------------------------------------------
71
201
  // Composition trails (for follow coverage)
72
202
  // ---------------------------------------------------------------------------
@@ -163,6 +293,63 @@ describe('testExamples skips trails with no examples', () => {
163
293
  });
164
294
  });
165
295
 
296
+ describe('testExamples service mocks', () => {
297
+ // eslint-disable-next-line jest/require-hook
298
+ testExamples(
299
+ topo('service-mock-app', {
300
+ mockDbService,
301
+ mockServiceTrail,
302
+ } as Record<string, unknown>)
303
+ );
304
+ });
305
+
306
+ describe('testExamples explicit service overrides', () => {
307
+ // eslint-disable-next-line jest/require-hook
308
+ testExamples(
309
+ topo('service-override-app', {
310
+ explicitOverrideTrail,
311
+ mockDbService,
312
+ } as Record<string, unknown>),
313
+ {
314
+ services: { 'db.mock.examples': { source: 'override' } },
315
+ }
316
+ );
317
+ });
318
+
319
+ describe('testExamples raw transformed input', () => {
320
+ // eslint-disable-next-line jest/require-hook
321
+ testExamples(
322
+ topo('transformed-input-app', {
323
+ transformedInputTrail,
324
+ } as Record<string, unknown>)
325
+ );
326
+ });
327
+
328
+ describe('testExamples context extension overrides', () => {
329
+ // eslint-disable-next-line jest/require-hook
330
+ testExamples(
331
+ topo('ctx-override-app', {
332
+ ctxOverrideTrail,
333
+ mockDbService,
334
+ } as Record<string, unknown>),
335
+ {
336
+ ctx: {
337
+ extensions: { 'db.mock.examples': { source: 'ctx' } },
338
+ },
339
+ }
340
+ );
341
+ });
342
+
343
+ describe('testExamples service declarations', () => {
344
+ // eslint-disable-next-line jest/require-hook
345
+ testExamples(
346
+ topo('undeclared-service-app', {
347
+ undeclaredDbService,
348
+ undeclaredServiceTrail,
349
+ } as Record<string, unknown>)
350
+ );
351
+ });
352
+
166
353
  describe('testExamples follow coverage for composition trails', () => {
167
354
  // eslint-disable-next-line jest/require-hook
168
355
  testExamples(
@@ -173,3 +360,174 @@ describe('testExamples follow coverage for composition trails', () => {
173
360
  } as Record<string, unknown>)
174
361
  );
175
362
  });
363
+
364
+ describe('testExamples service mocks through follow', () => {
365
+ // eslint-disable-next-line jest/require-hook
366
+ testExamples(
367
+ topo('service-follow-app', {
368
+ followedDbService,
369
+ followedLeafTrail,
370
+ followedRootTrail,
371
+ } as Record<string, unknown>)
372
+ );
373
+ });
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Nested follow chain: A → B → C
377
+ // ---------------------------------------------------------------------------
378
+
379
+ const leafTrail = trail('step.leaf', {
380
+ description: 'Leaf trail in a nested chain',
381
+ input: z.object({ value: z.string() }),
382
+ output: z.object({ leaf: z.string() }),
383
+ run: (input: { value: string }) => Result.ok({ leaf: input.value }),
384
+ });
385
+
386
+ const middleTrail = trail('step.middle', {
387
+ description: 'Middle trail that follows the leaf',
388
+ follow: ['step.leaf'],
389
+ input: z.object({ value: z.string() }),
390
+ output: z.object({ middle: z.string() }),
391
+ run: async (input: { value: string }, ctx) => {
392
+ if (!ctx.follow) {
393
+ return Result.err(new Error('follow not available'));
394
+ }
395
+ const leafResult = await ctx.follow<{ leaf: string }>('step.leaf', input);
396
+ if (leafResult.isErr()) {
397
+ return leafResult;
398
+ }
399
+ return Result.ok({ middle: leafResult.value.leaf });
400
+ },
401
+ });
402
+
403
+ const rootTrail = trail('step.root', {
404
+ description: 'Root trail that follows the middle trail',
405
+ examples: [
406
+ {
407
+ expected: { root: 'hello' },
408
+ input: { value: 'hello' },
409
+ name: 'Nested follow chain A→B→C',
410
+ },
411
+ ],
412
+ follow: ['step.middle'],
413
+ input: z.object({ value: z.string() }),
414
+ output: z.object({ root: z.string() }),
415
+ run: async (input: { value: string }, ctx) => {
416
+ if (!ctx.follow) {
417
+ return Result.err(new Error('follow not available'));
418
+ }
419
+ const midResult = await ctx.follow<{ middle: string }>(
420
+ 'step.middle',
421
+ input
422
+ );
423
+ if (midResult.isErr()) {
424
+ return midResult;
425
+ }
426
+ return Result.ok({ root: midResult.value.middle });
427
+ },
428
+ });
429
+
430
+ describe('testExamples nested follow chain (A → B → C)', () => {
431
+ // eslint-disable-next-line jest/require-hook
432
+ testExamples(
433
+ topo('nested-chain-app', {
434
+ leafTrail,
435
+ middleTrail,
436
+ rootTrail,
437
+ } as Record<string, unknown>)
438
+ );
439
+ });
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Auto-minting permit tests (B3)
443
+ // ---------------------------------------------------------------------------
444
+
445
+ const scopedTrail = trail('scoped.trail', {
446
+ description: 'Trail requiring admin scope',
447
+ examples: [
448
+ {
449
+ expected: { ok: true },
450
+ input: {},
451
+ name: 'Runs with auto-minted permit',
452
+ },
453
+ ],
454
+ input: z.object({}),
455
+ output: z.object({ ok: z.boolean() }),
456
+ permit: { scopes: ['admin'] },
457
+ run: (_input, ctx) => {
458
+ // Verify the permit was auto-minted with declared scopes
459
+ const permit = ctx.permit as
460
+ | { id: string; scopes: readonly string[] }
461
+ | undefined;
462
+ if (!permit || !permit.scopes.includes('admin')) {
463
+ return Result.err(new Error('Missing permit or scopes'));
464
+ }
465
+ return Result.ok({ ok: true });
466
+ },
467
+ });
468
+
469
+ const publicTrail = trail('public.trail', {
470
+ description: 'Public trail — no permit needed',
471
+ examples: [
472
+ {
473
+ expected: { ok: true },
474
+ input: {},
475
+ name: 'Runs without a permit',
476
+ },
477
+ ],
478
+ input: z.object({}),
479
+ output: z.object({ ok: z.boolean() }),
480
+ permit: 'public',
481
+ run: (_input, ctx) => {
482
+ // Public trail should NOT get a permit
483
+ if (ctx.permit !== undefined) {
484
+ return Result.err(new Error('Unexpected permit on public trail'));
485
+ }
486
+ return Result.ok({ ok: true });
487
+ },
488
+ });
489
+
490
+ describe('testExamples auto-minting permits', () => {
491
+ describe('scoped trail gets auto-minted permit', () => {
492
+ // eslint-disable-next-line jest/require-hook
493
+ testExamples(
494
+ topo('mint-scoped-app', {
495
+ scopedTrail,
496
+ } as Record<string, unknown>)
497
+ );
498
+ });
499
+
500
+ describe('public trail does NOT get a permit', () => {
501
+ // eslint-disable-next-line jest/require-hook
502
+ testExamples(
503
+ topo('mint-public-app', {
504
+ publicTrail,
505
+ } as Record<string, unknown>)
506
+ );
507
+ });
508
+
509
+ describe('strictPermits skips auto-minting', () => {
510
+ const strictScopedTrail = trail('strict.scoped', {
511
+ description: 'Trail that expects no permit under strictPermits',
512
+ examples: [
513
+ {
514
+ expected: { hasPermit: false },
515
+ input: {},
516
+ name: 'No auto-minted permit when strictPermits is true',
517
+ },
518
+ ],
519
+ input: z.object({}),
520
+ output: z.object({ hasPermit: z.boolean() }),
521
+ permit: { scopes: ['admin'] },
522
+ run: (_input, ctx) => Result.ok({ hasPermit: ctx.permit !== undefined }),
523
+ });
524
+
525
+ // eslint-disable-next-line jest/require-hook
526
+ testExamples(
527
+ topo('strict-app', {
528
+ strictScopedTrail,
529
+ } as Record<string, unknown>),
530
+ { strictPermits: true }
531
+ );
532
+ });
533
+ });