@cosmicdrift/kumiko-bundled-features 0.3.0 → 0.4.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/CHANGELOG.md +49 -0
- package/package.json +7 -5
- package/src/delivery/__tests__/delivery.integration.ts +6 -0
- package/src/delivery/delivery-service.ts +4 -12
- package/src/delivery/feature.ts +6 -4
- package/src/delivery/index.ts +0 -1
- package/src/legal-pages/web/client-plugin.ts +50 -10
- package/src/renderer-foundation/README.md +86 -0
- package/src/renderer-foundation/__tests__/api.test.ts +188 -0
- package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
- package/src/renderer-foundation/api.ts +106 -0
- package/src/renderer-foundation/constants.ts +21 -0
- package/src/renderer-foundation/feature.ts +47 -0
- package/src/renderer-foundation/index.ts +25 -0
- package/src/renderer-foundation/types.ts +109 -0
- package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
- package/src/renderer-simple/feature.ts +28 -3
- package/src/template-resolver/README.md +89 -0
- package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
- package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
- package/src/template-resolver/api.ts +189 -0
- package/src/template-resolver/constants.ts +28 -0
- package/src/template-resolver/feature.ts +36 -0
- package/src/template-resolver/handlers/archive.write.ts +42 -0
- package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
- package/src/template-resolver/handlers/list.query.ts +69 -0
- package/src/template-resolver/handlers/publish.write.ts +45 -0
- package/src/template-resolver/handlers/shared.ts +41 -0
- package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
- package/src/template-resolver/index.ts +28 -0
- package/src/template-resolver/qualified-names.ts +24 -0
- package/src/template-resolver/table.ts +67 -0
- package/src/text-content/__tests__/text-content.integration.ts +54 -0
- package/src/text-content/handlers/by-slug.query.ts +1 -0
- package/src/text-content/handlers/by-tenant.query.ts +2 -0
- package/src/text-content/handlers/set.write.ts +23 -0
- package/src/text-content/seeding.ts +9 -1
- package/src/text-content/table.ts +6 -0
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
- package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
- package/src/text-content/web/client-plugin.tsx +378 -0
- package/src/text-content/web/client-plugin.ts +0 -113
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-bundled-features
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 825e7d2: Visual-Tree V.1.4 → V.1.6 — Feature-complete Editor + Folder-Hierarchy + Roving-tabindex.
|
|
8
|
+
|
|
9
|
+
**V.1.4** — explicit `folder?: string` Schema-Field auf text-block-entity. Slug bleibt
|
|
10
|
+
kebab-only validiert, Folder explizit gesetzt. Tree gruppiert via `groupBlocksByFolder`
|
|
11
|
+
(ersetzt `groupBlocksBySlugPrefix`). `Subscribe<T>` Signature um optional `emitError`
|
|
12
|
+
erweitert für explicit async-error-Pfade. ProviderBranch zeigt Error-Banner mit
|
|
13
|
+
Retry-Button. Drift-Test pinnt seedTextBlock-vs-set.write Slug-Validation.
|
|
14
|
+
|
|
15
|
+
**V.1.4b** — URL-State-Routing für Editor-Target via `nav.searchParams`. F5 + Back-Button
|
|
16
|
+
stellen den Editor-State wieder her. Format: `?t=text-content:edit&a_slug=...&a_lang=...`.
|
|
17
|
+
Plus `useDispatchTarget` hook ersetzt globalen `dispatchTarget` als empfohlenen Production-
|
|
18
|
+
Pfad (legacy bleibt für Test-Hooks).
|
|
19
|
+
|
|
20
|
+
**V.1.5** — Arrow-Key-Navigation (`<aside role="tree">`, ARIA-tree-Pattern) + SSE-driven
|
|
21
|
+
Tree-Refresh. `ClientFeatureDefinition.treeEntities?: string[]` listet Entity-Namen pro
|
|
22
|
+
Provider; live-events triggern provider-re-mount → Stale-Tree-state="stub"→"filled"
|
|
23
|
+
flippt nach save automatisch.
|
|
24
|
+
|
|
25
|
+
**V.1.5c+d** — Active-Node-Highlight (explicit blue + 2px border-l + scrollIntoView),
|
|
26
|
+
VS-Code-Polish (compact spacing, focus-visible, folder-icon-color text-amber, indent-
|
|
27
|
+
guides per ancestor-depth), Folder-Wrapper für legal-pages ("📁 Legal" + slug-first
|
|
28
|
+
Verschachtelung) und text-content ("📁 Content").
|
|
29
|
+
|
|
30
|
+
**V.1.6** — Multi-level Folder-Splitting (`folder="page/marketing"` → nested folders,
|
|
31
|
+
walk-or-create-pattern, folder/leaf-collision-tolerant). Roving-tabindex (nur focused-
|
|
32
|
+
treeitem hat tabIndex=0, Tab cyclt aus dem Tree raus).
|
|
33
|
+
|
|
34
|
+
35/35 kumiko check PASS, 13/13 group-blocks + 22/22 text-content integration tests grün.
|
|
35
|
+
Browser + Keyboard lokal validated.
|
|
36
|
+
|
|
37
|
+
**Breaking**: `TreeContext` Type entfernt (V.1.2 SR2-Rip — war nie genutzt). Provider sind
|
|
38
|
+
session-bound: `TreeChildrenSubscribe = () => Subscribe<T>` statt `(ctx) => Subscribe<T>`.
|
|
39
|
+
|
|
40
|
+
**V.1.7-Followups**: useEffect-deps in VisualTree-focus-init (Performance), Cancellation-
|
|
41
|
+
Token in TreeProvider's fetch (emit-after-unmount-warning), inline-rename, drag-drop,
|
|
42
|
+
file-icons per slug-extension, parent-jump bei ArrowLeft auf collapsed-item.
|
|
43
|
+
|
|
44
|
+
### Patch Changes
|
|
45
|
+
|
|
46
|
+
- Updated dependencies [825e7d2]
|
|
47
|
+
- @cosmicdrift/kumiko-framework@0.4.0
|
|
48
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.4.0
|
|
49
|
+
- @cosmicdrift/kumiko-renderer@0.4.0
|
|
50
|
+
- @cosmicdrift/kumiko-renderer-web@0.4.0
|
|
51
|
+
|
|
3
52
|
## 0.3.0
|
|
4
53
|
|
|
5
54
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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>",
|
|
@@ -64,6 +64,8 @@
|
|
|
64
64
|
"./text-content": "./src/text-content/index.ts",
|
|
65
65
|
"./text-content/seeding": "./src/text-content/seeding.ts",
|
|
66
66
|
"./text-content/web": "./src/text-content/web/index.ts",
|
|
67
|
+
"./template-resolver": "./src/template-resolver/index.ts",
|
|
68
|
+
"./renderer-foundation": "./src/renderer-foundation/index.ts",
|
|
67
69
|
"./legal-pages": "./src/legal-pages/index.ts",
|
|
68
70
|
"./legal-pages/web": "./src/legal-pages/web/index.ts",
|
|
69
71
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
@@ -72,10 +74,10 @@
|
|
|
72
74
|
"@aws-sdk/client-s3": "^3.1045.0",
|
|
73
75
|
"@aws-sdk/lib-storage": "^3.1045.0",
|
|
74
76
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
|
75
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
76
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
77
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
78
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
77
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.4.0",
|
|
78
|
+
"@cosmicdrift/kumiko-framework": "0.4.0",
|
|
79
|
+
"@cosmicdrift/kumiko-renderer": "0.4.0",
|
|
80
|
+
"@cosmicdrift/kumiko-renderer-web": "0.4.0",
|
|
79
81
|
"@mollie/api-client": "^4.5.0",
|
|
80
82
|
"@node-rs/argon2": "^2.0.2",
|
|
81
83
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -27,8 +27,10 @@ import { createChannelPushFeature } from "../../channel-push/feature";
|
|
|
27
27
|
import { createInMemoryPushTransport } from "../../channel-push/types";
|
|
28
28
|
import { createConfigFeature } from "../../config/feature";
|
|
29
29
|
import { configValuesTable } from "../../config/table";
|
|
30
|
+
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
30
31
|
import { createRendererSimpleFeature } from "../../renderer-simple/feature";
|
|
31
32
|
import { simpleRenderer } from "../../renderer-simple/simple-renderer";
|
|
33
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
32
34
|
import { TenantQueries } from "../../tenant/constants";
|
|
33
35
|
import { createTenantFeature } from "../../tenant/feature";
|
|
34
36
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
@@ -241,6 +243,8 @@ const ticketFeature = defineFeature("tickets", (r) => {
|
|
|
241
243
|
|
|
242
244
|
const configFeature = createConfigFeature();
|
|
243
245
|
const tenantFeature = createTenantFeature();
|
|
246
|
+
const templateResolverFeature = createTemplateResolverFeature();
|
|
247
|
+
const rendererFoundationFeature = createRendererFoundationFeature();
|
|
244
248
|
const deliveryFeature = createDeliveryFeature();
|
|
245
249
|
const channelInAppFeature = createChannelInAppFeature();
|
|
246
250
|
const rendererSimpleFeature = createRendererSimpleFeature();
|
|
@@ -256,6 +260,8 @@ const channelPushFeature = createChannelPushFeature({
|
|
|
256
260
|
const features = [
|
|
257
261
|
configFeature,
|
|
258
262
|
tenantFeature,
|
|
263
|
+
templateResolverFeature,
|
|
264
|
+
rendererFoundationFeature,
|
|
259
265
|
deliveryFeature,
|
|
260
266
|
channelInAppFeature,
|
|
261
267
|
rendererSimpleFeature,
|
|
@@ -18,7 +18,6 @@ import type {
|
|
|
18
18
|
DeliveryChannel,
|
|
19
19
|
DeliveryLogEntry,
|
|
20
20
|
DeliveryService,
|
|
21
|
-
NotificationRenderer,
|
|
22
21
|
} from "./types";
|
|
23
22
|
|
|
24
23
|
export type RateLimitConfig = {
|
|
@@ -56,17 +55,10 @@ export function collectChannels(registry: Registry): DeliveryChannel[] {
|
|
|
56
55
|
});
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
for (const usage of usages) {
|
|
64
|
-
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
65
|
-
const opts = usage.options as { render: NotificationRenderer["render"] };
|
|
66
|
-
map.set(usage.entityName, { name: usage.entityName, render: opts.render });
|
|
67
|
-
}
|
|
68
|
-
return map;
|
|
69
|
-
}
|
|
58
|
+
// `collectRenderers` entfernt 2026-05-19: notificationRenderer-Extension-Point
|
|
59
|
+
// wurde nie konsumiert (channel-email nimmt renderer als Konstruktor-Option,
|
|
60
|
+
// nicht aus Extension-Usages). Multi-Kind-Plugin-Pool lebt jetzt im
|
|
61
|
+
// `renderer-foundation`-Bundle via `collectRendererPlugins`.
|
|
70
62
|
|
|
71
63
|
export function createDeliveryService(options: DeliveryServiceOptions): DeliveryService {
|
|
72
64
|
const {
|
package/src/delivery/feature.ts
CHANGED
|
@@ -48,13 +48,15 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
48
48
|
},
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
// Extension
|
|
51
|
+
// Extension point: delivery-channels (email/in-app/push). Renderer-
|
|
52
|
+
// Extension-Point lebt jetzt im `renderer-foundation`-Bundle als
|
|
53
|
+
// `renderer` (Multi-Kind-Plugin-Contract). delivery hostet keinen
|
|
54
|
+
// eigenen mehr — channel-email nimmt renderer als direkte
|
|
55
|
+
// Konstruktor-Option (siehe email-channel.ts), nicht via Extension-
|
|
56
|
+
// Usage. Migration 2026-05-19.
|
|
52
57
|
r.extendsRegistrar("deliveryChannel", {
|
|
53
58
|
onRegister: () => {},
|
|
54
59
|
});
|
|
55
|
-
r.extendsRegistrar("notificationRenderer", {
|
|
56
|
-
onRegister: () => {},
|
|
57
|
-
});
|
|
58
60
|
|
|
59
61
|
const handlers = {
|
|
60
62
|
setPreference: r.writeHandler(setPreferenceWrite),
|
package/src/delivery/index.ts
CHANGED
|
@@ -20,17 +20,57 @@ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framew
|
|
|
20
20
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
21
21
|
import { LEGAL_OPTIONAL_BLOCKS, LEGAL_REQUIRED_BLOCKS } from "../constants";
|
|
22
22
|
|
|
23
|
-
const treeProvider: TreeChildrenSubscribe = (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
const treeProvider: TreeChildrenSubscribe = () => (emit) => {
|
|
24
|
+
// V.1.5d Slug-first Verschachtelung (Variante C):
|
|
25
|
+
// 📁 Legal
|
|
26
|
+
// 📁 imprint
|
|
27
|
+
// de
|
|
28
|
+
// en
|
|
29
|
+
// 📁 privacy
|
|
30
|
+
// de
|
|
31
|
+
// en
|
|
32
|
+
//
|
|
33
|
+
// Slug ist der Übersetzungs-Anker — User pflegt DE+EN-Versionen
|
|
34
|
+
// desselben Inhalts zusammen statt nach Sprache zu gruppieren.
|
|
35
|
+
// Sub-Items sind reine Sprach-Leaves; Label = Sprache, target zeigt
|
|
36
|
+
// auf text-content:edit mit slug+lang.
|
|
37
|
+
|
|
38
|
+
// Group all blocks by slug, collect set of langs per slug.
|
|
39
|
+
const bySlug = new Map<string, string[]>();
|
|
40
|
+
for (const b of [...LEGAL_REQUIRED_BLOCKS, ...LEGAL_OPTIONAL_BLOCKS]) {
|
|
41
|
+
const langs = bySlug.get(b.slug) ?? [];
|
|
42
|
+
if (!langs.includes(b.lang)) langs.push(b.lang);
|
|
43
|
+
bySlug.set(b.slug, langs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const slugFolders: TreeNode[] = [];
|
|
47
|
+
for (const slug of [...bySlug.keys()].sort()) {
|
|
48
|
+
const langs = bySlug.get(slug);
|
|
49
|
+
if (langs === undefined) continue;
|
|
50
|
+
const langLeaves: TreeNode[] = langs.sort().map((lang) => ({
|
|
51
|
+
label: lang,
|
|
52
|
+
target: {
|
|
53
|
+
featureId: "text-content",
|
|
54
|
+
action: "edit",
|
|
55
|
+
args: { slug, lang },
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
slugFolders.push({
|
|
59
|
+
label: slug,
|
|
60
|
+
icon: "folder",
|
|
61
|
+
state: "filled",
|
|
62
|
+
children: langLeaves,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
emit([
|
|
67
|
+
{
|
|
68
|
+
label: "Legal",
|
|
69
|
+
icon: "folder",
|
|
70
|
+
state: "filled",
|
|
71
|
+
children: slugFolders,
|
|
31
72
|
},
|
|
32
|
-
|
|
33
|
-
emit(nodes);
|
|
73
|
+
]);
|
|
34
74
|
return () => {};
|
|
35
75
|
};
|
|
36
76
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# renderer-foundation
|
|
2
|
+
|
|
3
|
+
Plugin-Foundation für Renderer (Notification, HTML-Mail, PDF, Image). Plugins (`renderer-simple`, `renderer-mail-html`, `renderer-puppeteer-client`) registrieren sich via `r.useExtension("renderer", "<name>", { kinds, render })`.
|
|
4
|
+
|
|
5
|
+
**Plan-Doc:** [`kumiko-platform/docs/plans/features/renderer-foundation.md`](../../../../../../kumiko-platform/docs/plans/features/renderer-foundation.md)
|
|
6
|
+
|
|
7
|
+
## Mount
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import {
|
|
11
|
+
collectRendererPlugins,
|
|
12
|
+
createRendererFoundationApi,
|
|
13
|
+
createRendererFoundationFeature,
|
|
14
|
+
} from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
15
|
+
|
|
16
|
+
const features = [
|
|
17
|
+
createTemplateResolverFeature(),
|
|
18
|
+
createRendererFoundationFeature(),
|
|
19
|
+
createRendererSimpleFeature(), // Plugin
|
|
20
|
+
createRendererMailHtmlFeature(), // Plugin (enterprise)
|
|
21
|
+
// weitere Plugins...
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const app = createKumikoApp({
|
|
25
|
+
features,
|
|
26
|
+
extraContext: ({ registry }) => ({
|
|
27
|
+
rendererFoundation: createRendererFoundationApi(
|
|
28
|
+
collectRendererPlugins(registry),
|
|
29
|
+
),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Konsumtion
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { requireRendererFoundation } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
38
|
+
|
|
39
|
+
async function sendMail(ctx, tenantId) {
|
|
40
|
+
const foundation = requireRendererFoundation(ctx, "sendMail");
|
|
41
|
+
const renderer = foundation.createRendererForTenant({ tenantId, kind: "mail-html" });
|
|
42
|
+
const result = await renderer.render(
|
|
43
|
+
{
|
|
44
|
+
kind: "mail-html",
|
|
45
|
+
payload: { content: "Hello {{name}}", contentFormat: "markdown", variables: { name: "Frau Schmidt" } },
|
|
46
|
+
},
|
|
47
|
+
{ db: ctx.db, registry: ctx.registry, tenantId },
|
|
48
|
+
);
|
|
49
|
+
// result.html, result.text
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Plugins ohne Service-Deps (`renderer-simple`) ignorieren den zweiten `ctx`-Parameter.
|
|
54
|
+
|
|
55
|
+
## Plugin-Auswahl-Reihenfolge
|
|
56
|
+
|
|
57
|
+
1. Tenant-Override (Config-Key `rendererPluginByKind`, z.B. `{ "mail-html": "mail-html" }`)
|
|
58
|
+
2. `DEFAULT_PLUGIN_BY_KIND` aus constants
|
|
59
|
+
3. Erstes Plugin im Pool das das kind bedient
|
|
60
|
+
4. `RendererError("no_plugin_for_kind")` wenn nichts passt
|
|
61
|
+
|
|
62
|
+
## Eigenes Plugin schreiben
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
66
|
+
|
|
67
|
+
export const myRendererFeature = defineFeature("renderer-myown", (r) => {
|
|
68
|
+
r.requires("renderer-foundation");
|
|
69
|
+
r.useExtension("renderer", "myown", {
|
|
70
|
+
kinds: ["document-pdf"],
|
|
71
|
+
// ctx ist optional — nur nehmen wenn Service-Access nötig
|
|
72
|
+
render: async (req, ctx) => {
|
|
73
|
+
// ctx.db, ctx.registry, ctx.tenantId verfügbar
|
|
74
|
+
// eigene PDF-Logik
|
|
75
|
+
return { kind: "document-pdf", pdfBytes: ..., pageCount: 1, sizeBytes: ... };
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Out-of-Scope
|
|
82
|
+
|
|
83
|
+
- Template-Storage (kommt aus `template-resolver`)
|
|
84
|
+
- Resource-URL-Substitution (Caller-Verantwortung: signed-URL vs. data-URI je nach kind)
|
|
85
|
+
- Template-Authoring-UI — `designer`-Bundle (geplant)
|
|
86
|
+
- Mail-Versand — `delivery` + `mail-transport-smtp`
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { createRendererFoundationApi } from "../api";
|
|
4
|
+
import {
|
|
5
|
+
type RendererContext,
|
|
6
|
+
RendererError,
|
|
7
|
+
type RendererPlugin,
|
|
8
|
+
type RenderRequest,
|
|
9
|
+
type RenderResponse,
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
// Stub-Context für Plugin-Render-Calls in Unit-Tests. makePlugin ignoriert
|
|
13
|
+
// ctx; db+registry sind hier null-cast weil Unit-Tests keinen echten
|
|
14
|
+
// Stack haben — Integration-Tests (collect-plugins.integration.ts) nutzen
|
|
15
|
+
// echte stack.db / stack.registry. tenantId ist valid UUID (Memory-Lesson
|
|
16
|
+
// feedback_system_tenant_id_is_uuid).
|
|
17
|
+
const STUB_CTX: RendererContext = {
|
|
18
|
+
db: null as never,
|
|
19
|
+
registry: null as never,
|
|
20
|
+
tenantId: "11111111-1111-4111-8111-111111111111" as TenantId,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Test-Helper: minimal Plugin mit fix-Response. Mehrere im Pool für
|
|
24
|
+
// Multi-Kind- + Tenant-Override-Tests.
|
|
25
|
+
function makePlugin(name: string, kinds: RendererPlugin["kinds"]): RendererPlugin {
|
|
26
|
+
return {
|
|
27
|
+
name,
|
|
28
|
+
kinds,
|
|
29
|
+
render: async (req: RenderRequest): Promise<RenderResponse> => {
|
|
30
|
+
// shape-by-kind, gibt name in der response damit Tests sehen welcher plugin lief
|
|
31
|
+
switch (req.kind) {
|
|
32
|
+
case "notification":
|
|
33
|
+
return { kind: "notification", html: `from:${name}` };
|
|
34
|
+
case "mail-html":
|
|
35
|
+
return { kind: "mail-html", html: `from:${name}`, text: `from:${name}` };
|
|
36
|
+
case "document-pdf":
|
|
37
|
+
return {
|
|
38
|
+
kind: "document-pdf",
|
|
39
|
+
pdfBytes: new Uint8Array([1, 2, 3]),
|
|
40
|
+
pageCount: 1,
|
|
41
|
+
sizeBytes: 3,
|
|
42
|
+
};
|
|
43
|
+
case "image-snapshot":
|
|
44
|
+
return {
|
|
45
|
+
kind: "image-snapshot",
|
|
46
|
+
imageBytes: new Uint8Array([1]),
|
|
47
|
+
format: "png",
|
|
48
|
+
width: 1,
|
|
49
|
+
height: 1,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TENANT: TenantId = "22222222-2222-4222-8222-222222222222" as TenantId;
|
|
57
|
+
|
|
58
|
+
describe("renderer-foundation :: Plugin-Selection", () => {
|
|
59
|
+
test("default-plugin für notification = 'simple'", async () => {
|
|
60
|
+
const api = createRendererFoundationApi([
|
|
61
|
+
makePlugin("simple", ["notification"]),
|
|
62
|
+
makePlugin("other", ["notification"]),
|
|
63
|
+
]);
|
|
64
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
65
|
+
expect(plugin.name).toBe("simple");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("default-plugin für mail-html = 'mail-html'", async () => {
|
|
69
|
+
const api = createRendererFoundationApi([
|
|
70
|
+
makePlugin("simple", ["notification", "mail-html"]),
|
|
71
|
+
makePlugin("mail-html", ["mail-html"]),
|
|
72
|
+
]);
|
|
73
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" });
|
|
74
|
+
expect(plugin.name).toBe("mail-html");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("default-plugin für document-pdf = 'puppeteer'", async () => {
|
|
78
|
+
const api = createRendererFoundationApi([
|
|
79
|
+
makePlugin("puppeteer", ["document-pdf", "image-snapshot"]),
|
|
80
|
+
]);
|
|
81
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" });
|
|
82
|
+
expect(plugin.name).toBe("puppeteer");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("Tenant-Override gewinnt vor Default", async () => {
|
|
86
|
+
const api = createRendererFoundationApi(
|
|
87
|
+
[makePlugin("simple", ["notification"]), makePlugin("custom-notif", ["notification"])],
|
|
88
|
+
(tid) => (tid === TENANT ? { notification: "custom-notif" } : null),
|
|
89
|
+
);
|
|
90
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
91
|
+
expect(plugin.name).toBe("custom-notif");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("Tenant-Override auf nicht-registriertes Plugin → fällt durch auf Default", async () => {
|
|
95
|
+
const api = createRendererFoundationApi([makePlugin("simple", ["notification"])], () => ({
|
|
96
|
+
notification: "ghost-plugin",
|
|
97
|
+
}));
|
|
98
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
99
|
+
expect(plugin.name).toBe("simple");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("Fallback auf erstes passendes Plugin wenn weder Tenant-Config noch Default-Name matchen", async () => {
|
|
103
|
+
const api = createRendererFoundationApi([makePlugin("nonstandard-name", ["notification"])]);
|
|
104
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
105
|
+
expect(plugin.name).toBe("nonstandard-name");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("kein Plugin für kind → RendererError", () => {
|
|
109
|
+
const api = createRendererFoundationApi([makePlugin("simple", ["notification"])]);
|
|
110
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" })).toThrow(
|
|
111
|
+
RendererError,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("leerer Plugin-Pool → RendererError für jeden kind", () => {
|
|
116
|
+
const api = createRendererFoundationApi([]);
|
|
117
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "notification" })).toThrow(
|
|
118
|
+
RendererError,
|
|
119
|
+
);
|
|
120
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" })).toThrow(
|
|
121
|
+
RendererError,
|
|
122
|
+
);
|
|
123
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" })).toThrow(
|
|
124
|
+
RendererError,
|
|
125
|
+
);
|
|
126
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "image-snapshot" })).toThrow(
|
|
127
|
+
RendererError,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("RendererError code='no_plugin_for_kind' wenn kein Plugin", () => {
|
|
132
|
+
const api = createRendererFoundationApi([]);
|
|
133
|
+
try {
|
|
134
|
+
api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
135
|
+
expect.fail("expected RendererError");
|
|
136
|
+
} catch (e) {
|
|
137
|
+
expect(e).toBeInstanceOf(RendererError);
|
|
138
|
+
expect((e as RendererError).code).toBe("no_plugin_for_kind");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("Plugin mit mehreren kinds wird für jeden passend gewählt", async () => {
|
|
143
|
+
const multi = makePlugin("multi", ["notification", "mail-html", "document-pdf"]);
|
|
144
|
+
const api = createRendererFoundationApi([multi]);
|
|
145
|
+
expect(api.createRendererForTenant({ tenantId: TENANT, kind: "notification" }).name).toBe(
|
|
146
|
+
"multi",
|
|
147
|
+
);
|
|
148
|
+
expect(api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" }).name).toBe("multi");
|
|
149
|
+
expect(api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" }).name).toBe(
|
|
150
|
+
"multi",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("Plugin mit leeren kinds wird nie ausgewählt", () => {
|
|
155
|
+
const api = createRendererFoundationApi([
|
|
156
|
+
makePlugin("empty-kinds", []),
|
|
157
|
+
makePlugin("simple", ["notification"]),
|
|
158
|
+
]);
|
|
159
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
160
|
+
expect(plugin.name).toBe("simple");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("Tenant-Override mit falschem kind-Plugin → ignoriert, Fallback", async () => {
|
|
164
|
+
// Tenant config sagt "puppeteer" für notification, aber puppeteer kann
|
|
165
|
+
// nur document-pdf. Foundation muss den falsche-kind-Eintrag ignorieren.
|
|
166
|
+
const api = createRendererFoundationApi(
|
|
167
|
+
[makePlugin("simple", ["notification"]), makePlugin("puppeteer", ["document-pdf"])],
|
|
168
|
+
() => ({ notification: "puppeteer" }),
|
|
169
|
+
);
|
|
170
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
171
|
+
expect(plugin.name).toBe("simple");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("renderer-foundation :: Plugin executes render", () => {
|
|
176
|
+
test("Plugin.render returnt RenderResponse mit gleichem kind", async () => {
|
|
177
|
+
const api = createRendererFoundationApi([makePlugin("simple", ["notification"])]);
|
|
178
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
179
|
+
const response = await plugin.render(
|
|
180
|
+
{ kind: "notification", payload: { content: "hello", contentFormat: "markdown" } },
|
|
181
|
+
STUB_CTX,
|
|
182
|
+
);
|
|
183
|
+
expect(response.kind).toBe("notification");
|
|
184
|
+
if (response.kind === "notification") {
|
|
185
|
+
expect(response.html).toBe("from:simple");
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
4
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
5
|
+
import { createRendererFoundationApi } from "../api";
|
|
6
|
+
import { collectRendererPlugins, createRendererFoundationFeature } from "../feature";
|
|
7
|
+
import type { RenderRequest, RenderResponse } from "../types";
|
|
8
|
+
|
|
9
|
+
const TEST_TENANT = "11111111-1111-4111-8111-111111111111" as TenantId;
|
|
10
|
+
|
|
11
|
+
let stack: TestStack;
|
|
12
|
+
|
|
13
|
+
// Mini-Plugin via defineFeature + r.useExtension — wie ein echter
|
|
14
|
+
// renderer-plugin (renderer-simple, renderer-mail-html) sich registriert.
|
|
15
|
+
function createTestPluginFeature(name: string, kinds: ReadonlyArray<string>) {
|
|
16
|
+
return defineFeature(`renderer-${name}`, (r) => {
|
|
17
|
+
r.requires("renderer-foundation");
|
|
18
|
+
r.useExtension("renderer", name, {
|
|
19
|
+
kinds,
|
|
20
|
+
render: async (req: RenderRequest): Promise<RenderResponse> => {
|
|
21
|
+
if (req.kind === "notification") return { kind: "notification", html: `via:${name}` };
|
|
22
|
+
if (req.kind === "mail-html")
|
|
23
|
+
return { kind: "mail-html", html: `via:${name}`, text: `via:${name}` };
|
|
24
|
+
if (req.kind === "document-pdf")
|
|
25
|
+
return {
|
|
26
|
+
kind: "document-pdf",
|
|
27
|
+
pdfBytes: new Uint8Array([1]),
|
|
28
|
+
pageCount: 1,
|
|
29
|
+
sizeBytes: 1,
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
kind: "image-snapshot",
|
|
33
|
+
imageBytes: new Uint8Array([1]),
|
|
34
|
+
format: "png",
|
|
35
|
+
width: 1,
|
|
36
|
+
height: 1,
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
stack = await setupTestStack({
|
|
45
|
+
features: [
|
|
46
|
+
createTemplateResolverFeature(),
|
|
47
|
+
createRendererFoundationFeature(),
|
|
48
|
+
createTestPluginFeature("simple", ["notification"]),
|
|
49
|
+
createTestPluginFeature("mail-html", ["mail-html"]),
|
|
50
|
+
createTestPluginFeature("puppeteer", ["document-pdf", "image-snapshot"]),
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await stack.cleanup();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("renderer-foundation :: Plugin-Pool aus Registry", () => {
|
|
60
|
+
test("collectRendererPlugins findet alle registrierten Plugins", () => {
|
|
61
|
+
const plugins = collectRendererPlugins(stack.registry);
|
|
62
|
+
const names = plugins.map((p) => p.name).sort();
|
|
63
|
+
expect(names).toEqual(["mail-html", "puppeteer", "simple"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("jeder Plugin behält seine kinds-Deklaration", () => {
|
|
67
|
+
const plugins = collectRendererPlugins(stack.registry);
|
|
68
|
+
const byName = new Map(plugins.map((p) => [p.name, p]));
|
|
69
|
+
expect([...byName.get("simple")!.kinds]).toEqual(["notification"]);
|
|
70
|
+
expect([...byName.get("mail-html")!.kinds]).toEqual(["mail-html"]);
|
|
71
|
+
expect([...byName.get("puppeteer")!.kinds].sort()).toEqual(["document-pdf", "image-snapshot"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("API findet Default-Plugin pro kind aus echtem Pool", () => {
|
|
75
|
+
const plugins = collectRendererPlugins(stack.registry);
|
|
76
|
+
const api = createRendererFoundationApi(plugins);
|
|
77
|
+
expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "notification" }).name).toBe(
|
|
78
|
+
"simple",
|
|
79
|
+
);
|
|
80
|
+
expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "mail-html" }).name).toBe(
|
|
81
|
+
"mail-html",
|
|
82
|
+
);
|
|
83
|
+
expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "document-pdf" }).name).toBe(
|
|
84
|
+
"puppeteer",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("Plugin.render mit echtem Pool fließt end-to-end durch", async () => {
|
|
89
|
+
const plugins = collectRendererPlugins(stack.registry);
|
|
90
|
+
const api = createRendererFoundationApi(plugins);
|
|
91
|
+
const plugin = api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "notification" });
|
|
92
|
+
const result = await plugin.render(
|
|
93
|
+
{ kind: "notification", payload: { content: "hello", contentFormat: "markdown" } },
|
|
94
|
+
{ db: stack.db, registry: stack.registry, tenantId: TEST_TENANT },
|
|
95
|
+
);
|
|
96
|
+
expect(result.kind).toBe("notification");
|
|
97
|
+
if (result.kind === "notification") {
|
|
98
|
+
expect(result.html).toBe("via:simple");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|