@fragno-dev/test 0.1.12 → 0.1.13

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/src/db-test.ts ADDED
@@ -0,0 +1,574 @@
1
+ import type { AnySchema } from "@fragno-dev/db/schema";
2
+ import type {
3
+ RequestThisContext,
4
+ FragnoPublicConfig,
5
+ FragmentInstantiationBuilder,
6
+ FragnoInstantiatedFragment,
7
+ FragmentDefinition,
8
+ } from "@fragno-dev/core";
9
+ import type { AnyRouteOrFactory, FlattenRouteFactories } from "@fragno-dev/core/route";
10
+ import {
11
+ createAdapter,
12
+ type SupportedAdapter,
13
+ type AdapterContext,
14
+ type SchemaConfig,
15
+ } from "./adapters";
16
+ import type { DatabaseAdapter } from "@fragno-dev/db";
17
+ import type { AbstractQuery } from "@fragno-dev/db/query";
18
+ import type { BaseTestContext } from ".";
19
+
20
+ // BoundServices is an internal type that strips 'this' parameters from service methods
21
+ // It's used to represent services after they've been bound to a context
22
+ type BoundServices<T> = {
23
+ [K in keyof T]: T[K] extends (this: any, ...args: infer A) => infer R // eslint-disable-line @typescript-eslint/no-explicit-any
24
+ ? (...args: A) => R
25
+ : T[K] extends Record<string, unknown>
26
+ ? BoundServices<T[K]>
27
+ : T[K];
28
+ };
29
+
30
+ // Extract the schema type from database fragment dependencies
31
+ // Database fragments have ImplicitDatabaseDependencies<TSchema> which includes `schema: TSchema`
32
+ type ExtractSchemaFromDeps<TDeps> = TDeps extends { schema: infer TSchema extends AnySchema }
33
+ ? TSchema
34
+ : AnySchema;
35
+
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ type AnyLinkedFragments = Record<string, any>;
38
+
39
+ // Forward declarations for recursive type references
40
+ interface FragmentResult<
41
+ TDeps,
42
+ TServices extends Record<string, unknown>,
43
+ TServiceThisContext extends RequestThisContext,
44
+ THandlerThisContext extends RequestThisContext,
45
+ TRequestStorage,
46
+ TRoutes extends readonly any[], // eslint-disable-line @typescript-eslint/no-explicit-any
47
+ TSchema extends AnySchema,
48
+ TLinkedFragments extends AnyLinkedFragments = {},
49
+ > {
50
+ fragment: FragnoInstantiatedFragment<
51
+ TRoutes,
52
+ TDeps,
53
+ TServices,
54
+ TServiceThisContext,
55
+ THandlerThisContext,
56
+ TRequestStorage,
57
+ FragnoPublicConfig,
58
+ TLinkedFragments
59
+ >;
60
+ services: TServices;
61
+ deps: TDeps;
62
+ callRoute: FragnoInstantiatedFragment<
63
+ TRoutes,
64
+ TDeps,
65
+ TServices,
66
+ TServiceThisContext,
67
+ THandlerThisContext,
68
+ TRequestStorage,
69
+ FragnoPublicConfig,
70
+ TLinkedFragments
71
+ >["callRoute"];
72
+ db: AbstractQuery<TSchema>;
73
+ }
74
+
75
+ // Safe: Catch-all for any fragment result type
76
+ type AnyFragmentResult = FragmentResult<
77
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
78
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
79
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
80
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
81
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
82
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
83
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
84
+ any // eslint-disable-line @typescript-eslint/no-explicit-any
85
+ >;
86
+
87
+ // Safe: Catch-all for any fragment builder config type
88
+ type AnyFragmentBuilderConfig = FragmentBuilderConfig<
89
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
90
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
91
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
92
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
93
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
94
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
95
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
96
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
97
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
98
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
99
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
100
+ any // eslint-disable-line @typescript-eslint/no-explicit-any
101
+ >;
102
+
103
+ /**
104
+ * Configuration for a single fragment in the test builder
105
+ */
106
+ interface FragmentBuilderConfig<
107
+ TConfig,
108
+ TOptions extends FragnoPublicConfig,
109
+ TDeps,
110
+ TBaseServices extends Record<string, unknown>,
111
+ TServices extends Record<string, unknown>,
112
+ TServiceDependencies,
113
+ TPrivateServices extends Record<string, unknown>,
114
+ TServiceThisContext extends RequestThisContext,
115
+ THandlerThisContext extends RequestThisContext,
116
+ TRequestStorage,
117
+ TRoutesOrFactories extends readonly AnyRouteOrFactory[],
118
+ TLinkedFragments extends AnyLinkedFragments,
119
+ > {
120
+ definition: FragmentDefinition<
121
+ TConfig,
122
+ TOptions,
123
+ TDeps,
124
+ TBaseServices,
125
+ TServices,
126
+ TServiceDependencies,
127
+ TPrivateServices,
128
+ TServiceThisContext,
129
+ THandlerThisContext,
130
+ TRequestStorage,
131
+ TLinkedFragments
132
+ >;
133
+ builder: FragmentInstantiationBuilder<
134
+ TConfig,
135
+ TOptions,
136
+ TDeps,
137
+ TBaseServices,
138
+ TServices,
139
+ TServiceDependencies,
140
+ TPrivateServices,
141
+ TServiceThisContext,
142
+ THandlerThisContext,
143
+ TRequestStorage,
144
+ TRoutesOrFactories,
145
+ TLinkedFragments
146
+ >;
147
+ migrateToVersion?: number;
148
+ }
149
+
150
+ /**
151
+ * Test context combining base and adapter-specific functionality
152
+ */
153
+ type TestContext<
154
+ T extends SupportedAdapter,
155
+ TFirstFragmentThisContext extends RequestThisContext = RequestThisContext,
156
+ > = BaseTestContext &
157
+ AdapterContext<T> & {
158
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
+ adapter: DatabaseAdapter<any>;
160
+ /**
161
+ * Execute a callback within the first fragment's request context.
162
+ * This is useful for calling services outside of route handlers in tests.
163
+ */
164
+ inContext<TResult>(callback: (this: TFirstFragmentThisContext) => TResult): TResult;
165
+ inContext<TResult>(
166
+ callback: (this: TFirstFragmentThisContext) => Promise<TResult>,
167
+ ): Promise<TResult>;
168
+ };
169
+
170
+ /**
171
+ * Result of building the database fragments test
172
+ */
173
+ interface DatabaseFragmentsTestResult<
174
+ TFragments extends Record<string, AnyFragmentResult>,
175
+ TAdapter extends SupportedAdapter,
176
+ TFirstFragmentThisContext extends RequestThisContext = RequestThisContext,
177
+ > {
178
+ fragments: TFragments;
179
+ test: TestContext<TAdapter, TFirstFragmentThisContext>;
180
+ }
181
+
182
+ /**
183
+ * Internal storage for fragment configurations
184
+ */
185
+ type FragmentConfigMap = Map<string, AnyFragmentBuilderConfig>;
186
+
187
+ /**
188
+ * Builder for creating multiple database fragments for testing
189
+ */
190
+ export class DatabaseFragmentsTestBuilder<
191
+ TFragments extends Record<string, AnyFragmentResult>,
192
+ TAdapter extends SupportedAdapter | undefined = undefined,
193
+ TFirstFragmentThisContext extends RequestThisContext = RequestThisContext,
194
+ > {
195
+ #adapter?: SupportedAdapter;
196
+ #fragments: FragmentConfigMap = new Map();
197
+
198
+ /**
199
+ * Set the test adapter configuration
200
+ */
201
+ withTestAdapter<TNewAdapter extends SupportedAdapter>(
202
+ adapter: TNewAdapter,
203
+ ): DatabaseFragmentsTestBuilder<TFragments, TNewAdapter, TFirstFragmentThisContext> {
204
+ this.#adapter = adapter;
205
+ return this as any; // eslint-disable-line @typescript-eslint/no-explicit-any
206
+ }
207
+
208
+ /**
209
+ * Add a fragment to the test setup
210
+ *
211
+ * @param name - Unique name for the fragment
212
+ * @param builder - Pre-configured instantiation builder
213
+ * @param options - Additional options (optional)
214
+ */
215
+ withFragment<
216
+ TName extends string,
217
+ TConfig,
218
+ TOptions extends FragnoPublicConfig,
219
+ TDeps,
220
+ TBaseServices extends Record<string, unknown>,
221
+ TServices extends Record<string, unknown>,
222
+ TServiceDependencies,
223
+ TPrivateServices extends Record<string, unknown>,
224
+ TServiceThisContext extends RequestThisContext,
225
+ THandlerThisContext extends RequestThisContext,
226
+ TRequestStorage,
227
+ TRoutesOrFactories extends readonly AnyRouteOrFactory[],
228
+ TLinkedFragments extends AnyLinkedFragments,
229
+ >(
230
+ name: TName,
231
+ builder: FragmentInstantiationBuilder<
232
+ TConfig,
233
+ TOptions,
234
+ TDeps,
235
+ TBaseServices,
236
+ TServices,
237
+ TServiceDependencies,
238
+ TPrivateServices,
239
+ TServiceThisContext,
240
+ THandlerThisContext,
241
+ TRequestStorage,
242
+ TRoutesOrFactories,
243
+ TLinkedFragments
244
+ >,
245
+ options?: {
246
+ migrateToVersion?: number;
247
+ },
248
+ ): DatabaseFragmentsTestBuilder<
249
+ TFragments & {
250
+ [K in TName]: FragmentResult<
251
+ TDeps,
252
+ BoundServices<TBaseServices & TServices>,
253
+ TServiceThisContext,
254
+ THandlerThisContext,
255
+ TRequestStorage,
256
+ FlattenRouteFactories<TRoutesOrFactories>,
257
+ ExtractSchemaFromDeps<TDeps>, // Extract actual schema type from deps
258
+ TLinkedFragments
259
+ >;
260
+ },
261
+ TAdapter,
262
+ // If this is the first fragment (TFragments is empty {}), use THandlerThisContext; otherwise keep existing
263
+ keyof TFragments extends never ? THandlerThisContext : TFirstFragmentThisContext
264
+ > {
265
+ this.#fragments.set(name, {
266
+ definition: builder.definition,
267
+ builder,
268
+ migrateToVersion: options?.migrateToVersion,
269
+ });
270
+ return this as any; // eslint-disable-line @typescript-eslint/no-explicit-any
271
+ }
272
+
273
+ /**
274
+ * Build the test setup and return fragments and test context
275
+ */
276
+ async build(): Promise<
277
+ TAdapter extends SupportedAdapter
278
+ ? DatabaseFragmentsTestResult<TFragments, TAdapter, TFirstFragmentThisContext>
279
+ : never
280
+ > {
281
+ if (!this.#adapter) {
282
+ throw new Error("Test adapter must be set using withTestAdapter()");
283
+ }
284
+
285
+ if (this.#fragments.size === 0) {
286
+ throw new Error("At least one fragment must be added using withFragment()");
287
+ }
288
+
289
+ const adapterConfig = this.#adapter;
290
+
291
+ // Extract fragment names and configs
292
+ const fragmentNames = Array.from(this.#fragments.keys());
293
+ const fragmentConfigs = Array.from(this.#fragments.values());
294
+
295
+ // Extract schemas from definitions and prepare schema configs
296
+ const schemaConfigs: SchemaConfig[] = [];
297
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
298
+ const builderConfigs: Array<{ config: any; routes: any; options: any }> = [];
299
+
300
+ for (const fragmentConfig of fragmentConfigs) {
301
+ const builder = fragmentConfig.builder;
302
+ const definition = builder.definition;
303
+
304
+ // Extract schema from definition by calling dependencies with a mock adapter
305
+ let schema: AnySchema | undefined;
306
+ const namespace = definition.name + "-db";
307
+
308
+ if (definition.dependencies) {
309
+ try {
310
+ // Create a mock adapter to extract the schema
311
+ const mockAdapter = {
312
+ createQueryEngine: () => ({ schema: null }),
313
+ contextStorage: { run: (_data: unknown, fn: () => unknown) => fn() },
314
+ };
315
+
316
+ // Use the actual config from the builder instead of an empty mock
317
+ // This ensures dependencies can be properly initialized (e.g., Stripe with API keys)
318
+ const actualConfig = builder.config ?? {};
319
+
320
+ const deps = definition.dependencies({
321
+ config: actualConfig,
322
+ options: {
323
+ databaseAdapter: mockAdapter as any, // eslint-disable-line @typescript-eslint/no-explicit-any
324
+ } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
325
+ });
326
+
327
+ // The schema is in deps.schema for database fragments
328
+ if (deps && typeof deps === "object" && "schema" in deps) {
329
+ schema = (deps as any).schema; // eslint-disable-line @typescript-eslint/no-explicit-any
330
+ }
331
+ } catch (error) {
332
+ // If extraction fails, provide a helpful error message
333
+ const errorMessage =
334
+ error instanceof Error
335
+ ? error.message
336
+ : typeof error === "string"
337
+ ? error
338
+ : "Unknown error";
339
+
340
+ throw new Error(
341
+ `Failed to extract schema from fragment '${definition.name}'.\n` +
342
+ `Original error: ${errorMessage}\n\n` +
343
+ `Make sure the fragment is a database fragment using defineFragment().extend(withDatabase(schema)).`,
344
+ );
345
+ }
346
+ }
347
+
348
+ if (!schema) {
349
+ throw new Error(
350
+ `Fragment '${definition.name}' does not have a database schema. ` +
351
+ `Make sure you're using defineFragment().extend(withDatabase(schema)).`,
352
+ );
353
+ }
354
+
355
+ schemaConfigs.push({
356
+ schema,
357
+ namespace,
358
+ migrateToVersion: fragmentConfig.migrateToVersion,
359
+ });
360
+
361
+ // Extract config, routes, and options from builder using public getters
362
+ builderConfigs.push({
363
+ config: builder.config ?? {},
364
+ routes: builder.routes ?? [],
365
+ options: builder.options ?? {},
366
+ });
367
+ }
368
+
369
+ // Create adapter with all schemas
370
+ const { testContext, adapter } = await createAdapter(adapterConfig, schemaConfigs);
371
+
372
+ // Helper to create fragments with service wiring
373
+ const createFragments = () => {
374
+ // First pass: create fragments without service dependencies to extract provided services
375
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
376
+ const providedServicesByName: Record<string, { service: any; orm: any }> = {};
377
+ const fragmentResults: any[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any
378
+
379
+ for (let i = 0; i < fragmentConfigs.length; i++) {
380
+ const fragmentConfig = fragmentConfigs[i]!;
381
+ const builderConfig = builderConfigs[i]!;
382
+ const namespace = schemaConfigs[i]!.namespace;
383
+ const schema = schemaConfigs[i]!.schema;
384
+ const orm = testContext.getOrm(namespace);
385
+
386
+ // Merge builder options with database adapter
387
+ const mergedOptions = {
388
+ ...builderConfig.options,
389
+ databaseAdapter: adapter,
390
+ };
391
+
392
+ // Instantiate fragment using the builder
393
+ const fragment = fragmentConfig.builder.withOptions(mergedOptions).build();
394
+
395
+ // Extract provided services based on serviceDependencies metadata
396
+ // Note: serviceDependencies lists services this fragment USES, not provides
397
+ // For provided services, we need to check what's actually in fragment.services
398
+ // and match against other fragments' service dependencies
399
+
400
+ // Store all services as potentially provided
401
+ for (const [serviceName, serviceImpl] of Object.entries(fragment.services)) {
402
+ providedServicesByName[serviceName] = {
403
+ service: serviceImpl,
404
+ orm,
405
+ };
406
+ }
407
+
408
+ // Store the fragment result
409
+ const deps = fragment.$internal?.deps;
410
+
411
+ fragmentResults.push({
412
+ fragment,
413
+ services: fragment.services,
414
+ deps: deps || {},
415
+ callRoute: fragment.callRoute.bind(fragment),
416
+ get db() {
417
+ return orm;
418
+ },
419
+ _orm: orm,
420
+ _schema: schema,
421
+ });
422
+ }
423
+
424
+ // Second pass: rebuild fragments with service dependencies wired up
425
+ for (let i = 0; i < fragmentConfigs.length; i++) {
426
+ const fragmentConfig = fragmentConfigs[i]!;
427
+ const definition = fragmentConfig.builder.definition;
428
+ const builderConfig = builderConfigs[i]!;
429
+ const namespace = schemaConfigs[i]!.namespace;
430
+ const schema = schemaConfigs[i]!.schema;
431
+ const orm = testContext.getOrm(namespace);
432
+
433
+ // Build service implementations for services this fragment uses
434
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
+ const serviceImplementations: Record<string, any> = {};
436
+ const serviceDependencies = definition.serviceDependencies;
437
+
438
+ if (serviceDependencies) {
439
+ for (const serviceName of Object.keys(serviceDependencies)) {
440
+ if (providedServicesByName[serviceName]) {
441
+ serviceImplementations[serviceName] = providedServicesByName[serviceName]!.service;
442
+ }
443
+ }
444
+ }
445
+
446
+ // Merge builder options with database adapter
447
+ const mergedOptions = {
448
+ ...builderConfig.options,
449
+ databaseAdapter: adapter,
450
+ };
451
+
452
+ // Rebuild the fragment with service implementations using the builder
453
+ const fragment = fragmentConfig.builder
454
+ .withOptions(mergedOptions)
455
+ .withServices(serviceImplementations as any) // eslint-disable-line @typescript-eslint/no-explicit-any
456
+ .build();
457
+
458
+ // Update the result
459
+ // Access deps from the internal property
460
+ const deps = fragment.$internal?.deps;
461
+
462
+ fragmentResults[i] = {
463
+ fragment,
464
+ services: fragment.services,
465
+ deps: deps || {},
466
+ callRoute: fragment.callRoute.bind(fragment),
467
+ get db() {
468
+ return orm;
469
+ },
470
+ _orm: orm,
471
+ _schema: schema,
472
+ };
473
+ }
474
+
475
+ return fragmentResults;
476
+ };
477
+
478
+ const fragmentResults = createFragments();
479
+
480
+ // Wrap resetDatabase to also recreate all fragments
481
+ const originalResetDatabase = testContext.resetDatabase;
482
+ const resetDatabase = async () => {
483
+ await originalResetDatabase();
484
+
485
+ // Recreate all fragments with service wiring
486
+ const newFragmentResults = createFragments();
487
+
488
+ // Update the result objects
489
+ newFragmentResults.forEach((newResult, index) => {
490
+ const result = fragmentResults[index]!;
491
+ result.fragment = newResult.fragment;
492
+ result.services = newResult.services;
493
+ result.deps = newResult.deps;
494
+ result.callRoute = newResult.callRoute;
495
+ result._orm = newResult._orm;
496
+ });
497
+ };
498
+
499
+ // Get the first fragment's inContext method
500
+ const firstFragment = fragmentResults[0]?.fragment;
501
+ if (!firstFragment) {
502
+ throw new Error("At least one fragment must be added");
503
+ }
504
+
505
+ const finalTestContext = {
506
+ ...testContext,
507
+ resetDatabase,
508
+ adapter,
509
+ inContext: firstFragment.inContext.bind(firstFragment),
510
+ };
511
+
512
+ // Build result object with named fragments
513
+ const fragmentsObject = Object.fromEntries(
514
+ fragmentNames.map((name, index) => [name, fragmentResults[index]]),
515
+ );
516
+
517
+ // Safe cast: We've already validated that adapterConfig is SupportedAdapter at the beginning of build()
518
+ // TypeScript can't infer this through the conditional return type, so we use 'as any'
519
+ return {
520
+ fragments: fragmentsObject as TFragments,
521
+ test: finalTestContext,
522
+ } as any; // eslint-disable-line @typescript-eslint/no-explicit-any
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Create a builder for setting up multiple database fragments for testing.
528
+ * This is the new builder-based API that works with the new fragment instantiation builders.
529
+ *
530
+ * @example
531
+ * ```typescript
532
+ * const userFragmentDef = defineFragment("user")
533
+ * .extend(withDatabase(userSchema))
534
+ * .withDependencies(...)
535
+ * .build();
536
+ *
537
+ * const postFragmentDef = defineFragment("post")
538
+ * .extend(withDatabase(postSchema))
539
+ * .withDependencies(...)
540
+ * .build();
541
+ *
542
+ * const { fragments, test } = await buildDatabaseFragmentsTest()
543
+ * .withTestAdapter({ type: "kysely-sqlite" })
544
+ * .withFragment("user",
545
+ * instantiate(userFragmentDef)
546
+ * .withConfig({ ... })
547
+ * .withRoutes([...])
548
+ * )
549
+ * .withFragment("post",
550
+ * instantiate(postFragmentDef)
551
+ * .withRoutes([...])
552
+ * )
553
+ * .build();
554
+ *
555
+ * // Access fragments by name
556
+ * await fragments.user.services.createUser(...);
557
+ * await fragments.post.services.createPost(...);
558
+ *
559
+ * // Access dependencies directly
560
+ * const userDeps = fragments.user.deps;
561
+ *
562
+ * // Shared test context
563
+ * await test.resetDatabase();
564
+ * await test.cleanup();
565
+ * const adapter = test.adapter; // Access the database adapter
566
+ * ```
567
+ */
568
+ export function buildDatabaseFragmentsTest(): DatabaseFragmentsTestBuilder<
569
+ {},
570
+ undefined,
571
+ RequestThisContext
572
+ > {
573
+ return new DatabaseFragmentsTestBuilder();
574
+ }