@cosmicdrift/kumiko-framework 0.187.0 → 0.188.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 +4 -4
- package/src/api/__tests__/origin-middleware.test.ts +12 -13
- package/src/bun-db/__tests__/extract-table-info.test.ts +10 -11
- package/src/engine/__tests__/boot-validator.test.ts +128 -1
- package/src/engine/__tests__/build-app-schema.test.ts +4 -4
- package/src/engine/__tests__/engine.test.ts +7 -8
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -3
- package/src/engine/boot-validator/screens.ts +55 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.test.ts +1 -1
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +30 -27
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +1 -1
- package/src/time/__tests__/iana.test.ts +12 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.188.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>",
|
|
@@ -182,10 +182,10 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.188.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
|
-
"hono": "^4.
|
|
188
|
+
"hono": "^4.13.1",
|
|
189
189
|
"i18next": "^26.1.0",
|
|
190
190
|
"ioredis": "^5.10.1",
|
|
191
191
|
"jose": "^6.2.3",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.188.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -143,19 +143,18 @@ describe("originMiddleware", () => {
|
|
|
143
143
|
|
|
144
144
|
// The guard runs as /api/* middleware before routing, so a disallowed-origin
|
|
145
145
|
// request is rejected for every state-changing method even without a route.
|
|
146
|
-
test.each([
|
|
147
|
-
"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
146
|
+
test.each(["PUT", "PATCH", "DELETE"])(
|
|
147
|
+
"cookie transport + %s + disallowed origin → 403 (every state-changing method)",
|
|
148
|
+
async (method) => {
|
|
149
|
+
const { app, token } = await buildApp();
|
|
150
|
+
const res = await app.request("/api/write", {
|
|
151
|
+
method,
|
|
152
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
|
|
153
|
+
});
|
|
154
|
+
expect(res.status).toBe(403);
|
|
155
|
+
expect(await readErrorCode(res)).toBe("origin_not_allowed");
|
|
156
|
+
},
|
|
157
|
+
);
|
|
159
158
|
|
|
160
159
|
test("disallowed origin is blocked even as a simple text/plain request", async () => {
|
|
161
160
|
// The real vector: a `text/plain` POST skips the CORS preflight and reaches
|
|
@@ -29,17 +29,16 @@ describe("extractTableInfo — EntityTableMeta discriminator is shadow-proof", (
|
|
|
29
29
|
expect(info.pgTypeOf("source")).toBe("text");
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
test.each([
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
});
|
|
32
|
+
test.each(["columns", "tableName", "indexes"])(
|
|
33
|
+
"an entity field named `%s` (another meta key) also does not shadow it",
|
|
34
|
+
(fieldName) => {
|
|
35
|
+
const table = buildEntityTable("thing", {
|
|
36
|
+
fields: { [fieldName]: { type: "text", required: true } },
|
|
37
|
+
});
|
|
38
|
+
const info = extractTableInfo(table);
|
|
39
|
+
expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
|
|
40
|
+
},
|
|
41
|
+
);
|
|
43
42
|
|
|
44
43
|
test("control entity without a colliding field is unaffected", () => {
|
|
45
44
|
const table = buildEntityTable("note", {
|
|
@@ -1742,6 +1742,8 @@ describe("boot-validator", () => {
|
|
|
1742
1742
|
readonly redirect?: string;
|
|
1743
1743
|
readonly cancelTarget?: string | false;
|
|
1744
1744
|
readonly extraScreens?: readonly string[];
|
|
1745
|
+
readonly mode?: "single" | "wizard";
|
|
1746
|
+
readonly draft?: boolean;
|
|
1745
1747
|
};
|
|
1746
1748
|
|
|
1747
1749
|
// Hilfs-Schema-Setup: stamps eine Test-Entity + write-handler
|
|
@@ -1770,7 +1772,11 @@ describe("boot-validator", () => {
|
|
|
1770
1772
|
type: "actionForm",
|
|
1771
1773
|
handler,
|
|
1772
1774
|
fields: fields as never,
|
|
1773
|
-
layout: {
|
|
1775
|
+
layout: {
|
|
1776
|
+
sections: sections as never,
|
|
1777
|
+
...(override.mode !== undefined && { mode: override.mode }),
|
|
1778
|
+
...(override.draft !== undefined && { draft: override.draft }),
|
|
1779
|
+
},
|
|
1774
1780
|
...(override.redirect !== undefined && { redirect: override.redirect }),
|
|
1775
1781
|
...(override.cancelTarget !== undefined && { cancelTarget: override.cancelTarget }),
|
|
1776
1782
|
});
|
|
@@ -1871,6 +1877,66 @@ describe("boot-validator", () => {
|
|
|
1871
1877
|
const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
|
|
1872
1878
|
expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
|
|
1873
1879
|
});
|
|
1880
|
+
|
|
1881
|
+
test("mode: wizard mit nur 1 Section → Throw", () => {
|
|
1882
|
+
expect(() =>
|
|
1883
|
+
validateBoot([
|
|
1884
|
+
makeFeature({
|
|
1885
|
+
mode: "wizard",
|
|
1886
|
+
sections: [{ title: "Step 1", fields: ["note"] }],
|
|
1887
|
+
}),
|
|
1888
|
+
]),
|
|
1889
|
+
).toThrow(/mode: "wizard" but only 1 section\(s\)/);
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
test("mode: wizard mit Section ohne Titel → Throw", () => {
|
|
1893
|
+
const sections = [
|
|
1894
|
+
{ title: "Step 1", fields: ["note"] },
|
|
1895
|
+
{ title: "", fields: ["priority"] },
|
|
1896
|
+
];
|
|
1897
|
+
expect(() => validateBoot([makeFeature({ mode: "wizard", sections })])).toThrow(
|
|
1898
|
+
/sections\[1\] has no title/,
|
|
1899
|
+
);
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
test("mode: wizard mit >= 2 betitelten Sections → kein Throw", () => {
|
|
1903
|
+
const sections = [
|
|
1904
|
+
{ title: "Step 1", fields: ["note"] },
|
|
1905
|
+
{ title: "Step 2", fields: ["priority"] },
|
|
1906
|
+
];
|
|
1907
|
+
expect(() => validateBoot([makeFeature({ mode: "wizard", sections })])).not.toThrow();
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
test("draft: true ohne gemountetes form-draft-Feature → Throw", () => {
|
|
1911
|
+
const sections = [
|
|
1912
|
+
{ title: "Step 1", fields: ["note"] },
|
|
1913
|
+
{ title: "Step 2", fields: ["priority"] },
|
|
1914
|
+
];
|
|
1915
|
+
expect(() => validateBoot([makeFeature({ mode: "wizard", sections, draft: true })])).toThrow(
|
|
1916
|
+
/"form-draft" is not mounted/,
|
|
1917
|
+
);
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
test("draft: true mit gemountetem form-draft-Feature → kein Throw", () => {
|
|
1921
|
+
const sections = [
|
|
1922
|
+
{ title: "Step 1", fields: ["note"] },
|
|
1923
|
+
{ title: "Step 2", fields: ["priority"] },
|
|
1924
|
+
];
|
|
1925
|
+
expect(() =>
|
|
1926
|
+
validateBoot([
|
|
1927
|
+
makeFeature({ mode: "wizard", sections, draft: true }),
|
|
1928
|
+
defineFeature("form-draft", () => {}),
|
|
1929
|
+
]),
|
|
1930
|
+
).not.toThrow();
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
test("draft: true ohne mode: 'wizard' → Throw", () => {
|
|
1934
|
+
expect(() => validateBoot([makeFeature({ draft: true })])).toThrow(/mode is not "wizard"/);
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
test("mode weggelassen (Default 'single') bleibt bestehendes Layout gültig → kein Throw", () => {
|
|
1938
|
+
expect(() => validateBoot([makeFeature()])).not.toThrow();
|
|
1939
|
+
});
|
|
1874
1940
|
});
|
|
1875
1941
|
|
|
1876
1942
|
// --- configEdit-Screen ---
|
|
@@ -2046,6 +2112,67 @@ describe("boot-validator", () => {
|
|
|
2046
2112
|
});
|
|
2047
2113
|
});
|
|
2048
2114
|
|
|
2115
|
+
// --- entityEdit wizard mode (framework#1884) ---
|
|
2116
|
+
// layout.mode: "wizard" renders one section per step instead of all
|
|
2117
|
+
// sections at once — needs >= 2 sections, each with a title (the step
|
|
2118
|
+
// title), or it's a boot-fail instead of a broken step UI.
|
|
2119
|
+
describe("entityEdit wizard mode", () => {
|
|
2120
|
+
function makeFeature(
|
|
2121
|
+
sections: readonly { readonly title?: string; readonly fields: readonly string[] }[],
|
|
2122
|
+
mode?: "single" | "wizard",
|
|
2123
|
+
) {
|
|
2124
|
+
return defineFeature("shop", (r) => {
|
|
2125
|
+
r.entity(
|
|
2126
|
+
"product",
|
|
2127
|
+
createEntity({ fields: { name: createTextField(), sku: createTextField() } }),
|
|
2128
|
+
);
|
|
2129
|
+
r.screen({
|
|
2130
|
+
id: "product-edit",
|
|
2131
|
+
type: "entityEdit",
|
|
2132
|
+
entity: "product",
|
|
2133
|
+
layout: {
|
|
2134
|
+
sections: sections as never,
|
|
2135
|
+
...(mode !== undefined && { mode }),
|
|
2136
|
+
},
|
|
2137
|
+
});
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
test("mode: wizard mit nur 1 Section → Throw", () => {
|
|
2142
|
+
expect(() =>
|
|
2143
|
+
validateBoot([makeFeature([{ title: "Step 1", fields: ["name"] }], "wizard")]),
|
|
2144
|
+
).toThrow(/mode: "wizard" but only 1 section\(s\)/);
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
test("mode: wizard mit Section ohne Titel → Throw", () => {
|
|
2148
|
+
expect(() =>
|
|
2149
|
+
validateBoot([
|
|
2150
|
+
makeFeature([{ title: "Step 1", fields: ["name"] }, { fields: ["sku"] }], "wizard"),
|
|
2151
|
+
]),
|
|
2152
|
+
).toThrow(/sections\[1\] has no title/);
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
test("mode: wizard mit >= 2 betitelten Sections → kein Throw", () => {
|
|
2156
|
+
expect(() =>
|
|
2157
|
+
validateBoot([
|
|
2158
|
+
makeFeature(
|
|
2159
|
+
[
|
|
2160
|
+
{ title: "Step 1", fields: ["name"] },
|
|
2161
|
+
{ title: "Step 2", fields: ["sku"] },
|
|
2162
|
+
],
|
|
2163
|
+
"wizard",
|
|
2164
|
+
),
|
|
2165
|
+
]),
|
|
2166
|
+
).not.toThrow();
|
|
2167
|
+
});
|
|
2168
|
+
|
|
2169
|
+
test("mode weggelassen (Default 'single') bleibt bestehendes Layout gültig → kein Throw", () => {
|
|
2170
|
+
expect(() =>
|
|
2171
|
+
validateBoot([makeFeature([{ title: "Details", fields: ["name", "sku"] }])]),
|
|
2172
|
+
).not.toThrow();
|
|
2173
|
+
});
|
|
2174
|
+
});
|
|
2175
|
+
|
|
2049
2176
|
// --- Tier 2.7e-3: ReferenceFieldDef ---
|
|
2050
2177
|
describe("reference field (Tier 2.7e-3)", () => {
|
|
2051
2178
|
test("reference auf bestehende Entity → kein Throw", () => {
|
|
@@ -160,7 +160,7 @@ describe("buildAppSchema", () => {
|
|
|
160
160
|
});
|
|
161
161
|
const app = buildAppSchema(createRegistry([f]));
|
|
162
162
|
const fields = (
|
|
163
|
-
app.features[0]
|
|
163
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
164
164
|
fields: Record<string, Record<string, unknown>>;
|
|
165
165
|
}
|
|
166
166
|
).fields;
|
|
@@ -186,7 +186,7 @@ describe("buildAppSchema", () => {
|
|
|
186
186
|
});
|
|
187
187
|
const app = buildAppSchema(createRegistry([f]));
|
|
188
188
|
const fields = (
|
|
189
|
-
app.features[0]
|
|
189
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
190
190
|
fields: Record<string, Record<string, unknown>>;
|
|
191
191
|
}
|
|
192
192
|
).fields;
|
|
@@ -207,7 +207,7 @@ describe("buildAppSchema", () => {
|
|
|
207
207
|
});
|
|
208
208
|
const app = buildAppSchema(createRegistry([f]));
|
|
209
209
|
const fields = (
|
|
210
|
-
app.features[0]
|
|
210
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
211
211
|
fields: Record<string, Record<string, unknown>>;
|
|
212
212
|
}
|
|
213
213
|
).fields;
|
|
@@ -228,7 +228,7 @@ describe("buildAppSchema", () => {
|
|
|
228
228
|
});
|
|
229
229
|
const app = buildAppSchema(createRegistry([f]));
|
|
230
230
|
const fields = (
|
|
231
|
-
app.features[0]
|
|
231
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
232
232
|
fields: Record<string, Record<string, unknown>>;
|
|
233
233
|
}
|
|
234
234
|
).fields;
|
|
@@ -634,14 +634,13 @@ describe("hasAccess", () => {
|
|
|
634
634
|
{ userRoles: [], requiredRoles: ["Admin"], expected: false },
|
|
635
635
|
// Empty required-roles list denies everyone under default-deny.
|
|
636
636
|
{ userRoles: ["Admin"], requiredRoles: [], expected: false },
|
|
637
|
-
])(
|
|
638
|
-
userRoles,
|
|
639
|
-
requiredRoles,
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
});
|
|
637
|
+
])(
|
|
638
|
+
"user $userRoles vs required $requiredRoles → $expected",
|
|
639
|
+
({ userRoles, requiredRoles, expected }) => {
|
|
640
|
+
const user = createTestUser({ roles: userRoles });
|
|
641
|
+
expect(hasAccess(user, { roles: requiredRoles })).toBe(expected);
|
|
642
|
+
},
|
|
643
|
+
);
|
|
645
644
|
|
|
646
645
|
test("missing access rule denies access (default-deny)", () => {
|
|
647
646
|
const user = createTestUser({ roles: ["Employee"] });
|
|
@@ -120,7 +120,7 @@ describe("softDeleteCleanupJob handler", () => {
|
|
|
120
120
|
test("cutoff defaults to DEFAULT_GRACE_DAYS when no config resolver", async () => {
|
|
121
121
|
const calls: DeleteCall[] = [];
|
|
122
122
|
await softDeleteCleanupJob({}, makeCtx({ calls }));
|
|
123
|
-
const cutoff = (calls[0]
|
|
123
|
+
const cutoff = (calls[0]!.where["deletedAt"] as { lt: Temporal.Instant }).lt;
|
|
124
124
|
const expected = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
|
|
125
125
|
expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
|
|
126
126
|
});
|
|
@@ -128,7 +128,7 @@ describe("softDeleteCleanupJob handler", () => {
|
|
|
128
128
|
test("honours a per-tenant grace-days value from the config resolver", async () => {
|
|
129
129
|
const calls: DeleteCall[] = [];
|
|
130
130
|
await softDeleteCleanupJob({}, makeCtx({ calls, graceDays: 7 }));
|
|
131
|
-
const cutoff = (calls[0]
|
|
131
|
+
const cutoff = (calls[0]!.where["deletedAt"] as { lt: Temporal.Instant }).lt;
|
|
132
132
|
const expected = Temporal.Now.instant().subtract({ hours: 7 * 24 });
|
|
133
133
|
expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
|
|
134
134
|
});
|
|
@@ -155,7 +155,7 @@ describe("softDeleteCleanupSystemJob handler", () => {
|
|
|
155
155
|
test("cutoff is DEFAULT_GRACE_DAYS — no per-tenant config to read", async () => {
|
|
156
156
|
const calls: DeleteCall[] = [];
|
|
157
157
|
await softDeleteCleanupSystemJob({}, makeCtx({ calls }));
|
|
158
|
-
const cutoff = (calls[0]
|
|
158
|
+
const cutoff = (calls[0]!.where["deletedAt"] as { lt: Temporal.Instant }).lt;
|
|
159
159
|
const expected = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
|
|
160
160
|
expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
|
|
161
161
|
});
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
DashboardPanelDefinition,
|
|
16
16
|
DashboardScreenDefinition,
|
|
17
17
|
DashboardStatGroupPanel,
|
|
18
|
+
EditLayout,
|
|
18
19
|
FieldCondition,
|
|
19
20
|
RowAction,
|
|
20
21
|
RowFieldExtractor,
|
|
@@ -71,6 +72,58 @@ function validateRowActionNavigateParams(
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
// Wizard layouts (mode: "wizard") render one section per step — a single
|
|
76
|
+
// step (or a step without a title, which would leave the progress
|
|
77
|
+
// indicator blank) defeats the point, so both fail at boot rather than
|
|
78
|
+
// as a broken step UI. Missing/blank titles are checked identically for
|
|
79
|
+
// both section kinds — EditExtensionSection.title is required by type,
|
|
80
|
+
// but that doesn't stop author code that circumvented the check from
|
|
81
|
+
// passing an empty string.
|
|
82
|
+
function validateWizardLayout(
|
|
83
|
+
featureName: string,
|
|
84
|
+
screenId: string,
|
|
85
|
+
screenType: "entityEdit" | "actionForm",
|
|
86
|
+
layout: EditLayout,
|
|
87
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
88
|
+
): void {
|
|
89
|
+
// "form-draft" is hardcoded because the framework layer must not depend on
|
|
90
|
+
// @cosmicdrift/kumiko-bundled-features — same precedence as the
|
|
91
|
+
// "user-data-rights" check in gdpr-storage.ts.
|
|
92
|
+
if (layout.draft === true) {
|
|
93
|
+
if (layout.mode !== "wizard") {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but ` +
|
|
96
|
+
`mode is not "wizard" — draft persistence only applies to wizard layouts. Remove ` +
|
|
97
|
+
`draft: true or set mode: "wizard".`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!featureMap.has("form-draft")) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but the ` +
|
|
103
|
+
`bundled feature "form-draft" is not mounted — every resume would silently lose its ` +
|
|
104
|
+
`values. Add formDraftFeature() from @cosmicdrift/kumiko-bundled-features to the app's ` +
|
|
105
|
+
`feature list.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// skip: mode omitted/"single" — no wizard constraints apply.
|
|
110
|
+
if (layout.mode !== "wizard") return;
|
|
111
|
+
if (layout.sections.length < 2) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but only ` +
|
|
114
|
+
`${layout.sections.length} section(s) — a wizard needs at least 2 sections (one per step).`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
layout.sections.forEach((section, index) => {
|
|
118
|
+
if (section.title === undefined || section.title.trim().length === 0) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but ` +
|
|
121
|
+
`sections[${index}] has no title — every wizard step needs a title.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
74
127
|
// --- Screen validation ---
|
|
75
128
|
//
|
|
76
129
|
// For every r.screen() declaration check what's locally knowable at boot:
|
|
@@ -441,6 +494,7 @@ export function validateScreens(
|
|
|
441
494
|
}
|
|
442
495
|
}
|
|
443
496
|
}
|
|
497
|
+
validateWizardLayout(feature.name, screenId, "actionForm", screen.layout, featureMap);
|
|
444
498
|
if (screen.redirect !== undefined) {
|
|
445
499
|
// redirect ist die kurze Screen-ID (z.B. "item-list"); der
|
|
446
500
|
// nav-Router resolved sie beim Mount gegen die Schema-Map.
|
|
@@ -772,6 +826,7 @@ export function validateScreens(
|
|
|
772
826
|
}
|
|
773
827
|
}
|
|
774
828
|
}
|
|
829
|
+
validateWizardLayout(feature.name, screenId, "entityEdit", screen.layout, featureMap);
|
|
775
830
|
}
|
|
776
831
|
}
|
|
777
832
|
}
|
|
@@ -139,7 +139,7 @@ describe("upcaster error-policy: quarantine", () => {
|
|
|
139
139
|
expect(result).toHaveLength(1);
|
|
140
140
|
expect(result[0]?.id).toBe("10");
|
|
141
141
|
expect(result[0]?.eventVersion).toBe(2);
|
|
142
|
-
expect((result[0]
|
|
142
|
+
expect((result[0]!.payload as { migrated?: boolean }).migrated).toBe(true);
|
|
143
143
|
|
|
144
144
|
const dl = await listDeadLetters(testDb.db);
|
|
145
145
|
expect(dl).toHaveLength(1);
|
|
@@ -729,33 +729,36 @@ describe("runPostSaveBatch / runPostDeleteBatch", () => {
|
|
|
729
729
|
(pipeline: ReturnType<typeof createLifecycleHooks>) =>
|
|
730
730
|
pipeline.runPostDeleteBatch([deletectx], {}),
|
|
731
731
|
],
|
|
732
|
-
])(
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
732
|
+
])(
|
|
733
|
+
"one %s hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown",
|
|
734
|
+
async (_name, buildHooks, run) => {
|
|
735
|
+
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
736
|
+
try {
|
|
737
|
+
const calls: string[] = [];
|
|
738
|
+
const systemHooks = buildHooks([
|
|
739
|
+
{
|
|
740
|
+
name: "failing",
|
|
741
|
+
priority: 1000,
|
|
742
|
+
fn: async () => {
|
|
743
|
+
throw new Error("batch-hook-boom");
|
|
744
|
+
},
|
|
742
745
|
},
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
746
|
+
{
|
|
747
|
+
name: "ok",
|
|
748
|
+
priority: 1001,
|
|
749
|
+
fn: async () => {
|
|
750
|
+
calls.push("ok-ran");
|
|
751
|
+
},
|
|
749
752
|
},
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
}
|
|
760
|
-
|
|
753
|
+
]);
|
|
754
|
+
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
755
|
+
// Must not throw.
|
|
756
|
+
await run(pipeline);
|
|
757
|
+
expect(calls).toEqual(["ok-ran"]);
|
|
758
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
759
|
+
} finally {
|
|
760
|
+
consoleSpy.mockRestore();
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
);
|
|
761
764
|
});
|
|
@@ -297,7 +297,7 @@ describe("entity cache", () => {
|
|
|
297
297
|
|
|
298
298
|
const single = await cache.get("00000000-0000-4000-8000-000000000001", "event", 42);
|
|
299
299
|
expect(single?.["insertedAt"]).toBeInstanceOf(Date);
|
|
300
|
-
expect((single
|
|
300
|
+
expect((single!["insertedAt"] as Date).getTime()).toBe(insertedAt.getTime());
|
|
301
301
|
// Non-ISO strings must not be coerced
|
|
302
302
|
expect(typeof single?.["title"]).toBe("string");
|
|
303
303
|
expect(single?.["note"]).toBe("not a date: 2026-04");
|
|
@@ -4,26 +4,19 @@ import { isValidIanaTimeZone } from "../iana";
|
|
|
4
4
|
describe("isValidIanaTimeZone", () => {
|
|
5
5
|
// Die 5 Zonen der geplanten CI-TZ-Matrix (timezones.md) müssen alle gültig
|
|
6
6
|
// sein — sonst kann die Matrix sie nicht setzen.
|
|
7
|
-
test.each([
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
])("akzeptiert kanonische Zone %s", (zone) => {
|
|
14
|
-
expect(isValidIanaTimeZone(zone)).toBe(true);
|
|
15
|
-
});
|
|
7
|
+
test.each(["UTC", "Europe/Berlin", "America/Los_Angeles", "Asia/Tokyo", "Pacific/Apia"])(
|
|
8
|
+
"akzeptiert kanonische Zone %s",
|
|
9
|
+
(zone) => {
|
|
10
|
+
expect(isValidIanaTimeZone(zone)).toBe(true);
|
|
11
|
+
},
|
|
12
|
+
);
|
|
16
13
|
|
|
17
|
-
test.each([
|
|
18
|
-
"",
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"not-a-zone",
|
|
24
|
-
])("lehnt ungültigen / nicht-kanonischen String %p ab", (value) => {
|
|
25
|
-
expect(isValidIanaTimeZone(value)).toBe(false);
|
|
26
|
-
});
|
|
14
|
+
test.each(["", "Mars/Phobos", "europe/berlin", "Europe/Berlin ", "GMT+2", "not-a-zone"])(
|
|
15
|
+
"lehnt ungültigen / nicht-kanonischen String %p ab",
|
|
16
|
+
(value) => {
|
|
17
|
+
expect(isValidIanaTimeZone(value)).toBe(false);
|
|
18
|
+
},
|
|
19
|
+
);
|
|
27
20
|
|
|
28
21
|
// Intl.supportedValuesOf("timeZone") listet nur kanonische Namen — gültige
|
|
29
22
|
// IANA-Aliase fehlen darin, obwohl Intl.DateTimeFormat/Temporal/ctx.tz.parse
|