@cosmicdrift/kumiko-framework 0.152.0 → 0.154.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/package.json +2 -2
- package/src/__tests__/anonymous-access.integration.test.ts +25 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator.test.ts +7 -4
- package/src/engine/__tests__/nav.test.ts +80 -0
- package/src/engine/boot-validator/action-wiring.ts +99 -0
- package/src/engine/boot-validator/index.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +42 -0
- package/src/engine/feature-ui-extensions.ts +21 -0
- package/src/engine/types/feature.ts +3 -1
- package/src/engine/types/screen.ts +15 -2
- package/src/stack/redis.ts +8 -0
- package/src/stack/test-stack.ts +156 -136
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.154.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.154.0",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
|
@@ -361,6 +361,31 @@ describe("anonymous access — resolverTrust: authoritative", () => {
|
|
|
361
361
|
const body = (await res.json()) as { error: { code: string } };
|
|
362
362
|
expect(body.error.code).toBe("tenant_mismatch");
|
|
363
363
|
});
|
|
364
|
+
|
|
365
|
+
// pr-review kumiko-framework #1052/3 — resolveTenant treats header and
|
|
366
|
+
// cookie identically, but every case above only exercised the header.
|
|
367
|
+
// These two lock in that the cookie path behaves the same way.
|
|
368
|
+
test("kumiko_tenant cookie agreeing with the resolver → accepted", async () => {
|
|
369
|
+
const res = await stack.http.raw(
|
|
370
|
+
"POST",
|
|
371
|
+
"/api/query",
|
|
372
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
373
|
+
{ Cookie: `kumiko_tenant=${TENANT_ID}` },
|
|
374
|
+
);
|
|
375
|
+
expect(res.status).toBe(200);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("kumiko_tenant cookie disagreeing with the resolver → 400 tenant_mismatch, same as a disagreeing header", async () => {
|
|
379
|
+
const res = await stack.http.raw(
|
|
380
|
+
"POST",
|
|
381
|
+
"/api/query",
|
|
382
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
383
|
+
{ Cookie: `kumiko_tenant=${OTHER_TENANT_ID}` },
|
|
384
|
+
);
|
|
385
|
+
expect(res.status).toBe(400);
|
|
386
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
387
|
+
expect(body.error.code).toBe("tenant_mismatch");
|
|
388
|
+
});
|
|
364
389
|
});
|
|
365
390
|
|
|
366
391
|
describe("anonymous access — resolverTrust: authoritative, resolver returns null", () => {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { validateBoot } from "../boot-validator";
|
|
4
|
+
import { defineFeature } from "../define-feature";
|
|
5
|
+
import { createEntity, createTextField } from "../factories";
|
|
6
|
+
|
|
7
|
+
describe("validateBoot — action wiring (no function values)", () => {
|
|
8
|
+
test("rowAction writeHandler payload as function → Throw", () => {
|
|
9
|
+
const feature = defineFeature("shop", (r) => {
|
|
10
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
11
|
+
r.screen({
|
|
12
|
+
id: "product-list",
|
|
13
|
+
type: "entityList",
|
|
14
|
+
entity: "product",
|
|
15
|
+
columns: ["name"],
|
|
16
|
+
rowActions: [
|
|
17
|
+
{
|
|
18
|
+
kind: "writeHandler",
|
|
19
|
+
id: "sync",
|
|
20
|
+
label: "actions.sync",
|
|
21
|
+
handler: "shop:write:sync",
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
23
|
+
payload: ((row: unknown) => ({ id: row })) as any,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
28
|
+
access: { roles: ["Admin"] },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("rowAction writeHandler payload as declarative pick → kein Throw", () => {
|
|
35
|
+
const feature = defineFeature("shop", (r) => {
|
|
36
|
+
r.entity("product", createEntity({ fields: { name: createTextField({ sortable: true }) } }));
|
|
37
|
+
r.screen({
|
|
38
|
+
id: "product-list",
|
|
39
|
+
type: "entityList",
|
|
40
|
+
entity: "product",
|
|
41
|
+
columns: ["name"],
|
|
42
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
43
|
+
rowActions: [
|
|
44
|
+
{
|
|
45
|
+
kind: "writeHandler",
|
|
46
|
+
id: "sync",
|
|
47
|
+
label: "actions.sync",
|
|
48
|
+
handler: "shop:write:sync",
|
|
49
|
+
payload: { pick: ["name"] },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
54
|
+
access: { roles: ["Admin"] },
|
|
55
|
+
});
|
|
56
|
+
r.translations({
|
|
57
|
+
keys: {
|
|
58
|
+
"screen:product-list.title": { de: "Liste", en: "List" },
|
|
59
|
+
"shop:entity:product:field:name": { de: "Name", en: "Name" },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rowAction navigate visible as function → Throw", () => {
|
|
67
|
+
const feature = defineFeature("shop", (r) => {
|
|
68
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
69
|
+
r.screen({
|
|
70
|
+
id: "product-list",
|
|
71
|
+
type: "entityList",
|
|
72
|
+
entity: "product",
|
|
73
|
+
columns: ["name"],
|
|
74
|
+
rowActions: [
|
|
75
|
+
{
|
|
76
|
+
kind: "navigate",
|
|
77
|
+
id: "edit",
|
|
78
|
+
label: "actions.edit",
|
|
79
|
+
screen: "product-list",
|
|
80
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
81
|
+
visible: (() => true) as any,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
expect(() => validateBoot([feature])).toThrow(/rowAction "edit" visible is a function/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("toolbarAction writeHandler payload as function → Throw", () => {
|
|
90
|
+
const feature = defineFeature("shop", (r) => {
|
|
91
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
92
|
+
r.screen({
|
|
93
|
+
id: "product-list",
|
|
94
|
+
type: "entityList",
|
|
95
|
+
entity: "product",
|
|
96
|
+
columns: ["name"],
|
|
97
|
+
toolbarActions: [
|
|
98
|
+
{
|
|
99
|
+
kind: "writeHandler",
|
|
100
|
+
id: "sync",
|
|
101
|
+
label: "actions.sync",
|
|
102
|
+
handler: "shop:write:sync",
|
|
103
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
104
|
+
payload: (() => ({})) as any,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
109
|
+
access: { roles: ["Admin"] },
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
expect(() => validateBoot([feature])).toThrow(/toolbarAction "sync" payload is a function/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("entityList column renderer as function → Throw", () => {
|
|
116
|
+
const feature = defineFeature("shop", (r) => {
|
|
117
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
118
|
+
r.screen({
|
|
119
|
+
id: "product-list",
|
|
120
|
+
type: "entityList",
|
|
121
|
+
entity: "product",
|
|
122
|
+
columns: [
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
124
|
+
{ field: "name", renderer: ((v: unknown) => String(v)) as any },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
expect(() => validateBoot([feature])).toThrow(/column "name" renderer is a function/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("entityEdit field visible as function → Throw", () => {
|
|
132
|
+
const feature = defineFeature("shop", (r) => {
|
|
133
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
134
|
+
r.screen({
|
|
135
|
+
id: "product-edit",
|
|
136
|
+
type: "entityEdit",
|
|
137
|
+
entity: "product",
|
|
138
|
+
layout: {
|
|
139
|
+
sections: [
|
|
140
|
+
{
|
|
141
|
+
columns: 1,
|
|
142
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
143
|
+
fields: [{ field: "name", visible: (() => true) as any }],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
expect(() => validateBoot([feature])).toThrow(/field "name" visible is a function/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("entityEdit field renderer as function → Throw", () => {
|
|
153
|
+
const feature = defineFeature("shop", (r) => {
|
|
154
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
155
|
+
r.screen({
|
|
156
|
+
id: "product-edit",
|
|
157
|
+
type: "entityEdit",
|
|
158
|
+
entity: "product",
|
|
159
|
+
layout: {
|
|
160
|
+
sections: [
|
|
161
|
+
{
|
|
162
|
+
columns: 1,
|
|
163
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
164
|
+
fields: [{ field: "name", renderer: ((v: unknown) => String(v)) as any }],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("entityEdit field with declarative visible/readOnly/required → kein Throw", () => {
|
|
174
|
+
const feature = defineFeature("shop", (r) => {
|
|
175
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
176
|
+
r.screen({
|
|
177
|
+
id: "product-edit",
|
|
178
|
+
type: "entityEdit",
|
|
179
|
+
entity: "product",
|
|
180
|
+
layout: {
|
|
181
|
+
sections: [
|
|
182
|
+
{
|
|
183
|
+
columns: 1,
|
|
184
|
+
fields: [
|
|
185
|
+
{
|
|
186
|
+
field: "name",
|
|
187
|
+
visible: { field: "name", ne: "" },
|
|
188
|
+
readOnly: false,
|
|
189
|
+
required: true,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
r.translations({
|
|
197
|
+
keys: {
|
|
198
|
+
"screen:product-edit.title": { de: "Bearbeiten", en: "Edit" },
|
|
199
|
+
"shop:entity:product:field:name": { de: "Name", en: "Name" },
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("projectionList column renderer as function → Throw", () => {
|
|
207
|
+
const feature = defineFeature("shop", (r) => {
|
|
208
|
+
r.screen({
|
|
209
|
+
id: "sales-list",
|
|
210
|
+
type: "projectionList",
|
|
211
|
+
query: "shop:query:sales",
|
|
212
|
+
columns: [
|
|
213
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
214
|
+
{ field: "amount", renderer: ((v: unknown) => String(v)) as any },
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("projectionList rowAction payload as function → Throw", () => {
|
|
222
|
+
const feature = defineFeature("shop", (r) => {
|
|
223
|
+
r.screen({
|
|
224
|
+
id: "sales-list",
|
|
225
|
+
type: "projectionList",
|
|
226
|
+
query: "shop:query:sales",
|
|
227
|
+
columns: ["amount"],
|
|
228
|
+
rowActions: [
|
|
229
|
+
{
|
|
230
|
+
kind: "writeHandler",
|
|
231
|
+
id: "sync",
|
|
232
|
+
label: "actions.sync",
|
|
233
|
+
handler: "shop:write:sync",
|
|
234
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
235
|
+
payload: (() => ({})) as any,
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -1231,8 +1231,9 @@ describe("boot-validator", () => {
|
|
|
1231
1231
|
// --- entityList column-renderer form-check ---
|
|
1232
1232
|
// Validator akzeptiert die `{ react: { __component: "Name" } }`-Form
|
|
1233
1233
|
// (PlatformComponent → client-side Registry-Lookup) und prüft sie
|
|
1234
|
-
// strukturell.
|
|
1235
|
-
//
|
|
1234
|
+
// strukturell. null/undefined, native-only und andere Formen bleiben
|
|
1235
|
+
// opak. Function-Renderer werfen (action-wiring.ts validateFieldWiring) —
|
|
1236
|
+
// von JSON.stringify verworfen, sonst stiller No-op auf dem Client.
|
|
1236
1237
|
describe("entityList column renderer form", () => {
|
|
1237
1238
|
function shopFeature(renderer: unknown) {
|
|
1238
1239
|
return defineFeature("shop", (r) => {
|
|
@@ -1250,8 +1251,10 @@ describe("boot-validator", () => {
|
|
|
1250
1251
|
});
|
|
1251
1252
|
}
|
|
1252
1253
|
|
|
1253
|
-
test("function-renderer →
|
|
1254
|
-
expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).
|
|
1254
|
+
test("function-renderer → Throw (Function wird von JSON.stringify verworfen)", () => {
|
|
1255
|
+
expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).toThrow(
|
|
1256
|
+
/column "name" renderer is a function/,
|
|
1257
|
+
);
|
|
1255
1258
|
});
|
|
1256
1259
|
|
|
1257
1260
|
test("undefined renderer → kein Throw (Spalte ohne Renderer)", () => {
|
|
@@ -80,6 +80,86 @@ describe("r.nav() — registration", () => {
|
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
describe("r.screen({ nav }) — inline nav sugar", () => {
|
|
84
|
+
test("synthesizes a nav entry from the screen's id", () => {
|
|
85
|
+
const feature = defineFeature("shop", (r) => {
|
|
86
|
+
r.entity("product", productEntity());
|
|
87
|
+
r.screen({
|
|
88
|
+
id: "products",
|
|
89
|
+
type: "entityList",
|
|
90
|
+
entity: "product",
|
|
91
|
+
columns: ["name"],
|
|
92
|
+
nav: { label: "shop:nav.products", icon: "box", order: 5 },
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
expect(feature.navs["products"]).toMatchObject({
|
|
96
|
+
id: "products",
|
|
97
|
+
label: "shop:nav.products",
|
|
98
|
+
icon: "box",
|
|
99
|
+
order: 5,
|
|
100
|
+
screen: "shop:screen:products",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("supports parent like a standalone r.nav()", () => {
|
|
105
|
+
const feature = defineFeature("shop", (r) => {
|
|
106
|
+
r.nav({ id: "catalog", label: "x" });
|
|
107
|
+
r.entity("product", productEntity());
|
|
108
|
+
r.screen({
|
|
109
|
+
id: "products",
|
|
110
|
+
type: "entityList",
|
|
111
|
+
entity: "product",
|
|
112
|
+
columns: ["name"],
|
|
113
|
+
nav: { label: "y", parent: "shop:nav:catalog" },
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
expect(feature.navs["products"]?.parent).toBe("shop:nav:catalog");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("passes the same boot-validation as a standalone r.nav()", () => {
|
|
120
|
+
const feature = defineFeature("shop", (r) => {
|
|
121
|
+
r.entity("product", productEntity());
|
|
122
|
+
r.screen({
|
|
123
|
+
id: "products",
|
|
124
|
+
type: "entityList",
|
|
125
|
+
entity: "product",
|
|
126
|
+
columns: ["name"],
|
|
127
|
+
nav: { label: "y" },
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("screen without nav registers no nav entry", () => {
|
|
134
|
+
const feature = defineFeature("shop", (r) => {
|
|
135
|
+
r.entity("product", productEntity());
|
|
136
|
+
r.screen({
|
|
137
|
+
id: "products",
|
|
138
|
+
type: "entityList",
|
|
139
|
+
entity: "product",
|
|
140
|
+
columns: ["name"],
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
expect(feature.navs["products"]).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("rejects when a standalone r.nav() already used the screen's id", () => {
|
|
147
|
+
expect(() =>
|
|
148
|
+
defineFeature("shop", (r) => {
|
|
149
|
+
r.nav({ id: "products", label: "standalone" });
|
|
150
|
+
r.entity("product", productEntity());
|
|
151
|
+
r.screen({
|
|
152
|
+
id: "products",
|
|
153
|
+
type: "entityList",
|
|
154
|
+
entity: "product",
|
|
155
|
+
columns: ["name"],
|
|
156
|
+
nav: { label: "inline" },
|
|
157
|
+
});
|
|
158
|
+
}),
|
|
159
|
+
).toThrow(/already registered/);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
83
163
|
describe("createRegistry — nav indexing", () => {
|
|
84
164
|
test("indexes nav entries by qualified name", () => {
|
|
85
165
|
const feature = defineFeature("shop", (r) => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { EditFieldSpec, FeatureDefinition, RowAction, ToolbarAction } from "../types";
|
|
2
|
+
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
3
|
+
|
|
4
|
+
const FUNCTION_DROPPED_HINT =
|
|
5
|
+
"functions are dropped by JSON.stringify when the screen config reaches the client " +
|
|
6
|
+
"bundle — the action/field silently no-ops at runtime instead of failing loudly.";
|
|
7
|
+
|
|
8
|
+
function throwIfFunction(value: unknown, message: string): void {
|
|
9
|
+
if (typeof value === "function") {
|
|
10
|
+
throw new Error(message);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// rowActions/toolbarActions carry row-context extractors (payload/params),
|
|
15
|
+
// static visibility conditions and navigate entityId — all declarative-DSL-
|
|
16
|
+
// only fields (RowFieldExtractor's `{ pick }`/`{ map }`, FieldCondition's
|
|
17
|
+
// `{ field, eq }`/`{ field, ne }`, plain strings). A function literal here
|
|
18
|
+
// compiles fine (the DSL types are structural, not branded) but is silently
|
|
19
|
+
// dropped once the feature config is serialized for the client.
|
|
20
|
+
const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as const;
|
|
21
|
+
|
|
22
|
+
function validateActionNoFunctions(
|
|
23
|
+
featureName: string,
|
|
24
|
+
screenId: string,
|
|
25
|
+
actionKind: "rowAction" | "toolbarAction",
|
|
26
|
+
action: RowAction | ToolbarAction,
|
|
27
|
+
): void {
|
|
28
|
+
const record = action as unknown as Record<string, unknown>;
|
|
29
|
+
for (const field of ACTION_FUNCTION_FIELDS) {
|
|
30
|
+
throwIfFunction(
|
|
31
|
+
record[field],
|
|
32
|
+
`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${action.id}" ${field} ` +
|
|
33
|
+
`is a function — ${FUNCTION_DROPPED_HINT} Use the declarative DSL ({ pick }, { map }, ` +
|
|
34
|
+
`"fieldName", { field, eq }) instead.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateActionWiring(feature: FeatureDefinition): void {
|
|
40
|
+
for (const screen of Object.values(feature.screens)) {
|
|
41
|
+
if (screen.type !== "entityList" && screen.type !== "projectionList") continue;
|
|
42
|
+
for (const action of screen.rowActions ?? []) {
|
|
43
|
+
validateActionNoFunctions(feature.name, screen.id, "rowAction", action);
|
|
44
|
+
}
|
|
45
|
+
for (const action of screen.toolbarActions ?? []) {
|
|
46
|
+
validateActionNoFunctions(feature.name, screen.id, "toolbarAction", action);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// EditFieldSpec's visible/readOnly/required are the same FieldCondition DSL
|
|
52
|
+
// as rowActions — same footgun, different screen type (entityEdit layouts).
|
|
53
|
+
const EDIT_FIELD_FUNCTION_FIELDS = ["visible", "readOnly", "required"] as const;
|
|
54
|
+
|
|
55
|
+
function validateEditFieldNoFunctions(
|
|
56
|
+
featureName: string,
|
|
57
|
+
screenId: string,
|
|
58
|
+
fieldSpec: EditFieldSpec,
|
|
59
|
+
): void {
|
|
60
|
+
const normalized = normalizeEditField(fieldSpec);
|
|
61
|
+
const record = normalized as unknown as Record<string, unknown>;
|
|
62
|
+
for (const key of EDIT_FIELD_FUNCTION_FIELDS) {
|
|
63
|
+
throwIfFunction(
|
|
64
|
+
record[key],
|
|
65
|
+
`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
|
|
66
|
+
`${key} is a function — ${FUNCTION_DROPPED_HINT} Use a FieldCondition ` +
|
|
67
|
+
`(boolean or { field, eq }/{ field, ne }) instead.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throwIfFunction(
|
|
71
|
+
normalized.renderer,
|
|
72
|
+
`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
|
|
73
|
+
`renderer is a function — ${FUNCTION_DROPPED_HINT} Use a FormatSpec ({ format: "..." }) instead.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateFieldWiring(feature: FeatureDefinition): void {
|
|
78
|
+
for (const screen of Object.values(feature.screens)) {
|
|
79
|
+
if (screen.type === "entityList" || screen.type === "projectionList") {
|
|
80
|
+
for (const col of screen.columns) {
|
|
81
|
+
const normalized = normalizeListColumn(col);
|
|
82
|
+
throwIfFunction(
|
|
83
|
+
normalized.renderer,
|
|
84
|
+
`[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) column ` +
|
|
85
|
+
`"${normalized.field}" renderer is a function — ${FUNCTION_DROPPED_HINT} ` +
|
|
86
|
+
`Use a FormatSpec ({ format: "..." }) instead.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (screen.type !== "entityEdit") continue;
|
|
92
|
+
for (const section of screen.layout.sections) {
|
|
93
|
+
if (isExtensionEditSection(section)) continue;
|
|
94
|
+
for (const fieldSpec of section.fields) {
|
|
95
|
+
validateEditFieldNoFunctions(feature.name, screen.id, fieldSpec);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
3
|
import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
|
|
4
|
+
import { validateActionWiring, validateFieldWiring } from "./action-wiring";
|
|
4
5
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
|
|
5
6
|
import {
|
|
6
7
|
validateCircularDeps,
|
|
@@ -171,6 +172,12 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
171
172
|
validateConfigKeyBacking(feature);
|
|
172
173
|
validateOwnershipRules(feature, allClaimKeys, knownRoles);
|
|
173
174
|
validateMultiStreamProjections(feature);
|
|
175
|
+
// Vor validateScreens: dessen visible/entityId-Feldref-Checks werfen für
|
|
176
|
+
// einen Function-Wert bereits (mit verwirrender "unknown field undefined"-
|
|
177
|
+
// Message, weil `typeof fn !== "boolean"` true ist), bevor der klare
|
|
178
|
+
// Function-Check hier überhaupt liefe.
|
|
179
|
+
validateActionWiring(feature);
|
|
180
|
+
validateFieldWiring(feature);
|
|
174
181
|
validateScreens(
|
|
175
182
|
feature,
|
|
176
183
|
featureMap,
|
|
@@ -358,3 +358,45 @@ describe("renderPattern — single-pattern shape", () => {
|
|
|
358
358
|
expect(out).toBe('r.metric({ name: "requests", type: "counter" });');
|
|
359
359
|
});
|
|
360
360
|
});
|
|
361
|
+
|
|
362
|
+
// Regression guard for the class of bug the r.exposesApi/r.usesApi fold
|
|
363
|
+
// hit: a registrar-shape change (there, removing a method; here, adding a
|
|
364
|
+
// nested optional field) must survive the Designer's parse→render→parse
|
|
365
|
+
// cycle, not just a TS compile of the framework itself. r.screen()'s `nav`
|
|
366
|
+
// sugar is a generic nested object on an already-generic pattern (no
|
|
367
|
+
// per-field extractor/renderer), so this is the cheap case — but proving
|
|
368
|
+
// it beats assuming it.
|
|
369
|
+
const SCREEN_WITH_NAV_FEATURE = `
|
|
370
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
371
|
+
|
|
372
|
+
defineFeature("shop", (r) => {
|
|
373
|
+
r.entity("product", { fields: { name: { type: "text" } } });
|
|
374
|
+
r.screen({
|
|
375
|
+
id: "products",
|
|
376
|
+
type: "entityList",
|
|
377
|
+
entity: "product",
|
|
378
|
+
columns: ["name"],
|
|
379
|
+
nav: { label: "shop:nav.products", icon: "box", order: 5 },
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
`;
|
|
383
|
+
|
|
384
|
+
describe("render → parse roundtrip — r.screen({ nav }) inline sugar", () => {
|
|
385
|
+
test("nested nav object survives parse → render → parse unchanged", () => {
|
|
386
|
+
const initial = parse(SCREEN_WITH_NAV_FEATURE);
|
|
387
|
+
const rendered = renderFeatureFile({
|
|
388
|
+
featureName: initial.featureName ?? "",
|
|
389
|
+
patterns: initial.patterns,
|
|
390
|
+
});
|
|
391
|
+
const reparsed = parse(rendered);
|
|
392
|
+
expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
|
|
393
|
+
|
|
394
|
+
const screenPattern = reparsed.patterns.find((p) => p.kind === "screen");
|
|
395
|
+
expect(screenPattern?.kind).toBe("screen");
|
|
396
|
+
if (screenPattern?.kind === "screen") {
|
|
397
|
+
expect(screenPattern.definition).toMatchObject({
|
|
398
|
+
nav: { label: "shop:nav.products", icon: "box", order: 5 },
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
});
|
|
@@ -260,6 +260,27 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
260
260
|
);
|
|
261
261
|
}
|
|
262
262
|
state.screens[definition.id] = definition;
|
|
263
|
+
if (definition.nav) {
|
|
264
|
+
// Sugar for the common "one nav entry pointing at this screen"
|
|
265
|
+
// case — synthesizes id/screen from the screen's own id. Beyond
|
|
266
|
+
// label/icon/parent/order, declare a standalone r.nav() instead.
|
|
267
|
+
if (state.navs[definition.id]) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
|
|
270
|
+
`Nav ids must be unique per feature — remove the standalone ` +
|
|
271
|
+
`r.nav("${definition.id}", ...) call or the screen's inline nav.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const navDefinition: NavDefinition = {
|
|
275
|
+
id: definition.id,
|
|
276
|
+
label: definition.nav.label,
|
|
277
|
+
icon: definition.nav.icon,
|
|
278
|
+
parent: definition.nav.parent,
|
|
279
|
+
order: definition.nav.order,
|
|
280
|
+
screen: `${name}:screen:${definition.id}`,
|
|
281
|
+
};
|
|
282
|
+
state.navs[definition.id] = navDefinition;
|
|
283
|
+
}
|
|
263
284
|
},
|
|
264
285
|
nav(definition: NavDefinition): void {
|
|
265
286
|
// Reject kebab-drift at registration-time so the stack trace points at
|
|
@@ -684,7 +684,9 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
684
684
|
// the registry qualifies to "<feature>:screen:<id>". Boot-validation checks
|
|
685
685
|
// that entity-bound screens reference a registered entity and that the
|
|
686
686
|
// columns / form-field refs name real fields — cross-feature component-QN
|
|
687
|
-
// validation (r.uiComponent) comes in M4/M5.
|
|
687
|
+
// validation (r.uiComponent) comes in M4/M5. Optional `nav` field is
|
|
688
|
+
// sugar for a single nav entry pointing at this screen — equivalent to
|
|
689
|
+
// a standalone r.nav({ id: <same id>, screen: "<feature>:screen:<id>", ... }).
|
|
688
690
|
screen(definition: ScreenDefinition): void;
|
|
689
691
|
|
|
690
692
|
// Register a nav entry. The id is the feature-local short name (kebab-case);
|
|
@@ -727,7 +727,19 @@ export type ScreenSlots = {
|
|
|
727
727
|
|
|
728
728
|
// --- discriminated union ---
|
|
729
729
|
|
|
730
|
-
|
|
730
|
+
// Inline nav-entry sugar for `r.screen({ ..., nav: {...} })` — covers the
|
|
731
|
+
// common case of "one nav entry pointing at this screen". The nav entry's
|
|
732
|
+
// `id`/`screen` are synthesized from the screen's own id; for anything
|
|
733
|
+
// beyond label/icon/parent/order (access-gating, workspaces, actions),
|
|
734
|
+
// declare a standalone `r.nav()` entry instead.
|
|
735
|
+
export type ScreenNavSugar = {
|
|
736
|
+
readonly label: string;
|
|
737
|
+
readonly icon?: string;
|
|
738
|
+
readonly parent?: string;
|
|
739
|
+
readonly order?: number;
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
export type ScreenDefinition = (
|
|
731
743
|
| EntityListScreenDefinition
|
|
732
744
|
| ProjectionListScreenDefinition
|
|
733
745
|
| ProjectionDetailScreenDefinition
|
|
@@ -735,7 +747,8 @@ export type ScreenDefinition =
|
|
|
735
747
|
| EntityEditScreenDefinition
|
|
736
748
|
| ActionFormScreenDefinition
|
|
737
749
|
| ConfigEditScreenDefinition
|
|
738
|
-
| CustomScreenDefinition
|
|
750
|
+
| CustomScreenDefinition
|
|
751
|
+
) & { readonly nav?: ScreenNavSugar };
|
|
739
752
|
|
|
740
753
|
// Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
|
|
741
754
|
// authors who branch on the three FieldRenderer variants without manual
|
package/src/stack/redis.ts
CHANGED
|
@@ -3,6 +3,13 @@ import { requireEnv } from "./db";
|
|
|
3
3
|
|
|
4
4
|
export type TestRedis = {
|
|
5
5
|
redis: import("ioredis").default;
|
|
6
|
+
// The exact REDIS_URL used to build `redis` above — for a second, unrelated
|
|
7
|
+
// connection (e.g. the test-stack's JobRunner) that needs its own client
|
|
8
|
+
// rather than sharing this one's keyPrefix. Reconstructing a URL from
|
|
9
|
+
// `redis.options` loses password/username/tls/path (pr-review
|
|
10
|
+
// kumiko-framework #1036/2) — callers needing a fresh connection should use
|
|
11
|
+
// this raw string, not rebuild one from parsed options.
|
|
12
|
+
redisUrl: string;
|
|
6
13
|
/** Delete every key this test created (prefix-scoped). Replaces the old
|
|
7
14
|
* `redis.flushdb()` — that wiped other parallel tests' BullMQ state. */
|
|
8
15
|
flushNamespace: () => Promise<void>;
|
|
@@ -35,6 +42,7 @@ export async function createTestRedis(): Promise<TestRedis> {
|
|
|
35
42
|
|
|
36
43
|
return {
|
|
37
44
|
redis,
|
|
45
|
+
redisUrl,
|
|
38
46
|
flushNamespace,
|
|
39
47
|
cleanup: async () => {
|
|
40
48
|
await flushNamespace();
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -237,167 +237,187 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
237
237
|
// test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
|
|
238
238
|
let jobRunner: JobRunner | undefined;
|
|
239
239
|
if (options.jobs && registry.getAllJobs().size > 0) {
|
|
240
|
-
const redisOpts = testRedis.redis.options;
|
|
241
240
|
jobRunner = createJobRunner({
|
|
242
241
|
registry,
|
|
243
242
|
context: { db: testDb.db, registry },
|
|
244
|
-
|
|
243
|
+
// The real REDIS_URL, not one rebuilt from `.options` — that lost
|
|
244
|
+
// password/username/tls/path (pr-review kumiko-framework #1036/2).
|
|
245
|
+
redisUrl: testRedis.redisUrl,
|
|
245
246
|
...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
|
|
246
247
|
...(options.jobs.queueNamePrefix !== undefined && {
|
|
247
248
|
queueNamePrefix: options.jobs.queueNamePrefix,
|
|
248
249
|
}),
|
|
249
250
|
});
|
|
250
|
-
await jobRunner.start();
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
253
|
+
// From here on, any throw must stop a jobRunner that was already created
|
|
254
|
+
// above (createJobRunner() itself opens live BullMQ Queue/lock Redis
|
|
255
|
+
// connections, .start() adds a live worker) — otherwise a failing
|
|
256
|
+
// buildServer()/search-config/etc. leaks that connection and the test
|
|
257
|
+
// process hangs on exit (pr-review kumiko-framework #1036/1).
|
|
258
|
+
try {
|
|
259
|
+
if (jobRunner) await jobRunner.start();
|
|
260
|
+
|
|
261
|
+
// Auto-configure search for tenant 1 based on registry
|
|
262
|
+
if (enabledHooks.includes("search")) {
|
|
263
|
+
const searchableFields: string[] = [];
|
|
264
|
+
for (const feature of options.features) {
|
|
265
|
+
for (const [, entity] of Object.entries(feature.entities ?? {})) {
|
|
266
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
267
|
+
if (field.type === "text" && field.searchable) {
|
|
268
|
+
searchableFields.push(fieldName);
|
|
269
|
+
}
|
|
270
|
+
if (field.type === "embedded") {
|
|
271
|
+
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
272
|
+
if (subField.searchable) {
|
|
273
|
+
searchableFields.push(`${fieldName}_${subName}`);
|
|
274
|
+
}
|
|
266
275
|
}
|
|
267
276
|
}
|
|
268
277
|
}
|
|
269
278
|
}
|
|
270
279
|
}
|
|
271
|
-
}
|
|
272
280
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
if (options.searchConfig) {
|
|
282
|
+
await searchAdapter.configure(options.searchConfig.tenantId, {
|
|
283
|
+
searchableFields: options.searchConfig.searchableFields,
|
|
284
|
+
rankingFields: options.searchConfig.rankingFields,
|
|
285
|
+
});
|
|
286
|
+
} else if (searchableFields.length > 0) {
|
|
287
|
+
await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
|
|
288
|
+
searchableFields,
|
|
289
|
+
rankingFields: searchableFields,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
283
292
|
}
|
|
284
|
-
}
|
|
285
293
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
294
|
+
// Wire SSE broker with event collector
|
|
295
|
+
const sseBroker = createSseBroker();
|
|
296
|
+
sseBroker.addClient(
|
|
297
|
+
"tenant:00000000-0000-4000-8000-000000000001",
|
|
298
|
+
(event) => events.sse.push(event),
|
|
299
|
+
() => {},
|
|
300
|
+
);
|
|
293
301
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
302
|
+
const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
|
|
303
|
+
const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
|
|
304
|
+
const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
|
|
297
305
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
+
// A static `files.storageProvider` is wired as the per-tenant resolver — the
|
|
307
|
+
// framework test seam that doesn't require mounting config + file-foundation.
|
|
308
|
+
// (Bundled GDPR tests mount the real provider features instead.)
|
|
309
|
+
let fileProviderResolver: import("../files").FileProviderResolver | undefined;
|
|
310
|
+
if (options.files) {
|
|
311
|
+
const provider = options.files.storageProvider;
|
|
312
|
+
fileProviderResolver = () => Promise.resolve(provider);
|
|
313
|
+
}
|
|
306
314
|
|
|
307
|
-
|
|
308
|
-
registry,
|
|
309
|
-
context: {
|
|
310
|
-
db: testDb.db,
|
|
311
|
-
redis: testRedis.redis,
|
|
312
|
-
searchAdapter,
|
|
313
|
-
entityCache,
|
|
315
|
+
const server = buildServer({
|
|
314
316
|
registry,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
},
|
|
327
|
-
eventDedup,
|
|
328
|
-
sseBroker,
|
|
329
|
-
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
|
330
|
-
// deterministic drains — no timer-induced flakiness. pollIntervalMs
|
|
331
|
-
// stays short anyway in case a test opts into `.start()`. pgClient
|
|
332
|
-
// plumbs through the LISTEN wake-up for tests that want to measure
|
|
333
|
-
// post-commit latency (Sprint E.4).
|
|
334
|
-
eventDispatcher: {
|
|
335
|
-
pollIntervalMs: 50,
|
|
336
|
-
pgClient: testDb.client as PgClient | undefined,
|
|
337
|
-
systemConsumers: {
|
|
338
|
-
sse: enabledHooks.includes("sse"),
|
|
339
|
-
search: enabledHooks.includes("search"),
|
|
317
|
+
context: {
|
|
318
|
+
db: testDb.db,
|
|
319
|
+
redis: testRedis.redis,
|
|
320
|
+
searchAdapter,
|
|
321
|
+
entityCache,
|
|
322
|
+
registry,
|
|
323
|
+
...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
|
|
324
|
+
...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
|
|
325
|
+
...(typeof options.extraContext === "function"
|
|
326
|
+
? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
|
|
327
|
+
: options.extraContext),
|
|
340
328
|
},
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
329
|
+
jwtSecret,
|
|
330
|
+
dispatcherOptions: {
|
|
331
|
+
idempotency,
|
|
332
|
+
...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
|
|
333
|
+
...(jobRunner && { jobRunner }),
|
|
334
|
+
},
|
|
335
|
+
eventDedup,
|
|
336
|
+
sseBroker,
|
|
337
|
+
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
|
338
|
+
// deterministic drains — no timer-induced flakiness. pollIntervalMs
|
|
339
|
+
// stays short anyway in case a test opts into `.start()`. pgClient
|
|
340
|
+
// plumbs through the LISTEN wake-up for tests that want to measure
|
|
341
|
+
// post-commit latency (Sprint E.4).
|
|
342
|
+
eventDispatcher: {
|
|
343
|
+
pollIntervalMs: 50,
|
|
344
|
+
pgClient: testDb.client as PgClient | undefined,
|
|
345
|
+
systemConsumers: {
|
|
346
|
+
sse: enabledHooks.includes("sse"),
|
|
347
|
+
search: enabledHooks.includes("search"),
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
// Default tests to no login rate-limiter so existing suites that loop
|
|
351
|
+
// over logins don't hit a 429 after 10 attempts. Suites specifically
|
|
352
|
+
// testing the limiter can override via authConfig.loginRateLimit.
|
|
353
|
+
...(options.authConfig
|
|
354
|
+
? {
|
|
355
|
+
auth: {
|
|
356
|
+
...options.authConfig,
|
|
357
|
+
...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
: {}),
|
|
361
|
+
...(options.observability ? { observability: options.observability } : {}),
|
|
362
|
+
...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
|
|
363
|
+
...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
|
|
364
|
+
...(options.anonymousAccess
|
|
365
|
+
? {
|
|
366
|
+
anonymousAccess:
|
|
367
|
+
typeof options.anonymousAccess === "function"
|
|
368
|
+
? options.anonymousAccess({
|
|
369
|
+
registry,
|
|
370
|
+
db: testDb.db,
|
|
371
|
+
sseBroker,
|
|
372
|
+
redis: testRedis.redis,
|
|
373
|
+
})
|
|
374
|
+
: options.anonymousAccess,
|
|
375
|
+
}
|
|
376
|
+
: {}),
|
|
377
|
+
});
|
|
370
378
|
|
|
371
|
-
|
|
379
|
+
const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
|
|
372
380
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
381
|
+
// Pre-register consumer state rows so tests can call runOnce() directly
|
|
382
|
+
// without a preceding explicit start(). Timer fires at pollIntervalMs=50
|
|
383
|
+
// but passInFlight serialises concurrent passes — tests that drain via
|
|
384
|
+
// runOnce() remain deterministic. Tests that specifically exercise the
|
|
385
|
+
// timer loop call start() again (idempotent) after setup.
|
|
386
|
+
if (eventDispatcher) await eventDispatcher.ensureRegistered();
|
|
379
387
|
|
|
380
|
-
|
|
388
|
+
const http = createRequestHelper(server.app, server.jwt);
|
|
381
389
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
390
|
+
return {
|
|
391
|
+
app: server.app,
|
|
392
|
+
jwt: server.jwt,
|
|
393
|
+
registry,
|
|
394
|
+
db: testDb.db,
|
|
395
|
+
redis: testRedis,
|
|
396
|
+
search: searchAdapter,
|
|
397
|
+
events,
|
|
398
|
+
http,
|
|
399
|
+
observability: server.observability,
|
|
400
|
+
dispatcher: server.dispatcher,
|
|
401
|
+
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
402
|
+
...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
|
|
403
|
+
...(jobRunner ? { jobRunner } : {}),
|
|
404
|
+
cleanup: async () => {
|
|
405
|
+
if (jobRunner) await jobRunner.stop();
|
|
406
|
+
if (eventDispatcher) await eventDispatcher.stop();
|
|
407
|
+
await server.observability.shutdown();
|
|
408
|
+
await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
} catch (error) {
|
|
412
|
+
// Best-effort — a broken jobRunner.stop() must never mask the real
|
|
413
|
+
// setup failure below.
|
|
414
|
+
if (jobRunner) {
|
|
415
|
+
try {
|
|
416
|
+
await jobRunner.stop();
|
|
417
|
+
} catch {
|
|
418
|
+
// ignore — `error` is the one that matters
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
403
423
|
}
|