@cosmicdrift/kumiko-framework 0.187.0 → 0.189.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__/coerce-row-plain-date.test.ts +70 -0
- package/src/bun-db/__tests__/extract-table-info.test.ts +10 -11
- package/src/bun-db/__tests__/where-patterns.integration.test.ts +28 -0
- package/src/bun-db/query.ts +84 -14
- package/src/db/__tests__/column-ddl.integration.test.ts +9 -0
- package/src/db/__tests__/migrate-generator.test.ts +23 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +61 -1
- package/src/db/dialect.ts +10 -0
- package/src/db/entity-table-meta.ts +3 -0
- package/src/db/migrate-generator.ts +20 -7
- package/src/db/table-builder.ts +25 -19
- package/src/engine/__tests__/boot-validator.test.ts +405 -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 +136 -17
- package/src/errors/__tests__/classes.test.ts +39 -0
- package/src/errors/zod-bridge.ts +18 -1
- package/src/event-store/__tests__/perf.integration.test.ts +33 -17
- 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/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
|
@@ -7,8 +7,11 @@ import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
|
7
7
|
import { createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
8
8
|
import {
|
|
9
9
|
createDerivedField,
|
|
10
|
+
createEmbeddedField,
|
|
10
11
|
createEmbeddedListField,
|
|
11
12
|
createEntity,
|
|
13
|
+
createFilesField,
|
|
14
|
+
createJsonbField,
|
|
12
15
|
createMultiSelectField,
|
|
13
16
|
createTextField,
|
|
14
17
|
defineFeature,
|
|
@@ -1742,6 +1745,8 @@ describe("boot-validator", () => {
|
|
|
1742
1745
|
readonly redirect?: string;
|
|
1743
1746
|
readonly cancelTarget?: string | false;
|
|
1744
1747
|
readonly extraScreens?: readonly string[];
|
|
1748
|
+
readonly mode?: "single" | "wizard";
|
|
1749
|
+
readonly draft?: boolean;
|
|
1745
1750
|
};
|
|
1746
1751
|
|
|
1747
1752
|
// Hilfs-Schema-Setup: stamps eine Test-Entity + write-handler
|
|
@@ -1770,7 +1775,11 @@ describe("boot-validator", () => {
|
|
|
1770
1775
|
type: "actionForm",
|
|
1771
1776
|
handler,
|
|
1772
1777
|
fields: fields as never,
|
|
1773
|
-
layout: {
|
|
1778
|
+
layout: {
|
|
1779
|
+
sections: sections as never,
|
|
1780
|
+
...(override.mode !== undefined && { mode: override.mode }),
|
|
1781
|
+
...(override.draft !== undefined && { draft: override.draft }),
|
|
1782
|
+
},
|
|
1774
1783
|
...(override.redirect !== undefined && { redirect: override.redirect }),
|
|
1775
1784
|
...(override.cancelTarget !== undefined && { cancelTarget: override.cancelTarget }),
|
|
1776
1785
|
});
|
|
@@ -1858,6 +1867,39 @@ describe("boot-validator", () => {
|
|
|
1858
1867
|
expect(() => validateBoot([makeFeature({ cancelTarget: false })])).not.toThrow();
|
|
1859
1868
|
});
|
|
1860
1869
|
|
|
1870
|
+
// --- redirect/cancelTarget: cross-feature QN (#1946) ---
|
|
1871
|
+
test("redirect → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
|
|
1872
|
+
const consumer = defineFeature("statements", (r) => {
|
|
1873
|
+
r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
|
|
1874
|
+
});
|
|
1875
|
+
expect(() =>
|
|
1876
|
+
validateBoot([
|
|
1877
|
+
makeFeature({ redirect: "statements:screen:statement-upload-list" }),
|
|
1878
|
+
consumer,
|
|
1879
|
+
]),
|
|
1880
|
+
).not.toThrow();
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
test("redirect → unbekannte Cross-Feature-QN → Throw", () => {
|
|
1884
|
+
expect(() =>
|
|
1885
|
+
validateBoot([makeFeature({ redirect: "statements:screen:ghost-screen" })]),
|
|
1886
|
+
).toThrow(
|
|
1887
|
+
/redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
|
|
1888
|
+
);
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
test("cancelTarget → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
|
|
1892
|
+
const consumer = defineFeature("statements", (r) => {
|
|
1893
|
+
r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
|
|
1894
|
+
});
|
|
1895
|
+
expect(() =>
|
|
1896
|
+
validateBoot([
|
|
1897
|
+
makeFeature({ cancelTarget: "statements:screen:statement-upload-list" }),
|
|
1898
|
+
consumer,
|
|
1899
|
+
]),
|
|
1900
|
+
).not.toThrow();
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1861
1903
|
test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
|
|
1862
1904
|
// synthesizeActionFormScreen reicht die layout 1:1 an RenderEdit weiter —
|
|
1863
1905
|
// eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
|
|
@@ -1871,6 +1913,66 @@ describe("boot-validator", () => {
|
|
|
1871
1913
|
const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
|
|
1872
1914
|
expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
|
|
1873
1915
|
});
|
|
1916
|
+
|
|
1917
|
+
test("mode: wizard mit nur 1 Section → Throw", () => {
|
|
1918
|
+
expect(() =>
|
|
1919
|
+
validateBoot([
|
|
1920
|
+
makeFeature({
|
|
1921
|
+
mode: "wizard",
|
|
1922
|
+
sections: [{ title: "Step 1", fields: ["note"] }],
|
|
1923
|
+
}),
|
|
1924
|
+
]),
|
|
1925
|
+
).toThrow(/mode: "wizard" but only 1 section\(s\)/);
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
test("mode: wizard mit Section ohne Titel → Throw", () => {
|
|
1929
|
+
const sections = [
|
|
1930
|
+
{ title: "Step 1", fields: ["note"] },
|
|
1931
|
+
{ title: "", fields: ["priority"] },
|
|
1932
|
+
];
|
|
1933
|
+
expect(() => validateBoot([makeFeature({ mode: "wizard", sections })])).toThrow(
|
|
1934
|
+
/sections\[1\] has no title/,
|
|
1935
|
+
);
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
test("mode: wizard mit >= 2 betitelten Sections → kein Throw", () => {
|
|
1939
|
+
const sections = [
|
|
1940
|
+
{ title: "Step 1", fields: ["note"] },
|
|
1941
|
+
{ title: "Step 2", fields: ["priority"] },
|
|
1942
|
+
];
|
|
1943
|
+
expect(() => validateBoot([makeFeature({ mode: "wizard", sections })])).not.toThrow();
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
test("draft: true ohne gemountetes form-draft-Feature → Throw", () => {
|
|
1947
|
+
const sections = [
|
|
1948
|
+
{ title: "Step 1", fields: ["note"] },
|
|
1949
|
+
{ title: "Step 2", fields: ["priority"] },
|
|
1950
|
+
];
|
|
1951
|
+
expect(() => validateBoot([makeFeature({ mode: "wizard", sections, draft: true })])).toThrow(
|
|
1952
|
+
/"form-draft" is not mounted/,
|
|
1953
|
+
);
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
test("draft: true mit gemountetem form-draft-Feature → kein Throw", () => {
|
|
1957
|
+
const sections = [
|
|
1958
|
+
{ title: "Step 1", fields: ["note"] },
|
|
1959
|
+
{ title: "Step 2", fields: ["priority"] },
|
|
1960
|
+
];
|
|
1961
|
+
expect(() =>
|
|
1962
|
+
validateBoot([
|
|
1963
|
+
makeFeature({ mode: "wizard", sections, draft: true }),
|
|
1964
|
+
defineFeature("form-draft", () => {}),
|
|
1965
|
+
]),
|
|
1966
|
+
).not.toThrow();
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
test("draft: true ohne mode: 'wizard' → Throw", () => {
|
|
1970
|
+
expect(() => validateBoot([makeFeature({ draft: true })])).toThrow(/mode is not "wizard"/);
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
test("mode weggelassen (Default 'single') bleibt bestehendes Layout gültig → kein Throw", () => {
|
|
1974
|
+
expect(() => validateBoot([makeFeature()])).not.toThrow();
|
|
1975
|
+
});
|
|
1874
1976
|
});
|
|
1875
1977
|
|
|
1876
1978
|
// --- configEdit-Screen ---
|
|
@@ -2046,6 +2148,308 @@ describe("boot-validator", () => {
|
|
|
2046
2148
|
});
|
|
2047
2149
|
});
|
|
2048
2150
|
|
|
2151
|
+
// --- entityEdit wizard mode (framework#1884) ---
|
|
2152
|
+
// layout.mode: "wizard" renders one section per step instead of all
|
|
2153
|
+
// sections at once — needs >= 2 sections, each with a title (the step
|
|
2154
|
+
// title), or it's a boot-fail instead of a broken step UI.
|
|
2155
|
+
describe("entityEdit wizard mode", () => {
|
|
2156
|
+
function makeFeature(
|
|
2157
|
+
sections: readonly { readonly title?: string; readonly fields: readonly string[] }[],
|
|
2158
|
+
mode?: "single" | "wizard",
|
|
2159
|
+
) {
|
|
2160
|
+
return defineFeature("shop", (r) => {
|
|
2161
|
+
r.entity(
|
|
2162
|
+
"product",
|
|
2163
|
+
createEntity({ fields: { name: createTextField(), sku: createTextField() } }),
|
|
2164
|
+
);
|
|
2165
|
+
r.screen({
|
|
2166
|
+
id: "product-edit",
|
|
2167
|
+
type: "entityEdit",
|
|
2168
|
+
entity: "product",
|
|
2169
|
+
layout: {
|
|
2170
|
+
sections: sections as never,
|
|
2171
|
+
...(mode !== undefined && { mode }),
|
|
2172
|
+
},
|
|
2173
|
+
});
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
test("mode: wizard mit nur 1 Section → Throw", () => {
|
|
2178
|
+
expect(() =>
|
|
2179
|
+
validateBoot([makeFeature([{ title: "Step 1", fields: ["name"] }], "wizard")]),
|
|
2180
|
+
).toThrow(/mode: "wizard" but only 1 section\(s\)/);
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
test("mode: wizard mit Section ohne Titel → Throw", () => {
|
|
2184
|
+
expect(() =>
|
|
2185
|
+
validateBoot([
|
|
2186
|
+
makeFeature([{ title: "Step 1", fields: ["name"] }, { fields: ["sku"] }], "wizard"),
|
|
2187
|
+
]),
|
|
2188
|
+
).toThrow(/sections\[1\] has no title/);
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
test("mode: wizard mit >= 2 betitelten Sections → kein Throw", () => {
|
|
2192
|
+
expect(() =>
|
|
2193
|
+
validateBoot([
|
|
2194
|
+
makeFeature(
|
|
2195
|
+
[
|
|
2196
|
+
{ title: "Step 1", fields: ["name"] },
|
|
2197
|
+
{ title: "Step 2", fields: ["sku"] },
|
|
2198
|
+
],
|
|
2199
|
+
"wizard",
|
|
2200
|
+
),
|
|
2201
|
+
]),
|
|
2202
|
+
).not.toThrow();
|
|
2203
|
+
});
|
|
2204
|
+
|
|
2205
|
+
test("mode weggelassen (Default 'single') bleibt bestehendes Layout gültig → kein Throw", () => {
|
|
2206
|
+
expect(() =>
|
|
2207
|
+
validateBoot([makeFeature([{ title: "Details", fields: ["name", "sku"] }])]),
|
|
2208
|
+
).not.toThrow();
|
|
2209
|
+
});
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2212
|
+
// --- entityEdit redirect (framework#1942) ---
|
|
2213
|
+
// Post-save navigation target, same validation as actionForm's redirect:
|
|
2214
|
+
// short screen-ID, same feature, must resolve to a registered screen.
|
|
2215
|
+
describe("entityEdit redirect", () => {
|
|
2216
|
+
function makeFeature(redirect?: string, extraScreens: readonly string[] = []) {
|
|
2217
|
+
return defineFeature("shop", (r) => {
|
|
2218
|
+
r.entity(
|
|
2219
|
+
"product",
|
|
2220
|
+
createEntity({ fields: { name: createTextField(), sku: createTextField() } }),
|
|
2221
|
+
);
|
|
2222
|
+
r.screen({
|
|
2223
|
+
id: "product-edit",
|
|
2224
|
+
type: "entityEdit",
|
|
2225
|
+
entity: "product",
|
|
2226
|
+
layout: { sections: [{ fields: ["name", "sku"] }] },
|
|
2227
|
+
...(redirect !== undefined && { redirect }),
|
|
2228
|
+
});
|
|
2229
|
+
for (const extra of extraScreens) {
|
|
2230
|
+
r.screen({
|
|
2231
|
+
id: extra,
|
|
2232
|
+
type: "custom",
|
|
2233
|
+
renderer: { react: "stub" },
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
test("redirect → existing screen-id im selben feature → kein Throw", () => {
|
|
2240
|
+
expect(() => validateBoot([makeFeature("product-list", ["product-list"])])).not.toThrow();
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
test("redirect → unknown screen-id → Throw", () => {
|
|
2244
|
+
expect(() => validateBoot([makeFeature("ghost-screen")])).toThrow(
|
|
2245
|
+
/redirect "ghost-screen" does not resolve to a registered screen/,
|
|
2246
|
+
);
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
test("kein redirect gesetzt → kein Throw (Default: Liste)", () => {
|
|
2250
|
+
expect(() => validateBoot([makeFeature()])).not.toThrow();
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
// --- redirect: cross-feature QN (#1946) ---
|
|
2254
|
+
test("redirect → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
|
|
2255
|
+
const consumer = defineFeature("statements", (r) => {
|
|
2256
|
+
r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
|
|
2257
|
+
});
|
|
2258
|
+
expect(() =>
|
|
2259
|
+
validateBoot([makeFeature("statements:screen:statement-upload-list"), consumer]),
|
|
2260
|
+
).not.toThrow();
|
|
2261
|
+
});
|
|
2262
|
+
|
|
2263
|
+
test("redirect → unbekannte Cross-Feature-QN → Throw", () => {
|
|
2264
|
+
expect(() => validateBoot([makeFeature("statements:screen:ghost-screen")])).toThrow(
|
|
2265
|
+
/redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
|
|
2266
|
+
);
|
|
2267
|
+
});
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
// --- no-widget field types + required (#1925) ---
|
|
2271
|
+
// jsonb/embedded/files/images render read-only on the auto-wired
|
|
2272
|
+
// entityEdit path (render-field.tsx) — a statically-`required: true`
|
|
2273
|
+
// field of one of these types would be unresolvable by the user, so it's
|
|
2274
|
+
// caught loudly at boot instead of silently accepting an unfillable form.
|
|
2275
|
+
// Only the static case (literal `true`, entity-level or screen-spec) is
|
|
2276
|
+
// checked; a dynamic FieldCondition can't be evaluated without runtime
|
|
2277
|
+
// form values and is a documented, accepted gap.
|
|
2278
|
+
describe("no-widget field types + required (#1925)", () => {
|
|
2279
|
+
test("embedded field, entity-level required: true → Throw", () => {
|
|
2280
|
+
const features = [
|
|
2281
|
+
defineFeature("shop", (r) => {
|
|
2282
|
+
r.entity(
|
|
2283
|
+
"order",
|
|
2284
|
+
createEntity({
|
|
2285
|
+
fields: {
|
|
2286
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2287
|
+
},
|
|
2288
|
+
}),
|
|
2289
|
+
);
|
|
2290
|
+
r.screen({
|
|
2291
|
+
id: "order-edit",
|
|
2292
|
+
type: "entityEdit",
|
|
2293
|
+
entity: "order",
|
|
2294
|
+
layout: { sections: [{ fields: ["lines"] }] },
|
|
2295
|
+
});
|
|
2296
|
+
}),
|
|
2297
|
+
];
|
|
2298
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2299
|
+
/field "lines" is type "embedded", which renders read-only/,
|
|
2300
|
+
);
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
test("embedded field, entity-level required: true, screen-spec required: false override → kein Throw", () => {
|
|
2304
|
+
const features = [
|
|
2305
|
+
defineFeature("shop", (r) => {
|
|
2306
|
+
r.entity(
|
|
2307
|
+
"order",
|
|
2308
|
+
createEntity({
|
|
2309
|
+
fields: {
|
|
2310
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2311
|
+
},
|
|
2312
|
+
}),
|
|
2313
|
+
);
|
|
2314
|
+
r.screen({
|
|
2315
|
+
id: "order-edit",
|
|
2316
|
+
type: "entityEdit",
|
|
2317
|
+
entity: "order",
|
|
2318
|
+
layout: { sections: [{ fields: [{ field: "lines", required: false }] }] },
|
|
2319
|
+
});
|
|
2320
|
+
}),
|
|
2321
|
+
];
|
|
2322
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2323
|
+
});
|
|
2324
|
+
|
|
2325
|
+
test("embedded field, entity-level required: true, screen-spec readOnly: true → kein Throw", () => {
|
|
2326
|
+
const features = [
|
|
2327
|
+
defineFeature("shop", (r) => {
|
|
2328
|
+
r.entity(
|
|
2329
|
+
"order",
|
|
2330
|
+
createEntity({
|
|
2331
|
+
fields: {
|
|
2332
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2333
|
+
},
|
|
2334
|
+
}),
|
|
2335
|
+
);
|
|
2336
|
+
r.screen({
|
|
2337
|
+
id: "order-edit",
|
|
2338
|
+
type: "entityEdit",
|
|
2339
|
+
entity: "order",
|
|
2340
|
+
layout: { sections: [{ fields: [{ field: "lines", readOnly: true }] }] },
|
|
2341
|
+
});
|
|
2342
|
+
}),
|
|
2343
|
+
];
|
|
2344
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2345
|
+
});
|
|
2346
|
+
|
|
2347
|
+
test("embedded field, entity-level required: true, dynamic screen-spec required condition → kein Throw (not statically resolvable)", () => {
|
|
2348
|
+
const features = [
|
|
2349
|
+
defineFeature("shop", (r) => {
|
|
2350
|
+
r.entity(
|
|
2351
|
+
"order",
|
|
2352
|
+
createEntity({
|
|
2353
|
+
fields: {
|
|
2354
|
+
kind: createTextField(),
|
|
2355
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2356
|
+
},
|
|
2357
|
+
}),
|
|
2358
|
+
);
|
|
2359
|
+
r.screen({
|
|
2360
|
+
id: "order-edit",
|
|
2361
|
+
type: "entityEdit",
|
|
2362
|
+
entity: "order",
|
|
2363
|
+
layout: {
|
|
2364
|
+
sections: [
|
|
2365
|
+
{ fields: ["kind", { field: "lines", required: { field: "kind", eq: "b2b" } }] },
|
|
2366
|
+
],
|
|
2367
|
+
},
|
|
2368
|
+
});
|
|
2369
|
+
}),
|
|
2370
|
+
];
|
|
2371
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
test("jsonb field, screen-spec required: true override → Throw (JsonbFieldDef has no entity-level required)", () => {
|
|
2375
|
+
const features = [
|
|
2376
|
+
defineFeature("shop", (r) => {
|
|
2377
|
+
r.entity("order", createEntity({ fields: { data: createJsonbField() } }));
|
|
2378
|
+
r.screen({
|
|
2379
|
+
id: "order-edit",
|
|
2380
|
+
type: "entityEdit",
|
|
2381
|
+
entity: "order",
|
|
2382
|
+
layout: { sections: [{ fields: [{ field: "data", required: true }] }] },
|
|
2383
|
+
});
|
|
2384
|
+
}),
|
|
2385
|
+
];
|
|
2386
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2387
|
+
/field "data" is type "jsonb", which renders read-only/,
|
|
2388
|
+
);
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
test("files field, screen-spec required: true override → Throw (deliberately deferred, no multi-upload widget)", () => {
|
|
2392
|
+
const features = [
|
|
2393
|
+
defineFeature("shop", (r) => {
|
|
2394
|
+
r.entity("order", createEntity({ fields: { attachments: createFilesField() } }));
|
|
2395
|
+
r.screen({
|
|
2396
|
+
id: "order-edit",
|
|
2397
|
+
type: "entityEdit",
|
|
2398
|
+
entity: "order",
|
|
2399
|
+
layout: { sections: [{ fields: [{ field: "attachments", required: true }] }] },
|
|
2400
|
+
});
|
|
2401
|
+
}),
|
|
2402
|
+
];
|
|
2403
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2404
|
+
/field "attachments" is type "files", which renders read-only/,
|
|
2405
|
+
);
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
test("multiSelect field, entity-level required: true → kein Throw (has a combobox widget since #1925)", () => {
|
|
2409
|
+
const features = [
|
|
2410
|
+
defineFeature("shop", (r) => {
|
|
2411
|
+
r.entity(
|
|
2412
|
+
"product",
|
|
2413
|
+
createEntity({
|
|
2414
|
+
fields: {
|
|
2415
|
+
tags: createMultiSelectField({ options: ["a", "b"] as const, required: true }),
|
|
2416
|
+
},
|
|
2417
|
+
}),
|
|
2418
|
+
);
|
|
2419
|
+
r.screen({
|
|
2420
|
+
id: "product-edit",
|
|
2421
|
+
type: "entityEdit",
|
|
2422
|
+
entity: "product",
|
|
2423
|
+
layout: { sections: [{ fields: ["tags"] }] },
|
|
2424
|
+
});
|
|
2425
|
+
}),
|
|
2426
|
+
];
|
|
2427
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2428
|
+
});
|
|
2429
|
+
|
|
2430
|
+
test("embedded LIST field (multiple: true), entity-level required: true → kein Throw (has an EmbeddedListField grid widget since #1838)", () => {
|
|
2431
|
+
const features = [
|
|
2432
|
+
defineFeature("shop", (r) => {
|
|
2433
|
+
r.entity(
|
|
2434
|
+
"invoice",
|
|
2435
|
+
createEntity({
|
|
2436
|
+
fields: {
|
|
2437
|
+
lines: createEmbeddedListField({ note: { type: "text" } }, { required: true }),
|
|
2438
|
+
},
|
|
2439
|
+
}),
|
|
2440
|
+
);
|
|
2441
|
+
r.screen({
|
|
2442
|
+
id: "invoice-edit",
|
|
2443
|
+
type: "entityEdit",
|
|
2444
|
+
entity: "invoice",
|
|
2445
|
+
layout: { sections: [{ fields: ["lines"] }] },
|
|
2446
|
+
});
|
|
2447
|
+
}),
|
|
2448
|
+
];
|
|
2449
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2450
|
+
});
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2049
2453
|
// --- Tier 2.7e-3: ReferenceFieldDef ---
|
|
2050
2454
|
describe("reference field (Tier 2.7e-3)", () => {
|
|
2051
2455
|
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
|
});
|