@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/package.json +7 -5
  3. package/src/delivery/__tests__/delivery.integration.ts +6 -0
  4. package/src/delivery/delivery-service.ts +4 -12
  5. package/src/delivery/feature.ts +6 -4
  6. package/src/delivery/index.ts +0 -1
  7. package/src/legal-pages/web/client-plugin.ts +50 -10
  8. package/src/renderer-foundation/README.md +86 -0
  9. package/src/renderer-foundation/__tests__/api.test.ts +188 -0
  10. package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
  11. package/src/renderer-foundation/api.ts +106 -0
  12. package/src/renderer-foundation/constants.ts +21 -0
  13. package/src/renderer-foundation/feature.ts +47 -0
  14. package/src/renderer-foundation/index.ts +25 -0
  15. package/src/renderer-foundation/types.ts +109 -0
  16. package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
  17. package/src/renderer-simple/feature.ts +28 -3
  18. package/src/template-resolver/README.md +89 -0
  19. package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
  20. package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
  21. package/src/template-resolver/api.ts +189 -0
  22. package/src/template-resolver/constants.ts +28 -0
  23. package/src/template-resolver/feature.ts +36 -0
  24. package/src/template-resolver/handlers/archive.write.ts +42 -0
  25. package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
  26. package/src/template-resolver/handlers/list.query.ts +69 -0
  27. package/src/template-resolver/handlers/publish.write.ts +45 -0
  28. package/src/template-resolver/handlers/shared.ts +41 -0
  29. package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
  30. package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
  31. package/src/template-resolver/index.ts +28 -0
  32. package/src/template-resolver/qualified-names.ts +24 -0
  33. package/src/template-resolver/table.ts +67 -0
  34. package/src/text-content/__tests__/text-content.integration.ts +54 -0
  35. package/src/text-content/handlers/by-slug.query.ts +1 -0
  36. package/src/text-content/handlers/by-tenant.query.ts +2 -0
  37. package/src/text-content/handlers/set.write.ts +23 -0
  38. package/src/text-content/seeding.ts +9 -1
  39. package/src/text-content/table.ts +6 -0
  40. package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
  41. package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
  42. package/src/text-content/web/client-plugin.tsx +378 -0
  43. 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.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.3.0",
76
- "@cosmicdrift/kumiko-framework": "0.3.0",
77
- "@cosmicdrift/kumiko-renderer": "0.3.0",
78
- "@cosmicdrift/kumiko-renderer-web": "0.3.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
- // Build renderer map from registry extension usages
60
- export function collectRenderers(registry: Registry): Map<string, NotificationRenderer> {
61
- const usages = registry.getExtensionUsages("notificationRenderer");
62
- const map = new Map<string, NotificationRenderer>();
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 {
@@ -48,13 +48,15 @@ export function createDeliveryFeature(): FeatureDefinition {
48
48
  },
49
49
  });
50
50
 
51
- // Extension points: channels and renderers register as features
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),
@@ -8,7 +8,6 @@ export {
8
8
  } from "./constants";
9
9
  export {
10
10
  collectChannels,
11
- collectRenderers,
12
11
  createDeliveryService,
13
12
  type DeliveryServiceOptions,
14
13
  type KillSwitchResolver,
@@ -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 = (_ctx) => (emit) => {
24
- const allBlocks = [...LEGAL_REQUIRED_BLOCKS, ...LEGAL_OPTIONAL_BLOCKS];
25
- const nodes: readonly TreeNode[] = allBlocks.map((b) => ({
26
- label: `${b.slug} (${b.lang})`,
27
- target: {
28
- featureId: "text-content",
29
- action: "edit",
30
- args: { slug: b.slug, lang: b.lang },
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
+ });