@cosmicdrift/kumiko-testing 0.387.0 → 0.388.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.
@@ -1,9 +1,5 @@
1
1
  // @runtime dev
2
- import { createHash } from "node:crypto";
3
- import type { AuthRoutesConfig, JwtHelper } from "@cosmicdrift/kumiko-framework/api";
4
- import { buildServer, createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
5
- import type { DbConnection, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
6
- import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { AuthRoutesConfig, JwtHelper, SseBroker } from "@cosmicdrift/kumiko-framework/api";
7
3
  import type {
8
4
  AppContext,
9
5
  FeatureDefinition,
@@ -12,56 +8,30 @@ import type {
12
8
  TenantId,
13
9
  } from "@cosmicdrift/kumiko-framework/engine/types";
14
10
  import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
15
- import {
16
- createArchivedStreamsTable,
17
- createEventsTable,
18
- } from "@cosmicdrift/kumiko-framework/event-store";
19
11
  import type { Lifecycle } from "@cosmicdrift/kumiko-framework/internal/stack-support";
20
- import {
21
- createRateLimitResolver,
22
- unsafePushTables,
23
- validateNavBootSubset,
24
- validateOwnershipBoot,
25
- } from "@cosmicdrift/kumiko-framework/internal/stack-support";
26
- import {
27
- createJobRunner,
28
- type JobRunner,
29
- type JobRunnerOptions,
30
- } from "@cosmicdrift/kumiko-framework/jobs";
31
- import {
32
- createNoopProvider,
33
- type ObservabilityProvider,
34
- } from "@cosmicdrift/kumiko-framework/observability";
12
+ import type { JobRunner, JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
13
+ import type { ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
35
14
  import type {
36
15
  Dispatcher,
37
16
  EventDispatcher,
38
17
  SystemHooks,
39
18
  } from "@cosmicdrift/kumiko-framework/pipeline";
40
- import {
41
- createEntityCache,
42
- createEventDedup,
43
- createIdempotencyGuard,
44
- dispatcherToWriteRef,
45
- } from "@cosmicdrift/kumiko-framework/pipeline";
46
- import {
47
- type ClosableCacheSyncBus,
48
- createLocalCacheSyncBus,
49
- createRedisCacheSyncBus,
50
- } from "@cosmicdrift/kumiko-framework/redis";
19
+ import type { ClosableCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
51
20
  import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
52
- import { createInMemorySearchAdapter } from "@cosmicdrift/kumiko-framework/search";
53
- import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
54
21
  import type { Hono } from "hono";
22
+ import { createJobFailureTracker } from "./drain-jobs.ts";
23
+ import type { EventCollector } from "./event-collector.ts";
24
+ import type { TestRedis } from "./redis.ts";
25
+ import type { RequestHelper } from "./request-helper.ts";
55
26
  import {
56
- createTestDb,
57
- ensureTestDbTemplate,
58
- TEST_DB_TEMPLATE_RUN_ENV,
59
- testDbTemplatePrefix,
60
- } from "./db.ts";
61
- import { createJobFailureTracker, drainJobs } from "./drain-jobs.ts";
62
- import { createEventCollector, type EventCollector } from "./event-collector.ts";
63
- import { createTestRedis, queueNamePrefixForTestRedis, type TestRedis } from "./redis.ts";
64
- import { createRequestHelper, type RequestHelper } from "./request-helper.ts";
27
+ buildStackServices,
28
+ createStackCacheSync,
29
+ createStackJobRunner,
30
+ provisionStackStorage,
31
+ runCleanupsIgnoringErrors,
32
+ startStackServer,
33
+ validateStackFeatures,
34
+ } from "./test-stack-setup.ts";
65
35
 
66
36
  export type TestStack = {
67
37
  app: Hono;
@@ -288,295 +258,19 @@ export type TestStackOptions = {
288
258
 
289
259
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
290
260
 
291
- type MfaTokenSecretBinder = (
292
- masterSecret: string,
293
- env: Readonly<Record<string, string | undefined>>,
294
- ) => void;
295
-
296
- // Mirrors bindMfaTokenSecretsFromFeature from bundled-features/auth-mfa,
297
- // which this package cannot import: bundled-features is an optional peer.
298
- function isMfaTokenSecretBinder(value: unknown): value is MfaTokenSecretBinder {
299
- return typeof value === "function";
300
- }
301
-
302
- // The env is empty on purpose: test secrets derive from jwtSecret,
303
- // independent of the host env.
304
- function bindMfaTokenSecrets(features: readonly FeatureDefinition[], jwtSecret: string): void {
305
- for (const { exports } of features) {
306
- if (typeof exports !== "object" || exports === null || !("bindTokenSecrets" in exports)) {
307
- continue;
308
- }
309
- const { bindTokenSecrets } = exports;
310
- if (isMfaTokenSecretBinder(bindTokenSecrets)) bindTokenSecrets(jwtSecret, {});
311
- }
312
- }
313
-
314
- // Same table list as `kumiko schema generate` (collectTableMetas) — divergence
315
- // caused a production crash and left r.entity() tables missing.
316
- async function collectStackTableMetas(
317
- options: TestStackOptions,
318
- ): Promise<readonly EntityTableMeta[]> {
319
- const { collectTableMetas, enumerateFeatureTableSources, extractTableInfo } = await import(
320
- "@cosmicdrift/kumiko-framework/db"
321
- );
322
- const metas = collectTableMetas(options.features);
323
- if (options.entityTables !== false) return metas;
324
- const nonEntityNames = new Set(
325
- options.features.flatMap((f) =>
326
- enumerateFeatureTableSources(f).map((s) => extractTableInfo(s.table).name),
327
- ),
328
- );
329
- return metas.filter((m) => nonEntityNames.has(m.tableName));
330
- }
331
-
332
- async function pushStackSchema(
333
- db: DbConnection,
334
- metas: readonly EntityTableMeta[],
335
- withFileTables: boolean,
336
- ): Promise<void> {
337
- // Every ES-entity writes events via createEventStoreExecutor in the
338
- // feature's write handlers. Auto-create the events table so every
339
- // setupTestStack call is ready for writes without needing a manual
340
- // createEventsTable().
341
- await createEventsTable(db);
342
- // Archive-stream metadata — needed by ctx.appendEvent's archive guard and
343
- // loadAggregate's default-skip. Idempotent, so production boot running
344
- // the same call is fine.
345
- await createArchivedStreamsTable(db);
346
-
347
- // Framework state for projection rebuild/status + event-consumer cursors.
348
- // Idempotent — production boot flows run the same calls.
349
- const { createProjectionStateTable, createEventConsumerStateTable } = await import(
350
- "@cosmicdrift/kumiko-framework/pipeline"
351
- );
352
- await createProjectionStateTable(db);
353
- await createEventConsumerStateTable(db);
354
-
355
- // Files support: when a provider is registered, the fileRefs table must
356
- // exist before the first upload. Skipped when no provider — the table
357
- // stays off tenant test DBs that never touch files.
358
- if (withFileTables) {
359
- const { fileRefsTable, fileUploadLedgerTable } = await import(
360
- "@cosmicdrift/kumiko-framework/files"
361
- );
362
- await unsafePushTables(db, { fileRefsTable, fileUploadLedgerTable });
363
- }
364
-
365
- const { tableExists } = await import("@cosmicdrift/kumiko-framework/db");
366
- const missing: Record<string, unknown> = {};
367
- for (const meta of metas) {
368
- if (await tableExists(db, `public.${meta.tableName}`)) continue;
369
- missing[meta.tableName] = meta;
370
- }
371
- if (Object.keys(missing).length > 0) {
372
- await unsafePushTables(db, missing);
373
- }
374
- }
375
-
376
- // Only inside an integration-runner run: the framework tables above are not part
377
- // of the fingerprint, which holds because one run never sees two framework versions.
378
- async function resolveTestDbTemplateName(
379
- options: TestStackOptions,
380
- metas: readonly EntityTableMeta[],
381
- withFileTables: boolean,
382
- ): Promise<string | undefined> {
383
- const runId = process.env[TEST_DB_TEMPLATE_RUN_ENV];
384
- if (runId === undefined || options.persistentDb === true || options.dbName !== undefined) {
385
- return undefined;
386
- }
387
- const { renderTablesDdl } = await import("@cosmicdrift/kumiko-framework/db");
388
- const fingerprint = createHash("sha256")
389
- .update(JSON.stringify({ withFileTables, ddl: renderTablesDdl(metas) }))
390
- .digest("hex")
391
- .slice(0, 16);
392
- return `${testDbTemplatePrefix(runId)}${fingerprint}`;
393
- }
394
-
395
261
  export async function setupTestStack(options: TestStackOptions): Promise<TestStack> {
396
262
  const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
397
263
  const enabledHooks = options.systemHooks ?? ["sse", "search"];
398
-
399
- // Install the Temporal polyfill before feature code runs. Idempotent: the
400
- // production server boot does the same. A no-op on runtimes with native Temporal.
401
- const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
402
- await ensureTemporalPolyfill();
403
-
404
- // Ownership boot-guards (unqualified where-fragments, where-rules on the
405
- // write path) otherwise run only in the prod boot via validateBoot, leaving
406
- // every test stack blind to a broken access map.
407
- // Runs after the polyfill (buildEntityTable needs it to derive the probe
408
- // columns) and before the ephemeral DB/Redis exist, so a throw can't leak
409
- // a database that nothing will clean up.
410
- validateOwnershipBoot(options.features);
411
- // Nav/workspace refs fail the prod boot too; the subset check tolerates refs into unmounted features.
412
- validateNavBootSubset(options.features);
413
- if (options.validateBoot === "full") validateBoot(options.features);
414
- bindMfaTokenSecrets(options.features, jwtSecret);
415
-
416
- const stackTableMetas = await collectStackTableMetas(options);
417
- const withFileTables = options.files !== undefined;
418
- const buildStackSchema = (db: DbConnection): Promise<void> =>
419
- pushStackSchema(db, stackTableMetas, withFileTables);
420
- const templateDbName = await resolveTestDbTemplateName(options, stackTableMetas, withFileTables);
421
- if (templateDbName !== undefined) await ensureTestDbTemplate(templateDbName, buildStackSchema);
422
-
423
- // Forward db-name/persistent-flag through to createTestDb. The
424
- // defaults (undefined dbName, persistent:false) keep the legacy
425
- // test contract: fresh kumiko_test_<random> DB per setup, dropped
426
- // on cleanup.
427
- const [testDb, testRedis] = await Promise.all([
428
- createTestDb({
429
- ...(options.dbName !== undefined && { dbName: options.dbName }),
430
- ...(options.persistentDb !== undefined && { persistent: options.persistentDb }),
431
- ...(options.dbPoolMax !== undefined && { poolMax: options.dbPoolMax }),
432
- ...(options.outsideTransactionPoolMax !== undefined && {
433
- outsideTransactionPoolMax: options.outsideTransactionPoolMax,
434
- }),
435
- ...(templateDbName !== undefined && { templateDbName }),
436
- }),
437
- createTestRedis(
438
- options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix },
439
- ),
440
- ]);
441
-
442
- // Any throw in this DDL region must still drop the just-created ephemeral
443
- // DB/Redis — the caller never gets the stack object back to call
444
- // cleanup() itself (same "leaked ephemeral DB" failure class as the later
445
- // try/catch below, but for setup that runs before that try starts).
446
- if (templateDbName === undefined) {
447
- try {
448
- await buildStackSchema(testDb.db);
449
- } catch (error) {
450
- try {
451
- await testDb.cleanup();
452
- } catch {
453
- // ignore — `error` is the one that matters
454
- }
455
- try {
456
- await testRedis.cleanup();
457
- } catch {
458
- // ignore — `error` is the one that matters
459
- }
460
- throw error;
461
- }
462
- }
463
-
464
- // Redis only where two stacks can actually disagree (avoids two extra
465
- // Pub/Sub connections); otherwise a local bus so invalidation still works
466
- // inside this stack. Passing it explicitly keeps buildServer from using its
467
- // REDIS_URL default (an unnamespaced channel).
468
- const cacheSync: ClosableCacheSyncBus =
469
- options.cacheSync || options.sharedRedisWith
470
- ? createRedisCacheSyncBus({
471
- redisUrl: testRedis.redisUrl,
472
- channelPrefix: testRedis.keyPrefix,
473
- })
474
- : createLocalCacheSyncBus();
475
-
476
- const searchAdapter = createInMemorySearchAdapter();
477
- const events = createEventCollector();
478
- const registry = createRegistry([...options.features]);
479
-
480
- // Wire SSE broker with event collector — built early (not just before
481
- // buildServer) because extraContext + the job context below both need it.
482
- const sseBroker = createSseBroker();
483
- sseBroker.addClient(
484
- "tenant:00000000-0000-4000-8000-000000000001",
485
- (event) => events.sse.push(event),
486
- () => {},
487
- );
488
-
489
- const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
490
-
491
- // A static `files.storageProvider` is wired as the per-tenant resolver — the
492
- // framework test seam that doesn't require mounting config + file-foundation.
493
- // (Bundled GDPR tests mount the real provider features instead.)
494
- let fileProviderResolver:
495
- | import("@cosmicdrift/kumiko-framework/files").FileProviderResolver
496
- | undefined;
497
- if (options.files) {
498
- const provider = options.files.storageProvider;
499
- const { withUploadLedger } = await import("@cosmicdrift/kumiko-framework/files");
500
- fileProviderResolver = withUploadLedger(() => Promise.resolve(provider), testDb.db);
501
- }
502
-
503
- const observability = options.observability ?? createNoopProvider();
504
-
505
- // Same AppContext buildServer() gets below (db/redis/searchAdapter/
506
- // entityCache/masterKeyProvider/fileProviderResolver/extraContext) —
507
- // shared so the job context can't drift from the request-path context:
508
- // a reduced `{ db, registry }` literal let jobs pass in tests while
509
- // reaching for fields only prod's context has.
510
- //
511
- // effectiveFeatures deliberately stays OUT of appContext — prod never puts
512
- // it there either (only dispatcherOptions.effectiveFeatures, consumed by
513
- // the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
514
- // index.ts). Putting it here would reintroduce the exact test-vs-prod
515
- // drift fixed: a job/handler reading `ctx.effectiveFeatures` would pass in
516
- // tests and break in prod.
517
- const appContext = {
518
- db: testDb.db,
519
- redis: testRedis.redis,
520
- searchAdapter,
521
- entityCache,
522
- registry,
523
- ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
524
- ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
525
- ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
526
- ...(typeof options.extraContext === "function"
527
- ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
528
- : options.extraContext),
529
- };
530
-
531
- // Built before buildServer() so it can be merged into dispatcherOptions
532
- // (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
533
- // event-trigger hook both need it present at dispatcher-construction
534
- // time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
535
- // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
536
- //
537
- // context = appContext + tracer/meter, mirroring the prod entrypoint's
538
- // `contextWithObservability(options.context, observability)`.
539
- let jobRunner: JobRunner | undefined;
540
- // Fed to createJobRunner's onJobComplete/onJobFailed below so drainJobs()
264
+ await validateStackFeatures(options, jwtSecret);
265
+ const { testDb, testRedis } = await provisionStackStorage(options);
266
+ const cacheSync = createStackCacheSync(options, testRedis);
267
+ const services = await buildStackServices(options, testDb, testRedis);
268
+ // Fed to the job runner's onJobComplete/onJobFailed so drainJobs()
541
269
  // can tell a job's final outcome apart from an attempt that will retry —
542
- // built before createJobRunner regardless of whether a runner ends up
543
- // existing, so the returned drainJobs() closure always has one to read.
270
+ // built regardless of whether a runner ends up existing, so the returned
271
+ // drainJobs() closure always has one to read.
544
272
  const jobFailureTracker = createJobFailureTracker();
545
- if (options.jobs && registry.getAllJobs().size > 0) {
546
- const runLogger = options.jobs.runLogger?.({ registry, db: testDb.db });
547
- jobRunner = createJobRunner({
548
- registry,
549
- context: { ...appContext, tracer: observability.tracer, meter: observability.meter },
550
- // The real REDIS_URL, not one rebuilt from `.options` — rebuilding it lost
551
- // password/username/tls/path.
552
- redisUrl: testRedis.redisUrl,
553
- ...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
554
- queueNamePrefix:
555
- options.jobs.queueNamePrefix ?? queueNamePrefixForTestRedis(testRedis.keyPrefix),
556
- ...(options.jobs.getActiveTenantIds !== undefined && {
557
- getActiveTenantIds: options.jobs.getActiveTenantIds,
558
- }),
559
- ...(runLogger?.onJobStart !== undefined && { onJobStart: runLogger.onJobStart }),
560
- ...(runLogger?.onJobQueued !== undefined && { onJobQueued: runLogger.onJobQueued }),
561
- ...(runLogger?.onJobDropped !== undefined && { onJobDropped: runLogger.onJobDropped }),
562
- // Tracker fires in finally: drainJobs wakes on it, so the log row must
563
- // be written first, and a throwing logger must not swallow the signal.
564
- onJobComplete: async (jobName, jobId, ...rest) => {
565
- try {
566
- await runLogger?.onJobComplete?.(jobName, jobId, ...rest);
567
- } finally {
568
- jobFailureTracker.onJobComplete(jobName, jobId);
569
- }
570
- },
571
- onJobFailed: async (jobName, jobId, error, ...rest) => {
572
- try {
573
- await runLogger?.onJobFailed?.(jobName, jobId, error, ...rest);
574
- } finally {
575
- jobFailureTracker.onJobFailed(jobName, jobId, error);
576
- }
577
- },
578
- });
579
- }
273
+ const jobRunner = createStackJobRunner(options, services, testDb, testRedis, jobFailureTracker);
580
274
 
581
275
  // From here on, any throw must stop a jobRunner that was already created
582
276
  // above (createJobRunner() itself opens live BullMQ Queue/lock Redis
@@ -584,180 +278,17 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
584
278
  // buildServer()/search-config/etc. leaks that connection and the test
585
279
  // process hangs on exit.
586
280
  try {
587
- if (jobRunner) await jobRunner.start();
588
-
589
- // Auto-configure search for tenant 1 based on registry
590
- if (enabledHooks.includes("search")) {
591
- const searchableFields: string[] = [];
592
- for (const feature of options.features) {
593
- for (const [, entity] of Object.entries(feature.entities ?? {})) {
594
- for (const [fieldName, field] of Object.entries(entity.fields)) {
595
- if (field.type === "text" && field.searchable) {
596
- searchableFields.push(fieldName);
597
- }
598
- if (field.type === "embedded") {
599
- for (const [subName, subField] of Object.entries(field.schema)) {
600
- if (subField.searchable) {
601
- searchableFields.push(`${fieldName}_${subName}`);
602
- }
603
- }
604
- }
605
- }
606
- }
607
- }
608
-
609
- if (options.searchConfig) {
610
- await searchAdapter.configure(options.searchConfig.tenantId, {
611
- searchableFields: options.searchConfig.searchableFields,
612
- rankingFields: options.searchConfig.rankingFields,
613
- });
614
- } else if (searchableFields.length > 0) {
615
- await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
616
- searchableFields,
617
- rankingFields: searchableFields,
618
- });
619
- }
620
- }
621
-
622
- const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
623
- const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
624
-
625
- // buildServer only auto-wires an L3 RateLimitResolver from
626
- // `context.redis` — a degradation test that nulls `redis` via
627
- // extraContext (to exercise a handler's own ctx.redis-down guard) also
628
- // silently strips rate-limiting, even though every handler with a
629
- // `rateLimit` declaration still requires a resolver at dispatch time.
630
- // Since e7bf543ad, an IP-keyed bucket key is built unconditionally
631
- // (falls back to a shared "unknown" IP rather than skipping), so the
632
- // missing resolver now throws instead of merely skipping enforcement.
633
- // Mirrors idempotency/eventDedup above: built from the test-stack's own
634
- // always-real `testRedis.redis`, independent of whatever extraContext
635
- // did to `appContext.redis` — an explicit `context.rateLimit` from
636
- // extraContext still wins (same precedence buildServer documents).
637
- const explicitRateLimit = propertyOf(appContext, "rateLimit");
638
- const needsOwnRateLimitResolver =
639
- explicitRateLimit === undefined &&
640
- (appContext.redis === undefined || options.rateLimitNowMs !== undefined);
641
- const rateLimitFallback = needsOwnRateLimitResolver
642
- ? createRateLimitResolver({
643
- // An app-supplied redis keeps buckets and app redis on the same instance.
644
- redis: appContext.redis ?? testRedis.redis,
645
- ...(options.rateLimitNowMs ? { nowMs: options.rateLimitNowMs } : {}),
646
- })
647
- : undefined;
648
-
649
- const server = buildServer({
650
- registry,
651
- context: rateLimitFallback ? { ...appContext, rateLimit: rateLimitFallback } : appContext,
281
+ return await startStackServer({
282
+ options,
652
283
  jwtSecret,
653
- ...(options.observability ? { observability: options.observability } : {}),
654
- ...(options.lifecycleSystemHooks && { systemHooks: options.lifecycleSystemHooks }),
655
- dispatcherOptions: {
656
- idempotency,
657
- cacheSync,
658
- outsideTransactionDb: testDb.outsideTransactionDb,
659
- ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
660
- ...(jobRunner && { jobRunner }),
661
- },
662
- eventDedup,
663
- sseBroker,
664
- ...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
665
- ...(options.errorDocs && { errorDocs: options.errorDocs }),
666
- ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
667
- ...(options.metrics && { metrics: options.metrics }),
668
- // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
669
- // deterministic drains — no timer-induced flakiness. pollIntervalMs
670
- // stays short anyway in case a test opts into `.start()`. pgClient
671
- // plumbs through the LISTEN wake-up for tests that want to measure
672
- // post-commit latency (Sprint E.4).
673
- eventDispatcher: {
674
- pollIntervalMs: options.eventDispatcherPollIntervalMs ?? 50,
675
- pgClient: testDb.client,
676
- systemConsumers: {
677
- sse: enabledHooks.includes("sse"),
678
- search: enabledHooks.includes("search"),
679
- accessInvalidation: enabledHooks.includes("sse"),
680
- },
681
- },
682
- // Default tests to no login rate-limiter so existing suites that loop
683
- // over logins don't hit a 429 after 10 attempts. Suites specifically
684
- // testing the limiter can override via authConfig.loginRateLimit.
685
- ...(options.authConfig
686
- ? {
687
- auth: {
688
- ...options.authConfig,
689
- ...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
690
- },
691
- }
692
- : {}),
693
- ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
694
- ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
695
- ...(await (async () => {
696
- const baseAnon =
697
- typeof options.anonymousAccess === "function"
698
- ? options.anonymousAccess({
699
- registry,
700
- db: testDb.db,
701
- sseBroker,
702
- redis: testRedis.redis,
703
- })
704
- : options.anonymousAccess;
705
- const resolvedAnon = options.enrichAnonymousAccess
706
- ? await options.enrichAnonymousAccess(baseAnon, {
707
- registry,
708
- db: testDb.db,
709
- })
710
- : baseAnon;
711
- return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
712
- })()),
713
- });
714
-
715
- // Mirrors the prod entrypoints — without this, JobContext.write/writeAs/
716
- // queryAs/queryAsMember throw on first use inside a test-stack job.
717
- if (jobRunner) jobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
718
-
719
- const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
720
-
721
- // Pre-register consumer state rows so tests can call runOnce() directly
722
- // without a preceding explicit start(). Timer fires at pollIntervalMs=50
723
- // but each consumer runs at most one turn at a time — tests that drain via
724
- // runOnce() remain deterministic. Tests that specifically exercise the
725
- // timer loop call start() again (idempotent) after setup.
726
- if (eventDispatcher) await eventDispatcher.ensureRegistered();
727
-
728
- const http = createRequestHelper(server.app, server.jwt, {
729
- ...(options.authConfig?.sessionCreator !== undefined && {
730
- sessionCreator: options.authConfig.sessionCreator,
731
- }),
732
- });
733
-
734
- return {
735
- app: server.app,
736
- jwt: server.jwt,
737
- registry,
738
- db: testDb.db,
739
- dbPoolMax: testDb.poolMax,
740
- redis: testRedis,
284
+ enabledHooks,
285
+ testDb,
286
+ testRedis,
741
287
  cacheSync,
742
- search: searchAdapter,
743
- events,
744
- http,
745
- observability: server.observability,
746
- sseBroker,
747
- dispatcher: server.dispatcher,
748
- context: server.context,
749
- ...(eventDispatcher ? { eventDispatcher } : {}),
750
- ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
751
- ...(jobRunner ? { jobRunner } : {}),
752
- drainJobs: () => drainJobs({ db: testDb.db, eventDispatcher, jobRunner }, jobFailureTracker),
753
- cleanup: async () => {
754
- if (jobRunner) await jobRunner.stop();
755
- if (eventDispatcher) await eventDispatcher.stop();
756
- await server.observability.shutdown();
757
- await cacheSync.close();
758
- await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
759
- },
760
- };
288
+ services,
289
+ jobRunner,
290
+ jobFailureTracker,
291
+ });
761
292
  } catch (error) {
762
293
  // Best-effort — a broken jobRunner.stop()/cleanup() must never mask the
763
294
  // real setup failure below. testDb/testRedis are created before this
@@ -766,28 +297,14 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
766
297
  // sockets — the caller never gets the stack object back to call
767
298
  // cleanup() itself, which is the same "hangs on exit" failure class
768
299
  // this function exists to prevent.
769
- if (jobRunner) {
770
- try {
771
- await jobRunner.stop();
772
- } catch {
773
- // ignore — `error` is the one that matters
774
- }
775
- }
776
- try {
777
- await cacheSync.close();
778
- } catch {
779
- // ignore — `error` is the one that matters
780
- }
781
- try {
782
- await testDb.cleanup();
783
- } catch {
784
- // ignore — `error` is the one that matters
785
- }
786
- try {
787
- await testRedis.cleanup();
788
- } catch {
789
- // ignore — `error` is the one that matters
790
- }
300
+ await runCleanupsIgnoringErrors([
301
+ async () => {
302
+ if (jobRunner) await jobRunner.stop();
303
+ },
304
+ () => cacheSync.close(),
305
+ () => testDb.cleanup(),
306
+ () => testRedis.cleanup(),
307
+ ]);
791
308
  throw error;
792
309
  }
793
310
  }