@cosmicdrift/kumiko-testing 0.385.0 → 0.387.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/LICENSE +21 -57
- package/bin/kumiko-testing.ts +10 -2
- package/package.json +7 -7
- package/src/bunfig.ts +2 -2
- package/src/changes.json +12 -0
- package/src/e2e/screenshots.ts +2 -2
- package/src/e2e/seed-route.ts +4 -4
- package/src/helpers/boot-validator-fixture.ts +1 -1
- package/src/helpers/e2e-generator.ts +2 -2
- package/src/helpers/index.ts +3 -3
- package/src/helpers/without-ambient-temporal.ts +1 -1
- package/src/integration-runner.ts +21 -0
- package/src/seed-types.ts +1 -1
- package/src/stack/db.ts +82 -5
- package/src/stack/redis.ts +5 -4
- package/src/stack/request-helper.ts +1 -1
- package/src/stack/test-stack.ts +125 -77
package/LICENSE
CHANGED
|
@@ -1,57 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
controlling, or under common control with the Licensor ("Affiliates"), or
|
|
23
|
-
contractors acting on their behalf. The Licensor remains free to use the
|
|
24
|
-
Licensed Work for any purpose, including for the operation of kumiko.rocks.
|
|
25
|
-
|
|
26
|
-
Change Date: 2030-05-05
|
|
27
|
-
Change License: Apache License, Version 2.0
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Terms
|
|
31
|
-
|
|
32
|
-
The Licensor hereby grants you the right to copy, modify, create derivative works,
|
|
33
|
-
redistribute, and make non-production use of the Licensed Work. The Licensor may
|
|
34
|
-
make an Additional Use Grant, above, permitting limited production use.
|
|
35
|
-
|
|
36
|
-
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
37
|
-
available distribution of the Licensed Work under this License, whichever comes
|
|
38
|
-
first, this License will convert to the Change License.
|
|
39
|
-
|
|
40
|
-
This Business Source License governs use of the Licensed Work in all cases, except
|
|
41
|
-
as to any use that is explicitly granted in the Additional Use Grant above or
|
|
42
|
-
under the Change License after the Change Date.
|
|
43
|
-
|
|
44
|
-
If your use of the Licensed Work does not comply with the requirements of this
|
|
45
|
-
License, you must cease use of the Licensed Work immediately.
|
|
46
|
-
|
|
47
|
-
All copies of the Licensed Work, and all derivative works thereof, must include
|
|
48
|
-
this License.
|
|
49
|
-
|
|
50
|
-
This License does not grant you any right, title, or interest in any trademark,
|
|
51
|
-
logo, or branding of the Licensor, except as required to comply with this License.
|
|
52
|
-
|
|
53
|
-
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
|
|
54
|
-
“AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
|
|
55
|
-
WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
|
|
56
|
-
TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
|
|
57
|
-
ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marc Frost
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/kumiko-testing.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
buildIntegrationTestArgs,
|
|
9
9
|
listIntegrationTestFiles,
|
|
10
10
|
resolveRequestedIntegrationFiles,
|
|
11
|
+
runWithTestDbTemplates,
|
|
11
12
|
selectIntegrationFiles,
|
|
12
13
|
} from "../src/integration-runner";
|
|
13
14
|
|
|
@@ -99,8 +100,15 @@ async function runIntegration(args: readonly string[]): Promise<number> {
|
|
|
99
100
|
...(values.timings !== undefined && { timings: values.timings }),
|
|
100
101
|
...(values["update-timings"] === true && { updateTimings: true }),
|
|
101
102
|
});
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
return runWithTestDbTemplates(
|
|
104
|
+
// Bun.spawn defaults to the env from process start; pass the live one so the
|
|
105
|
+
// template run id set by runWithTestDbTemplates reaches the test process.
|
|
106
|
+
() =>
|
|
107
|
+
Bun.spawn(["bun", ...testArgs], {
|
|
108
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
109
|
+
env: process.env,
|
|
110
|
+
}).exited,
|
|
111
|
+
);
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
const [command, ...rest] = process.argv.slice(2);
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.387.0",
|
|
4
4
|
"description": "Test template for Kumiko apps: per-flow seedTenant, app test stack, canonical test preloads, bunfig generator and integration runner.",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -99,9 +99,9 @@
|
|
|
99
99
|
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
100
100
|
},
|
|
101
101
|
"dependencies": {
|
|
102
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
103
|
-
"@cosmicdrift/kumiko-guards": "0.
|
|
104
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
102
|
+
"@cosmicdrift/kumiko-framework": "0.387.0",
|
|
103
|
+
"@cosmicdrift/kumiko-guards": "0.387.0",
|
|
104
|
+
"@cosmicdrift/kumiko-types": "0.387.0",
|
|
105
105
|
"zod": "^4.4.3"
|
|
106
106
|
},
|
|
107
107
|
"peerDependencies": {
|
|
@@ -125,8 +125,8 @@
|
|
|
125
125
|
}
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-bundled-features": "0.387.0",
|
|
129
|
+
"@cosmicdrift/kumiko-dev-server": "0.387.0",
|
|
130
130
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
131
131
|
"@playwright/test": "^1.62.1",
|
|
132
132
|
"@testing-library/dom": "^10.4.1",
|
package/src/bunfig.ts
CHANGED
|
@@ -102,7 +102,7 @@ type BunfigBlock = { readonly header: string; readonly lines: readonly string[]
|
|
|
102
102
|
// contain "[" are never alone on their own line. Anything before the first
|
|
103
103
|
// header (top-level keys like `preload`/`telemetry`/`smol`/`logLevel`, plus
|
|
104
104
|
// the header comments) is its own block under the synthetic header "" —
|
|
105
|
-
// dropping it silently lost those keys
|
|
105
|
+
// dropping it silently lost those keys.
|
|
106
106
|
function splitBunfigBlocks(content: string): readonly BunfigBlock[] {
|
|
107
107
|
const blocks: { header: string; lines: string[] }[] = [];
|
|
108
108
|
let current: { header: string; lines: string[] } = { header: "", lines: [] };
|
|
@@ -224,7 +224,7 @@ function appendArrayExtras(
|
|
|
224
224
|
* verbatim, appended after the generated content — so a section the
|
|
225
225
|
* template doesn't own this run is never silently dropped. A key inside a
|
|
226
226
|
* section the template DOES emit that isn't in this run's own version of
|
|
227
|
-
* that section (e.g. `concurrency`, `timeout
|
|
227
|
+
* that section (e.g. `concurrency`, `timeout`) fails
|
|
228
228
|
* the merge instead of silently disappearing. Within an array key the
|
|
229
229
|
* template does emit on both sides (`preload`, `pathIgnorePatterns`,
|
|
230
230
|
* `coveragePathIgnorePatterns`), entries the app appended on top of the
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.386.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Integration test stacks clone a migrated template database",
|
|
6
|
+
"detail": "kumiko-testing integration and the framework integration runner set a run id; setupTestStack then builds one template per schema fingerprint under an advisory lock and clones it, and the runner drops the templates at the end. Plain bun test runs keep the in-place DDL path. setupAppTestStack skips pushEntityProjectionTables on fresh databases."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.386.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "createTestRedis waits for a ready connection",
|
|
12
|
+
"detail": "The test Redis client connected in the background, so the first rate-limit check in a test file could see status \"connect\" and fail with \"rate-limit redis connection not ready\". createTestRedis now connects explicitly and returns after the connection is ready."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.385.0",
|
|
4
16
|
"type": "fix",
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -324,7 +324,7 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
|
|
|
324
324
|
// Browser-context locale for JS-side Intl/navigator.language (e.g. money-input's
|
|
325
325
|
// resolvedLocale) — pinEnglishLocale() only seeds the app's own kumiko:locale.
|
|
326
326
|
// ponytail: native <input type="number"> still formats per the host OS region,
|
|
327
|
-
// unreachable from Playwright (context.locale and --lang both no-op there).
|
|
327
|
+
// unreachable from Playwright (context.locale and --lang both no-op there).
|
|
328
328
|
if (opts.pinLocale) test.use({ locale: "en-US" });
|
|
329
329
|
for (const s of scenarios) {
|
|
330
330
|
test(
|
|
@@ -556,7 +556,7 @@ export function runMatrix<T extends string>(
|
|
|
556
556
|
test.describe(locale, () => {
|
|
557
557
|
// Browser-context locale for JS-side Intl/navigator.language — the
|
|
558
558
|
// kumiko:locale seed below only drives the app's own i18n strings.
|
|
559
|
-
// ponytail: see the runScreenshots() locale comment above
|
|
559
|
+
// ponytail: see the runScreenshots() locale comment above.
|
|
560
560
|
const tag = resolveLocaleTag(locale, opts.localeTags);
|
|
561
561
|
test.use({ locale: tag });
|
|
562
562
|
|
package/src/e2e/seed-route.ts
CHANGED
|
@@ -51,7 +51,7 @@ function parseJsonBody<S extends z.ZodType>(schema: S, raw: string): ParsedBody<
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// Shared by every verify() below — a failed parse becomes a 400 the same way
|
|
54
|
-
// the
|
|
54
|
+
// the handler-level parseJsonBody/parseWith checks did.
|
|
55
55
|
function parseOrReject<T>(value: ParsedBody<T>): T {
|
|
56
56
|
if (value.success) return value.data;
|
|
57
57
|
throw new ExtraRouteRejection(400, { error: value.error });
|
|
@@ -153,9 +153,9 @@ function toCapturedMail(message: InboxMessage): CapturedMail {
|
|
|
153
153
|
// still throw in every environment) — the *mounting* decision is the
|
|
154
154
|
// caller's: use isE2eSeedingEnabled() to keep prod from ever registering
|
|
155
155
|
// these route objects at all, e.g. `...(isE2eSeedingEnabled() ?
|
|
156
|
-
// createE2eSeedRoutes() : [])` in a server entry that also runs in prod
|
|
157
|
-
//
|
|
158
|
-
//
|
|
156
|
+
// createE2eSeedRoutes() : [])` in a server entry that also runs in prod.
|
|
157
|
+
// Each route's own assertGateOpen() 404s outside the seed condition regardless
|
|
158
|
+
// — this is belt-and-suspenders, not the only gate.
|
|
159
159
|
export function createE2eSeedRoutes(
|
|
160
160
|
options: E2eSeedRoutesOptions,
|
|
161
161
|
bundled: SeedRouteBundledApi,
|
|
@@ -68,7 +68,7 @@ function ensureEntityListRowNavigation(
|
|
|
68
68
|
// No `params` here: this same-entity target always resolves to
|
|
69
69
|
// UPDATE mode (the renderer auto-fills entityId from row["id"]),
|
|
70
70
|
// and entityEdit-update never reads search-params as initial
|
|
71
|
-
// values — the boot-validator now rejects that combination
|
|
71
|
+
// values — the boot-validator now rejects that combination.
|
|
72
72
|
{
|
|
73
73
|
kind: "navigate",
|
|
74
74
|
id: "edit",
|
|
@@ -309,7 +309,7 @@ function buildEditFillOps(
|
|
|
309
309
|
ops.push({ kind: "fill", field, value: String(v) });
|
|
310
310
|
break;
|
|
311
311
|
// embedded/money/timestamp/locatedTimestamp/file/image/files/images:
|
|
312
|
-
// no generic interaction. Since
|
|
312
|
+
// no generic interaction. Since timestamp renders two inputs
|
|
313
313
|
// (date + time) that a single .fill() cannot drive; the test author
|
|
314
314
|
// supplies a hand override or skips it.
|
|
315
315
|
default:
|
|
@@ -489,7 +489,7 @@ function fieldToFixture(name: string, field: FieldDefinition): unknown {
|
|
|
489
489
|
case "images":
|
|
490
490
|
// File-upload fixtures are not generic yet: Playwright would have to supply
|
|
491
491
|
// file contents, and the write API already expects uploaded fileRef UUIDs.
|
|
492
|
-
// Can be added once a sample settles the paths
|
|
492
|
+
// Can be added once a sample settles the paths.
|
|
493
493
|
return undefined;
|
|
494
494
|
default:
|
|
495
495
|
return undefined;
|
package/src/helpers/index.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
// The four cache/injection resets stay in their own modules (they close over
|
|
6
6
|
// module-private state) and are only re-exported here — they are out of /crypto
|
|
7
|
-
// and /db
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// and /db. A production call to resetPiiSubjectKmsForTests() silently switches
|
|
8
|
+
// the PII layer off, and subject-annotated fields are written in plaintext from
|
|
9
|
+
// then on: no error, no log.
|
|
10
10
|
export {
|
|
11
11
|
resetBlindIndexKeyForTests,
|
|
12
12
|
resetEntityFieldEncryptionCacheForTests,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Run `fn` with `globalThis.Temporal` deleted, then restore.
|
|
3
|
-
* Used by ambient-independence regression tests
|
|
3
|
+
* Used by ambient-independence regression tests.
|
|
4
4
|
*/
|
|
5
5
|
export async function withoutAmbientTemporal<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
6
6
|
const savedGlobal: unknown = Reflect.get(globalThis, "Temporal");
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
3
|
import { join, resolve } from "node:path";
|
|
3
4
|
import { BUNFIG_FILES, TEST_TIMEOUT_MS } from "./bunfig";
|
|
5
|
+
import { withEnv } from "./helpers/with-env";
|
|
6
|
+
import { SERVICE_ENV_DEFAULTS } from "./preload/service-env-defaults-values";
|
|
4
7
|
|
|
5
8
|
export type IntegrationRunOptions = {
|
|
6
9
|
readonly files: readonly string[];
|
|
@@ -85,3 +88,21 @@ export function buildIntegrationTestArgs(opts: IntegrationRunOptions): string[]
|
|
|
85
88
|
...opts.files,
|
|
86
89
|
];
|
|
87
90
|
}
|
|
91
|
+
|
|
92
|
+
// Test stacks of this run clone migrated template databases instead of repeating
|
|
93
|
+
// the DDL per stack; the templates live exactly as long as the run.
|
|
94
|
+
export async function runWithTestDbTemplates(run: () => Promise<number>): Promise<number> {
|
|
95
|
+
const { TEST_DB_TEMPLATE_RUN_ENV, dropTestDbTemplates } = await import("./stack/db.ts");
|
|
96
|
+
const runId = randomBytes(4).toString("hex");
|
|
97
|
+
try {
|
|
98
|
+
return await withEnv({ [TEST_DB_TEMPLATE_RUN_ENV]: runId }, run);
|
|
99
|
+
} finally {
|
|
100
|
+
const baseUrl = process.env["TEST_DATABASE_URL"] ?? SERVICE_ENV_DEFAULTS.TEST_DATABASE_URL;
|
|
101
|
+
try {
|
|
102
|
+
await dropTestDbTemplates(baseUrl, runId);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// biome-ignore lint/suspicious/noConsole: runner output is the interface
|
|
105
|
+
console.warn(`kumiko-testing: could not drop the test DB templates of run ${runId}:`, error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/seed-types.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type SeededUser = SeededCredentials & {
|
|
|
28
28
|
export type SeedPart = (ctx: { readonly tenant: SeededTenant }) => Promise<void>;
|
|
29
29
|
|
|
30
30
|
// email may contain the literal "{tenantId}" placeholder, substituted with the
|
|
31
|
-
// seeded tenant's id — user:create's email uniqueness is global
|
|
31
|
+
// seeded tenant's id — user:create's email uniqueness is global,
|
|
32
32
|
// so a fixed literal email collides across per-scenario tenants.
|
|
33
33
|
export type SeedAdminIdentity = {
|
|
34
34
|
readonly displayName?: string;
|
package/src/stack/db.ts
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
import { DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import {
|
|
6
|
+
acquireSessionAdvisoryLock,
|
|
6
7
|
createDatabase,
|
|
7
8
|
createDbConnection,
|
|
8
9
|
databaseExists,
|
|
9
10
|
dropDatabaseIfExists,
|
|
11
|
+
listDatabaseNamesWithPrefix,
|
|
12
|
+
renameDatabase,
|
|
10
13
|
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
11
14
|
import { ensureTemporalPolyfill } from "@cosmicdrift/kumiko-framework/time";
|
|
12
15
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
@@ -39,14 +42,32 @@ export type CreateTestDbOptions = {
|
|
|
39
42
|
readonly persistent?: boolean;
|
|
40
43
|
readonly poolMax?: number;
|
|
41
44
|
readonly outsideTransactionPoolMax?: number;
|
|
45
|
+
/** Clone this already-migrated database instead of starting empty. */
|
|
46
|
+
readonly templateDbName?: string;
|
|
42
47
|
};
|
|
43
48
|
|
|
49
|
+
// Set by the integration runner for one run; templates named after it are dropped when the run ends.
|
|
50
|
+
export const TEST_DB_TEMPLATE_RUN_ENV = "KUMIKO_TEST_DB_TEMPLATE_RUN";
|
|
51
|
+
|
|
52
|
+
// Inside the kumiko_test_ namespace, so clean-test-dbs also sweeps templates of killed runs.
|
|
53
|
+
export function testDbTemplatePrefix(runId: string): string {
|
|
54
|
+
return `kumiko_test_tpl_${runId}_`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function adminUrlOf(url: string): string {
|
|
58
|
+
return url.replace(/\/[^/]+$/, "/postgres");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function databaseUrlOf(url: string, dbName: string): string {
|
|
62
|
+
return url.replace(/\/[^/]+$/, `/${dbName}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<TestDb> {
|
|
45
66
|
await ensureTemporalPolyfill();
|
|
46
67
|
const opts: CreateTestDbOptions = typeof arg === "string" ? { baseUrl: arg } : (arg ?? {});
|
|
47
68
|
const url = opts.baseUrl ?? requireEnv("TEST_DATABASE_URL");
|
|
48
69
|
const dbName = opts.dbName ?? `kumiko_test_${generateId().slice(-8)}`;
|
|
49
|
-
const adminUrl = url
|
|
70
|
+
const adminUrl = adminUrlOf(url);
|
|
50
71
|
|
|
51
72
|
const admin = createDbConnection({ url: adminUrl, maxConnections: 1 });
|
|
52
73
|
try {
|
|
@@ -55,13 +76,13 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
|
|
|
55
76
|
await createDatabase(admin.db, dbName);
|
|
56
77
|
}
|
|
57
78
|
} else {
|
|
58
|
-
await createDatabase(admin.db, dbName);
|
|
79
|
+
await createDatabase(admin.db, dbName, opts.templateDbName);
|
|
59
80
|
}
|
|
60
81
|
} finally {
|
|
61
82
|
await admin.close();
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
const testUrl = url
|
|
85
|
+
const testUrl = databaseUrlOf(url, dbName);
|
|
65
86
|
const poolMax = opts.poolMax ?? DEFAULT_TEST_DB_POOL_MAX;
|
|
66
87
|
const conn = createDbConnection({ url: testUrl, maxConnections: poolMax });
|
|
67
88
|
const outsideConn = createDbConnection({
|
|
@@ -69,8 +90,10 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
|
|
|
69
90
|
maxConnections: opts.outsideTransactionPoolMax ?? DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX,
|
|
70
91
|
});
|
|
71
92
|
|
|
72
|
-
|
|
73
|
-
|
|
93
|
+
if (opts.templateDbName === undefined) {
|
|
94
|
+
const { createEventsTable } = await import("@cosmicdrift/kumiko-framework/event-store");
|
|
95
|
+
await createEventsTable(conn.db);
|
|
96
|
+
}
|
|
74
97
|
|
|
75
98
|
return {
|
|
76
99
|
db: conn.db,
|
|
@@ -93,4 +116,58 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
|
|
|
93
116
|
};
|
|
94
117
|
}
|
|
95
118
|
|
|
119
|
+
const testDbTemplates = new Map<string, Promise<void>>();
|
|
120
|
+
|
|
121
|
+
// One build per template and process; other workers of the run wait on the
|
|
122
|
+
// advisory lock and reuse the finished database.
|
|
123
|
+
export function ensureTestDbTemplate(
|
|
124
|
+
templateDbName: string,
|
|
125
|
+
buildSchema: (db: DbConnection) => Promise<void>,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const known = testDbTemplates.get(templateDbName);
|
|
128
|
+
if (known) return known;
|
|
129
|
+
const pending = buildTestDbTemplate(templateDbName, buildSchema);
|
|
130
|
+
testDbTemplates.set(templateDbName, pending);
|
|
131
|
+
pending.catch(() => testDbTemplates.delete(templateDbName));
|
|
132
|
+
return pending;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function buildTestDbTemplate(
|
|
136
|
+
templateDbName: string,
|
|
137
|
+
buildSchema: (db: DbConnection) => Promise<void>,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const url = requireEnv("TEST_DATABASE_URL");
|
|
140
|
+
// One connection, so the session that takes the advisory lock is the one closed below.
|
|
141
|
+
const admin = createDbConnection({ url: adminUrlOf(url), maxConnections: 1 });
|
|
142
|
+
try {
|
|
143
|
+
await acquireSessionAdvisoryLock(admin.db, templateDbName);
|
|
144
|
+
// skip: another worker of this run already built it while we waited on the lock
|
|
145
|
+
if (await databaseExists(admin.db, templateDbName)) return;
|
|
146
|
+
// Built under a side name and renamed once complete, so a clone never sees a half-built schema.
|
|
147
|
+
const buildDbName = `${templateDbName}_build`;
|
|
148
|
+
await dropDatabaseIfExists(admin.db, buildDbName);
|
|
149
|
+
await createDatabase(admin.db, buildDbName);
|
|
150
|
+
const conn = createDbConnection({ url: databaseUrlOf(url, buildDbName), maxConnections: 1 });
|
|
151
|
+
try {
|
|
152
|
+
await buildSchema(conn.db);
|
|
153
|
+
} finally {
|
|
154
|
+
await conn.close();
|
|
155
|
+
}
|
|
156
|
+
await renameDatabase(admin.db, buildDbName, templateDbName);
|
|
157
|
+
} finally {
|
|
158
|
+
await admin.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function dropTestDbTemplates(baseUrl: string, runId: string): Promise<void> {
|
|
163
|
+
const admin = createDbConnection({ url: adminUrlOf(baseUrl), maxConnections: 1 });
|
|
164
|
+
try {
|
|
165
|
+
for (const name of await listDatabaseNamesWithPrefix(admin.db, testDbTemplatePrefix(runId))) {
|
|
166
|
+
await dropDatabaseIfExists(admin.db, name);
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
await admin.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
96
173
|
export { requireEnv };
|
package/src/stack/redis.ts
CHANGED
|
@@ -7,9 +7,8 @@ export type TestRedis = {
|
|
|
7
7
|
// The exact REDIS_URL used to build `redis` above — for a second, unrelated
|
|
8
8
|
// connection (e.g. the test-stack's JobRunner) that needs its own client
|
|
9
9
|
// rather than sharing this one's keyPrefix. Reconstructing a URL from
|
|
10
|
-
// `redis.options` loses password/username/tls/path
|
|
11
|
-
//
|
|
12
|
-
// this raw string, not rebuild one from parsed options.
|
|
10
|
+
// `redis.options` loses password/username/tls/path — callers needing a fresh
|
|
11
|
+
// connection should use this raw string, not rebuild one from parsed options.
|
|
13
12
|
redisUrl: string;
|
|
14
13
|
/** The per-test `keyPrefix` on `redis`, e.g. `kt:1a2b3c4d:`. */
|
|
15
14
|
keyPrefix: string;
|
|
@@ -39,7 +38,9 @@ export async function createTestRedis(opts: CreateTestRedisOptions = {}): Promis
|
|
|
39
38
|
// round-robin). Collisions at birthday-paradox rates are gone — the
|
|
40
39
|
// prefix space is unbounded. See Track B.3 in docs/plans/tests-refactor.
|
|
41
40
|
const prefix = opts.borrowKeyPrefix ?? `kt:${generateId().slice(-8)}:`;
|
|
42
|
-
|
|
41
|
+
// Callers that check redis.status (rate-limit resolver) must not see "connect" on their first call.
|
|
42
|
+
const redis = new Redis(redisUrl, { keyPrefix: prefix, lazyConnect: true });
|
|
43
|
+
await redis.connect();
|
|
43
44
|
|
|
44
45
|
async function deleteKeysMatching(pattern: string): Promise<void> {
|
|
45
46
|
// Open a prefix-less client for the scan — ioredis' keyPrefix is applied
|
|
@@ -109,7 +109,7 @@ export type RequestHelper = {
|
|
|
109
109
|
export type RequestHelperOptions = {
|
|
110
110
|
// When sessionChecker is wired (sessions feature), JWTs without jti are
|
|
111
111
|
// rejected as no_sid. Seed/test helpers that only jwt.sign(user) need a
|
|
112
|
-
// live sid — create one via the same sessionCreator login uses
|
|
112
|
+
// live sid — create one via the same sessionCreator login uses.
|
|
113
113
|
readonly sessionCreator?: SessionCreator;
|
|
114
114
|
};
|
|
115
115
|
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @runtime dev
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import type { AuthRoutesConfig, JwtHelper } from "@cosmicdrift/kumiko-framework/api";
|
|
3
4
|
import { buildServer, createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
5
|
+
import type { DbConnection, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
4
6
|
import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
7
|
import type {
|
|
6
8
|
AppContext,
|
|
@@ -50,7 +52,12 @@ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
|
50
52
|
import { createInMemorySearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
51
53
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
52
54
|
import type { Hono } from "hono";
|
|
53
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
createTestDb,
|
|
57
|
+
ensureTestDbTemplate,
|
|
58
|
+
TEST_DB_TEMPLATE_RUN_ENV,
|
|
59
|
+
testDbTemplatePrefix,
|
|
60
|
+
} from "./db.ts";
|
|
54
61
|
import { createJobFailureTracker, drainJobs } from "./drain-jobs.ts";
|
|
55
62
|
import { createEventCollector, type EventCollector } from "./event-collector.ts";
|
|
56
63
|
import { createTestRedis, queueNamePrefixForTestRedis, type TestRedis } from "./redis.ts";
|
|
@@ -83,7 +90,7 @@ export type TestStack = {
|
|
|
83
90
|
// The AppContext buildServer handed the request path, incl. the fields it
|
|
84
91
|
// wires itself (_fileProviderResolver). A dev-server that starts its own
|
|
85
92
|
// lane job-runners beside this stack must hand them THIS, not a
|
|
86
|
-
// `{ db, registry }` literal — that's the
|
|
93
|
+
// `{ db, registry }` literal — that's the drift, and it makes an
|
|
87
94
|
// event-triggered job reaching for ctx.files die where the request path works.
|
|
88
95
|
context: AppContext;
|
|
89
96
|
// Present whenever a system consumer (SSE, Search) or
|
|
@@ -246,11 +253,11 @@ export type TestStackOptions = {
|
|
|
246
253
|
/** Override the event dispatcher's polling-timer interval. Default 50ms.
|
|
247
254
|
* Tests that assert LISTEN/NOTIFY wake-up latency need this pushed far
|
|
248
255
|
* out (e.g. 60_000) so the polling timer can't land inside the
|
|
249
|
-
* assertion window and mask a dead subscription
|
|
256
|
+
* assertion window and mask a dead subscription. */
|
|
250
257
|
eventDispatcherPollIntervalMs?: number;
|
|
251
258
|
/** Opt out of auto-created r.entity() tables for callers that create/migrate
|
|
252
259
|
* them themselves. Default true — false pushes only projection/MSP/
|
|
253
|
-
* storeTable sources, same as before
|
|
260
|
+
* storeTable sources, same as before. */
|
|
254
261
|
entityTables?: boolean;
|
|
255
262
|
/** Time source of the stack's rate-limit resolver, so tests can freeze the
|
|
256
263
|
* clock instead of racing the window. Default: system time. */
|
|
@@ -304,6 +311,87 @@ function bindMfaTokenSecrets(features: readonly FeatureDefinition[], jwtSecret:
|
|
|
304
311
|
}
|
|
305
312
|
}
|
|
306
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
|
+
|
|
307
395
|
export async function setupTestStack(options: TestStackOptions): Promise<TestStack> {
|
|
308
396
|
const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
|
|
309
397
|
const enabledHooks = options.systemHooks ?? ["sse", "search"];
|
|
@@ -313,9 +401,9 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
313
401
|
const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
|
|
314
402
|
await ensureTemporalPolyfill();
|
|
315
403
|
|
|
316
|
-
// Ownership boot-guards (
|
|
317
|
-
//
|
|
318
|
-
//
|
|
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.
|
|
319
407
|
// Runs after the polyfill (buildEntityTable needs it to derive the probe
|
|
320
408
|
// columns) and before the ephemeral DB/Redis exist, so a throw can't leak
|
|
321
409
|
// a database that nothing will clean up.
|
|
@@ -325,6 +413,13 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
325
413
|
if (options.validateBoot === "full") validateBoot(options.features);
|
|
326
414
|
bindMfaTokenSecrets(options.features, jwtSecret);
|
|
327
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
|
+
|
|
328
423
|
// Forward db-name/persistent-flag through to createTestDb. The
|
|
329
424
|
// defaults (undefined dbName, persistent:false) keep the legacy
|
|
330
425
|
// test contract: fresh kumiko_test_<random> DB per setup, dropped
|
|
@@ -337,6 +432,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
337
432
|
...(options.outsideTransactionPoolMax !== undefined && {
|
|
338
433
|
outsideTransactionPoolMax: options.outsideTransactionPoolMax,
|
|
339
434
|
}),
|
|
435
|
+
...(templateDbName !== undefined && { templateDbName }),
|
|
340
436
|
}),
|
|
341
437
|
createTestRedis(
|
|
342
438
|
options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix },
|
|
@@ -347,70 +443,22 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
347
443
|
// DB/Redis — the caller never gets the stack object back to call
|
|
348
444
|
// cleanup() itself (same "leaked ephemeral DB" failure class as the later
|
|
349
445
|
// try/catch below, but for setup that runs before that try starts).
|
|
350
|
-
|
|
351
|
-
// Every ES-entity writes events via createEventStoreExecutor in the
|
|
352
|
-
// feature's write handlers. Auto-create the events table so every
|
|
353
|
-
// setupTestStack call is ready for writes without needing a manual
|
|
354
|
-
// createEventsTable().
|
|
355
|
-
await createEventsTable(testDb.db);
|
|
356
|
-
// Archive-stream metadata — needed by ctx.appendEvent's archive guard and
|
|
357
|
-
// loadAggregate's default-skip. Idempotent, so production boot running
|
|
358
|
-
// the same call is fine.
|
|
359
|
-
await createArchivedStreamsTable(testDb.db);
|
|
360
|
-
|
|
361
|
-
// Framework state for projection rebuild/status + event-consumer cursors.
|
|
362
|
-
// Idempotent — production boot flows run the same calls.
|
|
363
|
-
const { createProjectionStateTable, createEventConsumerStateTable } = await import(
|
|
364
|
-
"@cosmicdrift/kumiko-framework/pipeline"
|
|
365
|
-
);
|
|
366
|
-
await createProjectionStateTable(testDb.db);
|
|
367
|
-
await createEventConsumerStateTable(testDb.db);
|
|
368
|
-
|
|
369
|
-
// Files support: when a provider is registered, the fileRefs table must
|
|
370
|
-
// exist before the first upload. Skipped when no provider — the table
|
|
371
|
-
// stays off tenant test DBs that never touch files.
|
|
372
|
-
if (options.files) {
|
|
373
|
-
const { fileRefsTable, fileUploadLedgerTable } = await import(
|
|
374
|
-
"@cosmicdrift/kumiko-framework/files"
|
|
375
|
-
);
|
|
376
|
-
await unsafePushTables(testDb.db, { fileRefsTable, fileUploadLedgerTable });
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// Same table list as `kumiko schema generate` (collectTableMetas) — divergence
|
|
380
|
-
// was the #255 prod-crash and the #3102 missing r.entity() tables.
|
|
381
|
-
const { collectTableMetas } = await import("@cosmicdrift/kumiko-framework/db");
|
|
382
|
-
const { tableExists } = await import("@cosmicdrift/kumiko-framework/db");
|
|
383
|
-
let metas = collectTableMetas(options.features);
|
|
384
|
-
if (options.entityTables === false) {
|
|
385
|
-
const { enumerateFeatureTableSources } = await import("@cosmicdrift/kumiko-framework/db");
|
|
386
|
-
const { extractTableInfo } = await import("@cosmicdrift/kumiko-framework/db");
|
|
387
|
-
const nonEntityNames = new Set(
|
|
388
|
-
options.features.flatMap((f) =>
|
|
389
|
-
enumerateFeatureTableSources(f).map((s) => extractTableInfo(s.table).name),
|
|
390
|
-
),
|
|
391
|
-
);
|
|
392
|
-
metas = metas.filter((m) => nonEntityNames.has(m.tableName));
|
|
393
|
-
}
|
|
394
|
-
const missing: Record<string, unknown> = {};
|
|
395
|
-
for (const meta of metas) {
|
|
396
|
-
if (await tableExists(testDb.db, `public.${meta.tableName}`)) continue;
|
|
397
|
-
missing[meta.tableName] = meta;
|
|
398
|
-
}
|
|
399
|
-
if (Object.keys(missing).length > 0) {
|
|
400
|
-
await unsafePushTables(testDb.db, missing);
|
|
401
|
-
}
|
|
402
|
-
} catch (error) {
|
|
446
|
+
if (templateDbName === undefined) {
|
|
403
447
|
try {
|
|
404
|
-
await testDb.
|
|
405
|
-
} catch {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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;
|
|
412
461
|
}
|
|
413
|
-
throw error;
|
|
414
462
|
}
|
|
415
463
|
|
|
416
464
|
// Redis only where two stacks can actually disagree (avoids two extra
|
|
@@ -456,16 +504,16 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
456
504
|
|
|
457
505
|
// Same AppContext buildServer() gets below (db/redis/searchAdapter/
|
|
458
506
|
// entityCache/masterKeyProvider/fileProviderResolver/extraContext) —
|
|
459
|
-
// shared so the job context can't drift from the request-path context
|
|
460
|
-
//
|
|
461
|
-
//
|
|
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.
|
|
462
510
|
//
|
|
463
511
|
// effectiveFeatures deliberately stays OUT of appContext — prod never puts
|
|
464
512
|
// it there either (only dispatcherOptions.effectiveFeatures, consumed by
|
|
465
513
|
// the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
|
|
466
514
|
// index.ts). Putting it here would reintroduce the exact test-vs-prod
|
|
467
|
-
// drift
|
|
468
|
-
//
|
|
515
|
+
// drift fixed: a job/handler reading `ctx.effectiveFeatures` would pass in
|
|
516
|
+
// tests and break in prod.
|
|
469
517
|
const appContext = {
|
|
470
518
|
db: testDb.db,
|
|
471
519
|
redis: testRedis.redis,
|
|
@@ -499,8 +547,8 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
499
547
|
jobRunner = createJobRunner({
|
|
500
548
|
registry,
|
|
501
549
|
context: { ...appContext, tracer: observability.tracer, meter: observability.meter },
|
|
502
|
-
// The real REDIS_URL, not one rebuilt from `.options` —
|
|
503
|
-
// password/username/tls/path
|
|
550
|
+
// The real REDIS_URL, not one rebuilt from `.options` — rebuilding it lost
|
|
551
|
+
// password/username/tls/path.
|
|
504
552
|
redisUrl: testRedis.redisUrl,
|
|
505
553
|
...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
|
|
506
554
|
queueNamePrefix:
|
|
@@ -534,7 +582,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
534
582
|
// above (createJobRunner() itself opens live BullMQ Queue/lock Redis
|
|
535
583
|
// connections, .start() adds a live worker) — otherwise a failing
|
|
536
584
|
// buildServer()/search-config/etc. leaks that connection and the test
|
|
537
|
-
// process hangs on exit
|
|
585
|
+
// process hangs on exit.
|
|
538
586
|
try {
|
|
539
587
|
if (jobRunner) await jobRunner.start();
|
|
540
588
|
|