@cosmicdrift/kumiko-bundled-features 0.174.1 → 0.176.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 +8 -10
- package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
- package/src/legal-pages/README.md +26 -25
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
- package/src/legal-pages/feature.ts +40 -31
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
- package/src/legal-pages/web/client-plugin.ts +3 -3
- package/src/seo/__tests__/robots-txt.test.ts +21 -0
- package/src/seo/__tests__/seo.integration.test.ts +13 -13
- package/src/seo/robots-txt.ts +21 -1
- package/src/template-resolver/README.md +30 -1
- package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
- package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
- package/src/template-resolver/api.ts +11 -7
- package/src/template-resolver/constants.ts +22 -0
- package/src/template-resolver/feature.ts +16 -1
- package/src/template-resolver/handlers/by-slug.query.ts +54 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
- package/src/template-resolver/handlers/set.write.ts +110 -0
- package/src/template-resolver/handlers/shared.ts +13 -2
- package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
- package/src/template-resolver/index.ts +7 -0
- package/src/template-resolver/qualified-names.ts +3 -0
- package/src/template-resolver/seeding.ts +126 -2
- package/src/template-resolver/table.ts +11 -2
- package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
- package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
- package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
- package/src/template-resolver/web/client-plugin.tsx +352 -0
- package/src/template-resolver/web/index.ts +8 -0
- package/src/text-content/README.md +0 -197
- package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
- package/src/text-content/api.ts +0 -86
- package/src/text-content/changes.json +0 -14
- package/src/text-content/constants.ts +0 -21
- package/src/text-content/feature.ts +0 -53
- package/src/text-content/handlers/by-slug.query.ts +0 -55
- package/src/text-content/handlers/by-tenant.query.ts +0 -60
- package/src/text-content/handlers/set.write.ts +0 -141
- package/src/text-content/index.ts +0 -14
- package/src/text-content/seeding.ts +0 -139
- package/src/text-content/table.ts +0 -53
- package/src/text-content/web/client-plugin.tsx +0 -391
- package/src/text-content/web/index.ts +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.176.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -104,12 +104,10 @@
|
|
|
104
104
|
"./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
|
|
105
105
|
"./feature-toggles": "./src/feature-toggles/index.ts",
|
|
106
106
|
"./feature-toggles/web": "./src/feature-toggles/web/index.ts",
|
|
107
|
-
"./text-content": "./src/text-content/index.ts",
|
|
108
|
-
"./text-content/seeding": "./src/text-content/seeding.ts",
|
|
109
|
-
"./text-content/web": "./src/text-content/web/index.ts",
|
|
110
107
|
"./template-resolver": "./src/template-resolver/index.ts",
|
|
111
108
|
"./template-resolver/seeding": "./src/template-resolver/seeding.ts",
|
|
112
109
|
"./template-resolver/testing": "./src/template-resolver/testing.ts",
|
|
110
|
+
"./template-resolver/web": "./src/template-resolver/web/index.ts",
|
|
113
111
|
"./renderer-foundation": "./src/renderer-foundation/index.ts",
|
|
114
112
|
"./legal-pages": "./src/legal-pages/index.ts",
|
|
115
113
|
"./legal-pages/web": "./src/legal-pages/web/index.ts",
|
|
@@ -122,12 +120,12 @@
|
|
|
122
120
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
123
121
|
},
|
|
124
122
|
"dependencies": {
|
|
125
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
126
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
127
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
123
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.176.0",
|
|
124
|
+
"@cosmicdrift/kumiko-framework": "0.176.0",
|
|
125
|
+
"@cosmicdrift/kumiko-headless": "0.176.0",
|
|
126
|
+
"@cosmicdrift/kumiko-renderer": "0.176.0",
|
|
127
|
+
"@cosmicdrift/kumiko-renderer-web": "0.176.0",
|
|
128
|
+
"@cosmicdrift/kumiko-types": "0.176.0",
|
|
131
129
|
"@mollie/api-client": "^4.5.0",
|
|
132
130
|
"imapflow": "^1.3.3",
|
|
133
131
|
"mailparser": "^3.9.8",
|
|
@@ -17,17 +17,21 @@ import {
|
|
|
17
17
|
tenantComplianceProfileTable,
|
|
18
18
|
} from "../compliance-profiles/schema/profile-selection";
|
|
19
19
|
import { seedComplianceProfile } from "../compliance-profiles/seeding";
|
|
20
|
-
import {
|
|
21
|
-
import { seedTextBlock } from "../
|
|
22
|
-
import {
|
|
20
|
+
import { createTemplateResolverFeature } from "../template-resolver/feature";
|
|
21
|
+
import { seedTextBlock } from "../template-resolver/seeding";
|
|
22
|
+
import {
|
|
23
|
+
type TemplateResourceRow,
|
|
24
|
+
templateResourceEntity,
|
|
25
|
+
templateResourcesTable,
|
|
26
|
+
} from "../template-resolver/table";
|
|
23
27
|
|
|
24
28
|
let stack: TestStack;
|
|
25
29
|
|
|
26
30
|
beforeAll(async () => {
|
|
27
31
|
stack = await setupTestStack({
|
|
28
|
-
features: [
|
|
32
|
+
features: [createTemplateResolverFeature(), createComplianceProfilesFeature()],
|
|
29
33
|
});
|
|
30
|
-
await unsafeCreateEntityTable(stack.db,
|
|
34
|
+
await unsafeCreateEntityTable(stack.db, templateResourceEntity);
|
|
31
35
|
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
32
36
|
await createEventsTable(stack.db);
|
|
33
37
|
});
|
|
@@ -43,32 +47,32 @@ describe("boot-seed contract", () => {
|
|
|
43
47
|
await seedTextBlock(stack.db, {
|
|
44
48
|
tenantId,
|
|
45
49
|
slug: "imprint",
|
|
46
|
-
|
|
50
|
+
locale: "de",
|
|
47
51
|
title: "Impressum",
|
|
48
|
-
|
|
52
|
+
content: "seed body",
|
|
49
53
|
});
|
|
50
54
|
await seedTextBlock(stack.db, {
|
|
51
55
|
tenantId,
|
|
52
56
|
slug: "imprint",
|
|
53
|
-
|
|
57
|
+
locale: "de",
|
|
54
58
|
title: "Impressum (edited)",
|
|
55
|
-
|
|
59
|
+
content: "admin body",
|
|
56
60
|
ifExists: "update",
|
|
57
61
|
});
|
|
58
62
|
await seedTextBlock(stack.db, {
|
|
59
63
|
tenantId,
|
|
60
64
|
slug: "imprint",
|
|
61
|
-
|
|
65
|
+
locale: "de",
|
|
62
66
|
title: "Impressum",
|
|
63
|
-
|
|
67
|
+
content: "seed body",
|
|
64
68
|
});
|
|
65
69
|
|
|
66
|
-
const row = await fetchOne<
|
|
70
|
+
const row = await fetchOne<TemplateResourceRow>(stack.db, templateResourcesTable, {
|
|
67
71
|
tenantId,
|
|
68
72
|
slug: "imprint",
|
|
69
|
-
|
|
73
|
+
locale: "de",
|
|
70
74
|
});
|
|
71
|
-
expect(row).toMatchObject({ title: "Impressum (edited)",
|
|
75
|
+
expect(row).toMatchObject({ title: "Impressum (edited)", content: "admin body", version: 2 });
|
|
72
76
|
|
|
73
77
|
const events = await selectMany(stack.db, eventsTable, { aggregateId: String(row!.id) });
|
|
74
78
|
expect(events).toHaveLength(2);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# legal-pages
|
|
2
2
|
|
|
3
|
-
Opt-in wrapper around [`
|
|
3
|
+
Opt-in wrapper around [`template-resolver`](../template-resolver/) text-blocks for
|
|
4
4
|
DACH compliance. Ships four fixed public HTML routes
|
|
5
5
|
(`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`,
|
|
6
6
|
`/legal/privacy`) with Markdown→HTML rendering and a boot check that
|
|
@@ -17,25 +17,25 @@ feature.
|
|
|
17
17
|
```typescript
|
|
18
18
|
import { createLegalPagesFeature } from "@cosmicdrift/kumiko-bundled-features/legal-pages";
|
|
19
19
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from "@cosmicdrift/kumiko-bundled-features/
|
|
20
|
+
createTemplateResolverApi,
|
|
21
|
+
createTemplateResolverFeature,
|
|
22
|
+
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
23
23
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
24
24
|
|
|
25
25
|
runProdApp({
|
|
26
26
|
features: [
|
|
27
|
-
|
|
27
|
+
createTemplateResolverFeature(), // legal-pages requires template-resolver
|
|
28
28
|
createLegalPagesFeature(),
|
|
29
29
|
/* ... */
|
|
30
30
|
],
|
|
31
31
|
// Two wirings are required:
|
|
32
32
|
// 1. anonymousAccess for /legal/* routes (run without a JWT)
|
|
33
|
-
// 2. extraContext.
|
|
34
|
-
// decoupling — legal-pages imports no code from
|
|
33
|
+
// 2. extraContext.templateResolver for the boot check (cross-feature
|
|
34
|
+
// decoupling — legal-pages imports no code from template-resolver,
|
|
35
35
|
// only uses the API via ctx)
|
|
36
36
|
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
37
37
|
extraContext: ({ db }) => ({
|
|
38
|
-
|
|
38
|
+
templateResolver: createTemplateResolverApi(db),
|
|
39
39
|
}),
|
|
40
40
|
});
|
|
41
41
|
```
|
|
@@ -44,19 +44,20 @@ runProdApp({
|
|
|
44
44
|
|
|
45
45
|
### Production table setup
|
|
46
46
|
|
|
47
|
-
legal-pages doesn't have its own table — it
|
|
48
|
-
`
|
|
47
|
+
legal-pages doesn't have its own table — it stores its blocks as
|
|
48
|
+
`kind: "text-block"` rows in template-resolver's `read_template_resources`.
|
|
49
|
+
Table setup therefore goes through template-resolver:
|
|
49
50
|
|
|
50
51
|
```bash
|
|
51
|
-
bun kumiko
|
|
52
|
-
bun kumiko
|
|
52
|
+
bun kumiko schema generate <name> # template-resource entity is detected
|
|
53
|
+
bun kumiko schema apply
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
See [
|
|
56
|
+
See [template-resolver/README.md](../template-resolver/README.md).
|
|
56
57
|
|
|
57
58
|
## Routes
|
|
58
59
|
|
|
59
|
-
| Path | Slug +
|
|
60
|
+
| Path | Slug + locale | Title fallback (when block empty) |
|
|
60
61
|
|---|---|---|
|
|
61
62
|
| `GET /legal/impressum` | `imprint` / `de` | "Impressum" |
|
|
62
63
|
| `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung" |
|
|
@@ -64,12 +65,12 @@ See [text-content/README.md](../text-content/README.md#production-table-setup).
|
|
|
64
65
|
| `GET /legal/privacy` | `privacy` / `en` | "Privacy Policy" |
|
|
65
66
|
|
|
66
67
|
Response:
|
|
67
|
-
- `200 text/html` — block exists + has
|
|
68
|
+
- `200 text/html` — block exists + has content. Cache header `public, max-age=300`.
|
|
68
69
|
- `404 text/plain` — block missing. Hint: "Tenant admin must set this text block".
|
|
69
70
|
- `503 text/plain` — `app.fetch` to `/api/query` failed (anonymousAccess missing?).
|
|
70
71
|
|
|
71
72
|
Layout: a minimal HTML5 skeleton with inline CSS — apps that want to
|
|
72
|
-
integrate into their own layout use `
|
|
73
|
+
integrate into their own layout use `template-resolver:query:by-slug`
|
|
73
74
|
directly and render themselves.
|
|
74
75
|
|
|
75
76
|
---
|
|
@@ -79,7 +80,7 @@ directly and render themselves.
|
|
|
79
80
|
`r.job` with `runOnBoot: true` checks at app start whether the DE
|
|
80
81
|
required blocks exist in SYSTEM_TENANT:
|
|
81
82
|
|
|
82
|
-
| Slug +
|
|
83
|
+
| Slug + locale | What happens when missing |
|
|
83
84
|
|---|---|
|
|
84
85
|
| `imprint` / `de` | **Production:** `throw new Error(...)` blocks app start. **Dev:** `ctx.log.warn(...)` |
|
|
85
86
|
| `privacy` / `de` | as above |
|
|
@@ -104,18 +105,18 @@ await fetch("/api/write", {
|
|
|
104
105
|
method: "POST",
|
|
105
106
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
|
|
106
107
|
body: JSON.stringify({
|
|
107
|
-
type: "
|
|
108
|
+
type: "template-resolver:write:set",
|
|
108
109
|
payload: {
|
|
109
110
|
slug: "imprint",
|
|
110
|
-
|
|
111
|
+
locale: "de",
|
|
111
112
|
title: "Impressum",
|
|
112
|
-
|
|
113
|
+
content: "## Angaben gemäß § 5 TMG\n\n...",
|
|
113
114
|
},
|
|
114
115
|
}),
|
|
115
116
|
});
|
|
116
117
|
```
|
|
117
118
|
|
|
118
|
-
→ Idempotent: a second call with the same `(slug,
|
|
119
|
+
→ Idempotent: a second call with the same `(slug, locale)` updates the block.
|
|
119
120
|
ACL: `roles: ["TenantAdmin", "SystemAdmin"]` — SystemAdmin (a global
|
|
120
121
|
role) may set SYSTEM_TENANT texts, TenantAdmin only tenant-owned ones.
|
|
121
122
|
|
|
@@ -128,15 +129,15 @@ instant visibility, you can help things along with a CDN purge.
|
|
|
128
129
|
On first app boot or via migration:
|
|
129
130
|
|
|
130
131
|
```typescript
|
|
131
|
-
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/
|
|
132
|
+
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
|
|
132
133
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
133
134
|
|
|
134
135
|
await seedTextBlock(db, {
|
|
135
136
|
tenantId: SYSTEM_TENANT_ID,
|
|
136
137
|
slug: "imprint",
|
|
137
|
-
|
|
138
|
+
locale: "de",
|
|
138
139
|
title: "Impressum",
|
|
139
|
-
|
|
140
|
+
content: `## Angaben gemäß § 5 TMG
|
|
140
141
|
|
|
141
142
|
**Marc Frost**
|
|
142
143
|
|
|
@@ -181,7 +182,7 @@ no DOMPurify dependency needed:
|
|
|
181
182
|
Kumiko app share the SYSTEM_TENANT version of the legal pages. If you
|
|
182
183
|
need per-tenant imprints (rare — typical case: the platform operator
|
|
183
184
|
is the responsible party, not the tenant customer), call
|
|
184
|
-
|
|
185
|
+
the by-slug query directly with a tenant-specific TenantId
|
|
185
186
|
and put your own routes in front.
|
|
186
187
|
|
|
187
188
|
---
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
TemplateResolverApi,
|
|
4
|
+
TemplateResource,
|
|
5
|
+
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
3
6
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
createTemplateResolverApi,
|
|
8
|
+
createTemplateResolverFeature,
|
|
9
|
+
TEXT_BLOCK_KIND,
|
|
10
|
+
TemplateNotFoundError,
|
|
11
|
+
templateResourceEntity,
|
|
12
|
+
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
13
|
+
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
|
|
9
14
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
10
15
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
16
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
@@ -20,46 +25,46 @@ import { renderMarkdownToHtml, wrapInLayout } from "../markdown";
|
|
|
20
25
|
let stack: TestStack;
|
|
21
26
|
let db: DbConnection;
|
|
22
27
|
|
|
23
|
-
const textFeature =
|
|
28
|
+
const textFeature = createTemplateResolverFeature();
|
|
24
29
|
const legalFeature = createLegalPagesFeature();
|
|
25
30
|
|
|
26
31
|
beforeAll(async () => {
|
|
27
32
|
// legal-pages braucht zwei wirings:
|
|
28
33
|
// 1. anonymousAccess für die /legal/*-Routes (laufen ohne JWT)
|
|
29
|
-
// 2. extraContext.
|
|
34
|
+
// 2. extraContext.templateResolver damit der Boot-Check + interner
|
|
30
35
|
// Cross-Feature-Lookup ohne direct DB-Coupling funktioniert
|
|
31
36
|
stack = await setupTestStack({
|
|
32
37
|
features: [textFeature, legalFeature],
|
|
33
38
|
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
34
39
|
extraContext: ({ db }) => ({
|
|
35
|
-
|
|
40
|
+
templateResolver: createTemplateResolverApi(db),
|
|
36
41
|
}),
|
|
37
42
|
});
|
|
38
43
|
db = stack.db;
|
|
39
|
-
await unsafeCreateEntityTable(db,
|
|
44
|
+
await unsafeCreateEntityTable(db, templateResourceEntity);
|
|
40
45
|
await createEventsTable(db);
|
|
41
46
|
|
|
42
47
|
// Seed legal blocks für SYSTEM_TENANT in DE
|
|
43
48
|
await seedTextBlock(db, {
|
|
44
49
|
tenantId: SYSTEM_TENANT_ID,
|
|
45
50
|
slug: "imprint",
|
|
46
|
-
|
|
51
|
+
locale: "de",
|
|
47
52
|
title: "Impressum",
|
|
48
|
-
|
|
53
|
+
content: "## Angaben gemäß § 5 TMG\n\n**Marc Frost**\n\nSlevogtstr. 10, Leipzig",
|
|
49
54
|
});
|
|
50
55
|
await seedTextBlock(db, {
|
|
51
56
|
tenantId: SYSTEM_TENANT_ID,
|
|
52
57
|
slug: "privacy",
|
|
53
|
-
|
|
58
|
+
locale: "de",
|
|
54
59
|
title: "Datenschutzerklärung",
|
|
55
|
-
|
|
60
|
+
content: "## 1. Überblick\n\nWir verarbeiten **keine Tracking-Cookies**.",
|
|
56
61
|
});
|
|
57
62
|
await seedTextBlock(db, {
|
|
58
63
|
tenantId: SYSTEM_TENANT_ID,
|
|
59
64
|
slug: "imprint",
|
|
60
|
-
|
|
65
|
+
locale: "en",
|
|
61
66
|
title: "Imprint",
|
|
62
|
-
|
|
67
|
+
content: "## Provider\n\n**Marc Frost**\n\nLeipzig, Germany",
|
|
63
68
|
});
|
|
64
69
|
});
|
|
65
70
|
|
|
@@ -120,9 +125,9 @@ describe("legal-pages :: edge-cases", () => {
|
|
|
120
125
|
await seedTextBlock(db, {
|
|
121
126
|
tenantId: SYSTEM_TENANT_ID,
|
|
122
127
|
slug: "imprint",
|
|
123
|
-
|
|
128
|
+
locale: "fr",
|
|
124
129
|
title: "Mentions légales",
|
|
125
|
-
|
|
130
|
+
content: null,
|
|
126
131
|
});
|
|
127
132
|
// Keine /legal/imprint-fr-Route registriert (LEGAL_ROUTES ist
|
|
128
133
|
// de+en) — wir adden nicht extra. Stattdessen testen wir das
|
|
@@ -131,9 +136,9 @@ describe("legal-pages :: edge-cases", () => {
|
|
|
131
136
|
await seedTextBlock(db, {
|
|
132
137
|
tenantId: SYSTEM_TENANT_ID,
|
|
133
138
|
slug: "privacy",
|
|
134
|
-
|
|
139
|
+
locale: "en",
|
|
135
140
|
title: "Privacy Policy",
|
|
136
|
-
|
|
141
|
+
content: null,
|
|
137
142
|
});
|
|
138
143
|
const res = await stack.app.request("/legal/privacy");
|
|
139
144
|
expect(res.status).toBe(404);
|
|
@@ -147,9 +152,9 @@ describe("legal-pages :: edge-cases", () => {
|
|
|
147
152
|
await seedTextBlock(db, {
|
|
148
153
|
tenantId: SYSTEM_TENANT_ID,
|
|
149
154
|
slug: "imprint",
|
|
150
|
-
|
|
155
|
+
locale: "de",
|
|
151
156
|
title: "Impressum",
|
|
152
|
-
|
|
157
|
+
content: "## XSS-Test\n\n<script>window.x=1</script>\n\nDanach.",
|
|
153
158
|
ifExists: "update",
|
|
154
159
|
});
|
|
155
160
|
const res = await stack.app.request("/legal/impressum");
|
|
@@ -233,7 +238,7 @@ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", ()
|
|
|
233
238
|
// (leeren) imprint-Block abfragen → 404. Mit Fix immer SYSTEM_TENANT.
|
|
234
239
|
const otherTenantId = "22222222-2222-4222-8222-222222222222";
|
|
235
240
|
const hostScopedStack = await setupTestStack({
|
|
236
|
-
features: [
|
|
241
|
+
features: [createTemplateResolverFeature(), createLegalPagesFeature()],
|
|
237
242
|
anonymousAccess: {
|
|
238
243
|
// Resolver gibt IMMER einen anderen Tenant zurück — wenn legal-
|
|
239
244
|
// pages den respektieren würde, wäre der DB-Lookup leer.
|
|
@@ -245,20 +250,20 @@ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", ()
|
|
|
245
250
|
tenantExists: async (id) => id === otherTenantId || id === SYSTEM_TENANT_ID,
|
|
246
251
|
},
|
|
247
252
|
extraContext: ({ db }) => ({
|
|
248
|
-
|
|
253
|
+
templateResolver: createTemplateResolverApi(db),
|
|
249
254
|
}),
|
|
250
255
|
});
|
|
251
256
|
try {
|
|
252
|
-
await unsafeCreateEntityTable(hostScopedStack.db,
|
|
257
|
+
await unsafeCreateEntityTable(hostScopedStack.db, templateResourceEntity);
|
|
253
258
|
await createEventsTable(hostScopedStack.db);
|
|
254
259
|
|
|
255
260
|
// Block NUR im SYSTEM_TENANT seeden — NICHT im otherTenantId
|
|
256
261
|
await seedTextBlock(hostScopedStack.db, {
|
|
257
262
|
tenantId: SYSTEM_TENANT_ID,
|
|
258
263
|
slug: "imprint",
|
|
259
|
-
|
|
264
|
+
locale: "de",
|
|
260
265
|
title: "System-Impressum",
|
|
261
|
-
|
|
266
|
+
content: "## Plattform\n\nMarc Frost",
|
|
262
267
|
});
|
|
263
268
|
|
|
264
269
|
const res = await hostScopedStack.app.request("/legal/impressum");
|
|
@@ -277,7 +282,7 @@ describe("legal-pages :: wrapLayout erhält route.slug (alt-lang-switch-regressi
|
|
|
277
282
|
const seenSlugs: (string | undefined)[] = [];
|
|
278
283
|
const customStack = await setupTestStack({
|
|
279
284
|
features: [
|
|
280
|
-
|
|
285
|
+
createTemplateResolverFeature(),
|
|
281
286
|
createLegalPagesFeature({
|
|
282
287
|
wrapLayout: (opts) => {
|
|
283
288
|
seenSlugs.push(opts.slug);
|
|
@@ -287,25 +292,25 @@ describe("legal-pages :: wrapLayout erhält route.slug (alt-lang-switch-regressi
|
|
|
287
292
|
],
|
|
288
293
|
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
289
294
|
extraContext: ({ db }) => ({
|
|
290
|
-
|
|
295
|
+
templateResolver: createTemplateResolverApi(db),
|
|
291
296
|
}),
|
|
292
297
|
});
|
|
293
298
|
try {
|
|
294
|
-
await unsafeCreateEntityTable(customStack.db,
|
|
299
|
+
await unsafeCreateEntityTable(customStack.db, templateResourceEntity);
|
|
295
300
|
await createEventsTable(customStack.db);
|
|
296
301
|
await seedTextBlock(customStack.db, {
|
|
297
302
|
tenantId: SYSTEM_TENANT_ID,
|
|
298
303
|
slug: "imprint",
|
|
299
|
-
|
|
304
|
+
locale: "de",
|
|
300
305
|
title: "Impressum",
|
|
301
|
-
|
|
306
|
+
content: "Body",
|
|
302
307
|
});
|
|
303
308
|
await seedTextBlock(customStack.db, {
|
|
304
309
|
tenantId: SYSTEM_TENANT_ID,
|
|
305
310
|
slug: "privacy",
|
|
306
|
-
|
|
311
|
+
locale: "de",
|
|
307
312
|
title: "Datenschutzerklärung",
|
|
308
|
-
|
|
313
|
+
content: "Body",
|
|
309
314
|
});
|
|
310
315
|
|
|
311
316
|
const imprintRes = await customStack.app.request("/legal/impressum");
|
|
@@ -328,36 +333,61 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
|
|
|
328
333
|
// keine JobRunner-Coupling, keine Test-Stacks. Das ist die echte
|
|
329
334
|
// Verhalten-Test-Surface; r.job() ist nur thin shell darum.
|
|
330
335
|
|
|
331
|
-
type Block = { slug: string; lang: string; title: string;
|
|
336
|
+
type Block = { slug: string; lang: string; title: string; content: string | null };
|
|
332
337
|
|
|
333
|
-
function
|
|
334
|
-
api:
|
|
338
|
+
function fakeTemplateResolver(blocks: readonly Block[]): {
|
|
339
|
+
api: TemplateResolverApi;
|
|
335
340
|
calls: { tenantId: string; slug: string; lang: string }[];
|
|
336
341
|
} {
|
|
337
342
|
const calls: { tenantId: string; slug: string; lang: string }[] = [];
|
|
343
|
+
const find = (slug: string, locale: string): TemplateResource | null => {
|
|
344
|
+
const block = blocks.find((b) => b.slug === slug && b.lang === locale);
|
|
345
|
+
if (!block) return null;
|
|
346
|
+
return {
|
|
347
|
+
id: `${block.slug}-${block.lang}`,
|
|
348
|
+
version: 1,
|
|
349
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
350
|
+
slug: block.slug,
|
|
351
|
+
kind: TEXT_BLOCK_KIND,
|
|
352
|
+
locale: block.lang,
|
|
353
|
+
title: block.title,
|
|
354
|
+
folder: null,
|
|
355
|
+
content: block.content ?? "",
|
|
356
|
+
contentFormat: "markdown",
|
|
357
|
+
variableSchema: {},
|
|
358
|
+
linkedResources: {},
|
|
359
|
+
scope: "system",
|
|
360
|
+
parentTemplateId: null,
|
|
361
|
+
status: "active",
|
|
362
|
+
updatedAt: new Date(),
|
|
363
|
+
};
|
|
364
|
+
};
|
|
338
365
|
return {
|
|
339
366
|
calls,
|
|
340
367
|
api: {
|
|
341
|
-
|
|
342
|
-
calls.push({ tenantId, slug, lang });
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
368
|
+
findExact: async ({ tenantId, slug, locale }) => {
|
|
369
|
+
calls.push({ tenantId, slug, lang: locale });
|
|
370
|
+
return find(slug, locale);
|
|
371
|
+
},
|
|
372
|
+
resolveTemplate: async ({ slug, kind, locale }) => {
|
|
373
|
+
const found = find(slug, locale);
|
|
374
|
+
if (!found) throw new TemplateNotFoundError({ slug, kind, locale });
|
|
375
|
+
return found;
|
|
346
376
|
},
|
|
347
377
|
},
|
|
348
378
|
};
|
|
349
379
|
}
|
|
350
380
|
|
|
351
381
|
test("alle Pflicht-Blocks vorhanden → log.info, kein throw", async () => {
|
|
352
|
-
const { api } =
|
|
353
|
-
{ slug: "imprint", lang: "de", title: "I",
|
|
354
|
-
{ slug: "privacy", lang: "de", title: "P",
|
|
382
|
+
const { api } = fakeTemplateResolver([
|
|
383
|
+
{ slug: "imprint", lang: "de", title: "I", content: "body" },
|
|
384
|
+
{ slug: "privacy", lang: "de", title: "P", content: "body" },
|
|
355
385
|
]);
|
|
356
386
|
const infos: string[] = [];
|
|
357
387
|
const warns: string[] = [];
|
|
358
388
|
await expect(
|
|
359
389
|
runLegalPagesBootCheck({
|
|
360
|
-
|
|
390
|
+
templateResolver: api,
|
|
361
391
|
log: { info: (m) => infos.push(m), warn: (m) => warns.push(m) },
|
|
362
392
|
}),
|
|
363
393
|
).resolves.toBeUndefined();
|
|
@@ -367,11 +397,11 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
|
|
|
367
397
|
});
|
|
368
398
|
|
|
369
399
|
test("missing blocks + NODE_ENV=production → throws mit slug-Liste", async () => {
|
|
370
|
-
const { api } =
|
|
400
|
+
const { api } = fakeTemplateResolver([]);
|
|
371
401
|
const originalEnv = process.env["NODE_ENV"];
|
|
372
402
|
process.env["NODE_ENV"] = "production";
|
|
373
403
|
try {
|
|
374
|
-
await expect(runLegalPagesBootCheck({
|
|
404
|
+
await expect(runLegalPagesBootCheck({ templateResolver: api })).rejects.toThrow(
|
|
375
405
|
/Boot-Validation failed.*imprint\/de.*privacy\/de/s,
|
|
376
406
|
);
|
|
377
407
|
} finally {
|
|
@@ -381,14 +411,14 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
|
|
|
381
411
|
});
|
|
382
412
|
|
|
383
413
|
test("missing blocks + NODE_ENV!=production → log.warn, kein throw", async () => {
|
|
384
|
-
const { api } =
|
|
414
|
+
const { api } = fakeTemplateResolver([]);
|
|
385
415
|
const warns: string[] = [];
|
|
386
416
|
const originalEnv = process.env["NODE_ENV"];
|
|
387
417
|
process.env["NODE_ENV"] = "development";
|
|
388
418
|
try {
|
|
389
419
|
await expect(
|
|
390
420
|
runLegalPagesBootCheck({
|
|
391
|
-
|
|
421
|
+
templateResolver: api,
|
|
392
422
|
log: { warn: (m) => warns.push(m) },
|
|
393
423
|
}),
|
|
394
424
|
).resolves.toBeUndefined();
|
|
@@ -402,21 +432,23 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
|
|
|
402
432
|
}
|
|
403
433
|
});
|
|
404
434
|
|
|
405
|
-
test("ctx ohne
|
|
406
|
-
await expect(runLegalPagesBootCheck({})).rejects.toThrow(
|
|
435
|
+
test("ctx ohne templateResolver → InternalError mit Wiring-Hinweis", async () => {
|
|
436
|
+
await expect(runLegalPagesBootCheck({})).rejects.toThrow(
|
|
437
|
+
/templateResolver missing.*extraContext/s,
|
|
438
|
+
);
|
|
407
439
|
});
|
|
408
440
|
|
|
409
|
-
test("Block existiert aber
|
|
410
|
-
const { api } =
|
|
411
|
-
{ slug: "imprint", lang: "de", title: "I",
|
|
412
|
-
{ slug: "privacy", lang: "de", title: "P",
|
|
441
|
+
test("Block existiert aber content ist null → wird als missing gezählt", async () => {
|
|
442
|
+
const { api } = fakeTemplateResolver([
|
|
443
|
+
{ slug: "imprint", lang: "de", title: "I", content: null },
|
|
444
|
+
{ slug: "privacy", lang: "de", title: "P", content: "body" },
|
|
413
445
|
]);
|
|
414
446
|
const warns: string[] = [];
|
|
415
447
|
const originalEnv = process.env["NODE_ENV"];
|
|
416
448
|
process.env["NODE_ENV"] = "development";
|
|
417
449
|
try {
|
|
418
450
|
await runLegalPagesBootCheck({
|
|
419
|
-
|
|
451
|
+
templateResolver: api,
|
|
420
452
|
log: { warn: (m) => warns.push(m) },
|
|
421
453
|
});
|
|
422
454
|
expect(warns[0]).toContain("missing 1 required text-block(s)");
|
|
@@ -429,11 +461,11 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
|
|
|
429
461
|
});
|
|
430
462
|
|
|
431
463
|
test("alle Lookups erfolgen gegen SYSTEM_TENANT_ID (nie tenant-scoped)", async () => {
|
|
432
|
-
const { api, calls } =
|
|
433
|
-
{ slug: "imprint", lang: "de", title: "I",
|
|
434
|
-
{ slug: "privacy", lang: "de", title: "P",
|
|
464
|
+
const { api, calls } = fakeTemplateResolver([
|
|
465
|
+
{ slug: "imprint", lang: "de", title: "I", content: "x" },
|
|
466
|
+
{ slug: "privacy", lang: "de", title: "P", content: "x" },
|
|
435
467
|
]);
|
|
436
|
-
await runLegalPagesBootCheck({
|
|
468
|
+
await runLegalPagesBootCheck({ templateResolver: api });
|
|
437
469
|
expect(calls).toHaveLength(2);
|
|
438
470
|
for (const call of calls) {
|
|
439
471
|
expect(call.tenantId).toBe(SYSTEM_TENANT_ID);
|