@cosmicdrift/kumiko-bundled-features 0.3.0 → 0.4.1

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 (54) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/package.json +7 -5
  3. package/src/auth-email-password/i18n.ts +8 -0
  4. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +128 -1
  5. package/src/auth-email-password/web/login-screen.tsx +73 -8
  6. package/src/config/__tests__/cascade.integration.ts +419 -0
  7. package/src/config/__tests__/config.integration.ts +109 -2
  8. package/src/config/constants.ts +1 -0
  9. package/src/config/feature.ts +2 -0
  10. package/src/config/handlers/cascade.query.ts +70 -0
  11. package/src/config/handlers/values.query.ts +14 -4
  12. package/src/config/index.ts +17 -0
  13. package/src/config/resolver.ts +273 -1
  14. package/src/delivery/__tests__/delivery.integration.ts +6 -0
  15. package/src/delivery/delivery-service.ts +4 -12
  16. package/src/delivery/feature.ts +6 -4
  17. package/src/delivery/index.ts +0 -1
  18. package/src/legal-pages/web/client-plugin.ts +50 -10
  19. package/src/renderer-foundation/README.md +86 -0
  20. package/src/renderer-foundation/__tests__/api.test.ts +188 -0
  21. package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
  22. package/src/renderer-foundation/api.ts +106 -0
  23. package/src/renderer-foundation/constants.ts +21 -0
  24. package/src/renderer-foundation/feature.ts +47 -0
  25. package/src/renderer-foundation/index.ts +25 -0
  26. package/src/renderer-foundation/types.ts +109 -0
  27. package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
  28. package/src/renderer-simple/feature.ts +28 -3
  29. package/src/template-resolver/README.md +89 -0
  30. package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
  31. package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
  32. package/src/template-resolver/api.ts +205 -0
  33. package/src/template-resolver/constants.ts +28 -0
  34. package/src/template-resolver/feature.ts +36 -0
  35. package/src/template-resolver/handlers/archive.write.ts +42 -0
  36. package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
  37. package/src/template-resolver/handlers/list.query.ts +71 -0
  38. package/src/template-resolver/handlers/publish.write.ts +45 -0
  39. package/src/template-resolver/handlers/shared.ts +41 -0
  40. package/src/template-resolver/handlers/upsert-system.write.ts +81 -0
  41. package/src/template-resolver/handlers/upsert-tenant.write.ts +105 -0
  42. package/src/template-resolver/index.ts +28 -0
  43. package/src/template-resolver/qualified-names.ts +24 -0
  44. package/src/template-resolver/table.ts +67 -0
  45. package/src/text-content/__tests__/text-content.integration.ts +54 -0
  46. package/src/text-content/handlers/by-slug.query.ts +1 -0
  47. package/src/text-content/handlers/by-tenant.query.ts +2 -0
  48. package/src/text-content/handlers/set.write.ts +23 -0
  49. package/src/text-content/seeding.ts +9 -1
  50. package/src/text-content/table.ts +6 -0
  51. package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
  52. package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
  53. package/src/text-content/web/client-plugin.tsx +378 -0
  54. package/src/text-content/web/client-plugin.ts +0 -113
@@ -1,12 +1,37 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { RendererError, type RenderRequest, type RenderResponse } from "../renderer-foundation";
2
3
  import { simpleRenderer } from "./simple-renderer";
3
4
 
5
+ // Adapter: simpleRenderer.render hat `Promise<string>`-Signatur (Legacy
6
+ // NotificationRenderer-Contract), renderer-foundation erwartet
7
+ // `Promise<RenderResponse>` mit discriminated union. Mapper bewahrt
8
+ // die simpleRenderer-Implementierung (Template-Strings → HTML mit
9
+ // Inline-CSS) und packt sie in den RendererPlugin-Contract.
10
+ //
11
+ // Exported damit der Adapter-Pfad direkt testbar ist (unit-test).
12
+ export async function adaptToFoundation(req: RenderRequest): Promise<RenderResponse> {
13
+ if (req.kind !== "notification") {
14
+ // Defensiver Guard — Foundation wählt Plugins nur für matching kinds,
15
+ // dieser Pfad sollte unter normalen Umständen nie erreicht werden.
16
+ throw new RendererError(
17
+ `renderer-simple supports only kind="notification", got "${req.kind}"`,
18
+ "invalid_payload",
19
+ );
20
+ }
21
+ const html = await simpleRenderer.render({
22
+ template: req.payload.template ?? "",
23
+ variables: req.payload.variables ?? {},
24
+ });
25
+ return { kind: "notification", html };
26
+ }
27
+
4
28
  export function createRendererSimpleFeature(): FeatureDefinition {
5
29
  return defineFeature("rendererSimple", (r) => {
6
- r.requires("delivery");
30
+ r.requires("renderer-foundation");
7
31
 
8
- r.useExtension("notificationRenderer", "simple", {
9
- render: simpleRenderer.render,
32
+ r.useExtension("renderer", "simple", {
33
+ kinds: ["notification"] as const,
34
+ render: adaptToFoundation,
10
35
  });
11
36
  });
12
37
  }
@@ -0,0 +1,89 @@
1
+ # template-resolver
2
+
3
+ Strukturierter Template-Storage mit Tenant-Override-Hierarchie, Locale-Fallback und Resource-Linking via `file-foundation`.
4
+
5
+ **Plan-Doc:** [`kumiko-platform/docs/plans/features/template-resolver.md`](../../../../../../kumiko-platform/docs/plans/features/template-resolver.md)
6
+
7
+ **Status (2026-05-19):** 45 Integration-Tests grün, typecheck grün, self+advisor-reviewed. Implementierungs-Erkenntnisse im Plan-Doc.
8
+
9
+ ## Mount
10
+
11
+ ```typescript
12
+ // App-Bootstrap
13
+ import {
14
+ createTemplateResolverApi,
15
+ createTemplateResolverFeature,
16
+ } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
17
+
18
+ const features = [
19
+ createTemplateResolverFeature(),
20
+ // ... weitere Features
21
+ ];
22
+
23
+ const app = createKumikoApp({
24
+ features,
25
+ extraContext: ({ db }) => ({
26
+ templateResolver: createTemplateResolverApi(db),
27
+ }),
28
+ });
29
+ ```
30
+
31
+ ## Konsumtion (in Feature-Handlern)
32
+
33
+ ```typescript
34
+ import { requireTemplateResolver } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
35
+
36
+ async function someHandler(ctx) {
37
+ const templateResolver = requireTemplateResolver(ctx, "someHandler");
38
+ const template = await templateResolver.resolveTemplate({
39
+ tenantId: ctx.user.tenantId,
40
+ slug: "nka-versand",
41
+ kind: "mail-html",
42
+ locale: "de",
43
+ });
44
+ // template.content + template.variableSchema + template.linkedResources verwenden
45
+ // ...
46
+ }
47
+ ```
48
+
49
+ ## Resolver-Reihenfolge (4-Stufen-Fallback)
50
+
51
+ 1. `tenantId` + requested locale
52
+ 2. `SYSTEM_TENANT_ID` + requested locale
53
+ 3. `tenantId` + `FALLBACK_LOCALE` (default "de")
54
+ 4. `SYSTEM_TENANT_ID` + `FALLBACK_LOCALE`
55
+
56
+ Wenn nichts gefunden → `TemplateNotFoundError`.
57
+
58
+ ## Admin-Workflows (Write-Handlers + Queries)
59
+
60
+ | Handler | QN | Wer | Was |
61
+ |---|---|---|---|
62
+ | `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin | Erstellt/Updated System-Default-Templates (`SYSTEM_TENANT_ID`, scope='system', status='active') |
63
+ | `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (eigener Tenant) + SystemAdmin via `tenantIdOverride` | Erstellt/Updated Tenant-Overrides (scope='tenant'), default-status='draft' |
64
+ | `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (eigener Tenant) | Setzt status='active' |
65
+ | `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (eigener Tenant) | Setzt status='archived' (Resolver ignoriert es danach) |
66
+ | `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (eigener Tenant + system-templates sichtbar) | Raw-Lookup für Edit-UI |
67
+ | `TemplateResolverQueries.list` | `template-resolver:query:list` | gleich | Filter nach kind/locale/status, optional includeSystem |
68
+
69
+ **SystemAdmin-Cross-Tenant für publish/archive/findById:** aktuell nicht implementiert. `ctx.db` ist tenant-scoped (createTenantDb in dispatcher), SystemAdmin sieht ohne explicit `tenantIdOverride` keine fremden Tenants. Wenn Admin-UI das fordert: Schema-Erweiterung in einer M2-Iteration.
70
+
71
+ ## Status-Lifecycle
72
+
73
+ ```
74
+ upsertSystem ──┐
75
+ ├──► status: "active" (System-Default sofort aktiv)
76
+ upsertTenant ──┴──► status: "draft" (Default) | "active" (explizit)
77
+
78
+ publish ───────► status: "active"
79
+ archive ───────► status: "archived"
80
+ ```
81
+
82
+ Resolver returnt **nur** Templates mit `status: "active"`. draft/archived werden ignoriert.
83
+
84
+ ## Out-of-Scope
85
+
86
+ - Rendering (Markdown/MJML → HTML/PDF) — siehe `renderer-foundation`
87
+ - Resource-URL-Substitution (signed-URL vs. data-URI) — Caller-Verantwortung je nach kind
88
+ - Visual Template-Editor — `designer`-Bundle (geplant)
89
+ - A/B-Testing — eigenes Bundle wenn Bedarf real
@@ -0,0 +1,403 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import {
5
+ createTestUser,
6
+ setupTestStack,
7
+ type TestStack,
8
+ TestUsers,
9
+ testTenantId,
10
+ unsafeCreateEntityTable,
11
+ } from "@cosmicdrift/kumiko-framework/stack";
12
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
13
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
14
+ import { createTemplateResolverFeature } from "../feature";
15
+ import { TemplateResolverHandlers, TemplateResolverQueries } from "../qualified-names";
16
+ import { templateResourceEntity } from "../table";
17
+
18
+ let stack: TestStack;
19
+ let db: DbConnection;
20
+
21
+ const systemAdmin = TestUsers.systemAdmin;
22
+ // Explizite, distinct tenantIds — createTestUser default-falls auf
23
+ // TestUsers.admin.tenantId (alle User im selben Tenant). Wir testen
24
+ // Tenant-Isolation, deshalb pro Test-User eigener Tenant.
25
+ const tenantA_Admin = createTestUser({
26
+ id: 2,
27
+ roles: ["TenantAdmin"],
28
+ tenantId: testTenantId(10),
29
+ });
30
+ const tenantB_Admin = createTestUser({
31
+ id: 3,
32
+ roles: ["TenantAdmin"],
33
+ tenantId: testTenantId(20),
34
+ });
35
+ const normalUser = createTestUser({ id: 4, tenantId: testTenantId(10) });
36
+
37
+ const feature = createTemplateResolverFeature();
38
+
39
+ beforeAll(async () => {
40
+ stack = await setupTestStack({ features: [feature] });
41
+ db = stack.db;
42
+ await unsafeCreateEntityTable(db, templateResourceEntity);
43
+ await createEventsTable(db);
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await stack.cleanup();
48
+ });
49
+
50
+ const basePayload = {
51
+ slug: "test-slug",
52
+ kind: "mail-html" as const,
53
+ locale: "de",
54
+ content: "Hello {{variables.name}}",
55
+ contentFormat: "markdown" as const,
56
+ variableSchema: { name: { type: "string" } },
57
+ linkedResources: {},
58
+ };
59
+
60
+ // Audit-Trail via event-store-executor: kein dedizierter Test, weil
61
+ // `stack.events.postSave` im aktuellen Test-Stack-Setup nicht aktiv
62
+ // populated wird (Pipeline-Hooks-Wiring unterschiedlich zum Prod-Path).
63
+ // Indirekter Beweis: alle Resolver/Handler-Tests funktionieren — würde
64
+ // der executor nicht in die DB schreiben, würden findById/list/
65
+ // resolveTemplate alles nichts finden. Wenn echtes Audit-Log-Test
66
+ // gebraucht wird: direkt `read_events`-Tabelle abfragen.
67
+ describe("template-resolver :: upsertSystem", () => {
68
+ test("SystemAdmin kann System-Template anlegen", async () => {
69
+ const result = await stack.http.writeOk<Record<string, unknown>>(
70
+ TemplateResolverHandlers.upsertSystem,
71
+ { ...basePayload, slug: "system-new" },
72
+ systemAdmin,
73
+ );
74
+ expect(result).toMatchObject({ slug: "system-new", isNew: true });
75
+ });
76
+
77
+ test("idempotent — zweiter Call updated existing System-Template", async () => {
78
+ await stack.http.writeOk(
79
+ TemplateResolverHandlers.upsertSystem,
80
+ { ...basePayload, slug: "system-idem", content: "v1" },
81
+ systemAdmin,
82
+ );
83
+ const result = await stack.http.writeOk<Record<string, unknown>>(
84
+ TemplateResolverHandlers.upsertSystem,
85
+ { ...basePayload, slug: "system-idem", content: "v2" },
86
+ systemAdmin,
87
+ );
88
+ expect(result).toMatchObject({ slug: "system-idem", isNew: false });
89
+ });
90
+
91
+ test("TenantAdmin denied (access_denied)", async () => {
92
+ const err = await stack.http.writeErr(
93
+ TemplateResolverHandlers.upsertSystem,
94
+ { ...basePayload, slug: "tenant-blocked" },
95
+ tenantA_Admin,
96
+ );
97
+ expectErrorIncludes(err, "access_denied");
98
+ });
99
+
100
+ test("normal User denied", async () => {
101
+ const err = await stack.http.writeErr(
102
+ TemplateResolverHandlers.upsertSystem,
103
+ { ...basePayload, slug: "user-blocked" },
104
+ normalUser,
105
+ );
106
+ expectErrorIncludes(err, "access_denied");
107
+ });
108
+
109
+ test("invalid slug rejected", async () => {
110
+ const err = await stack.http.writeErr(
111
+ TemplateResolverHandlers.upsertSystem,
112
+ { ...basePayload, slug: "Invalid Slug!" },
113
+ systemAdmin,
114
+ );
115
+ expectErrorIncludes(err, "validation_error");
116
+ });
117
+ });
118
+
119
+ describe("template-resolver :: upsertTenant", () => {
120
+ test("TenantAdmin kann Override für eigenen Tenant anlegen", async () => {
121
+ const result = await stack.http.writeOk<Record<string, unknown>>(
122
+ TemplateResolverHandlers.upsertTenant,
123
+ { ...basePayload, slug: "tenant-own" },
124
+ tenantA_Admin,
125
+ );
126
+ expect(result).toMatchObject({ slug: "tenant-own", isNew: true });
127
+ });
128
+
129
+ test("default-status ist draft, explizites active geht auch", async () => {
130
+ await stack.http.writeOk(
131
+ TemplateResolverHandlers.upsertTenant,
132
+ { ...basePayload, slug: "tenant-default-draft" },
133
+ tenantA_Admin,
134
+ );
135
+ const fetched = await stack.http.queryOk<Record<string, unknown>>(
136
+ TemplateResolverQueries.list,
137
+ { kind: "mail-html", locale: "de", includeSystem: false },
138
+ tenantA_Admin,
139
+ );
140
+ const found = (fetched as unknown as Array<{ slug: string; status: string }>).find(
141
+ (r) => r.slug === "tenant-default-draft",
142
+ );
143
+ expect(found?.status).toBe("draft");
144
+
145
+ await stack.http.writeOk(
146
+ TemplateResolverHandlers.upsertTenant,
147
+ { ...basePayload, slug: "tenant-explicit-active", status: "active" },
148
+ tenantA_Admin,
149
+ );
150
+ const fetched2 = await stack.http.queryOk<Record<string, unknown>>(
151
+ TemplateResolverQueries.list,
152
+ { kind: "mail-html", locale: "de", includeSystem: false },
153
+ tenantA_Admin,
154
+ );
155
+ const found2 = (fetched2 as unknown as Array<{ slug: string; status: string }>).find(
156
+ (r) => r.slug === "tenant-explicit-active",
157
+ );
158
+ expect(found2?.status).toBe("active");
159
+ });
160
+
161
+ test("SystemAdmin kann via tenantIdOverride cross-tenant schreiben", async () => {
162
+ const result = await stack.http.writeOk<Record<string, unknown>>(
163
+ TemplateResolverHandlers.upsertTenant,
164
+ { ...basePayload, slug: "system-override", tenantIdOverride: tenantA_Admin.tenantId },
165
+ systemAdmin,
166
+ );
167
+ expect(result).toMatchObject({ slug: "system-override", isNew: true });
168
+ });
169
+
170
+ test("SystemAdmin-Override auf SYSTEM_TENANT_ID → access_denied (use upsertSystem)", async () => {
171
+ const err = await stack.http.writeErr(
172
+ TemplateResolverHandlers.upsertTenant,
173
+ { ...basePayload, slug: "denied-system-override", tenantIdOverride: SYSTEM_TENANT_ID },
174
+ systemAdmin,
175
+ );
176
+ expectErrorIncludes(err, "access_denied");
177
+ });
178
+
179
+ test("TenantAdmin-Override-Versuch → access_denied", async () => {
180
+ const err = await stack.http.writeErr(
181
+ TemplateResolverHandlers.upsertTenant,
182
+ { ...basePayload, slug: "denied-override", tenantIdOverride: tenantB_Admin.tenantId },
183
+ tenantA_Admin,
184
+ );
185
+ expectErrorIncludes(err, "access_denied");
186
+ });
187
+
188
+ test("normal User denied", async () => {
189
+ const err = await stack.http.writeErr(
190
+ TemplateResolverHandlers.upsertTenant,
191
+ { ...basePayload, slug: "user-denied" },
192
+ normalUser,
193
+ );
194
+ expectErrorIncludes(err, "access_denied");
195
+ });
196
+ });
197
+
198
+ describe("template-resolver :: publish + archive", () => {
199
+ test("publish setzt status auf active", async () => {
200
+ const created = await stack.http.writeOk<{ id: string }>(
201
+ TemplateResolverHandlers.upsertTenant,
202
+ { ...basePayload, slug: "publish-test", status: "draft" },
203
+ tenantA_Admin,
204
+ );
205
+ const published = await stack.http.writeOk<Record<string, unknown>>(
206
+ TemplateResolverHandlers.publish,
207
+ { id: created.id },
208
+ tenantA_Admin,
209
+ );
210
+ expect(published).toMatchObject({ status: "active" });
211
+ });
212
+
213
+ test("publish: TenantA kann TenantB's Template nicht publishen (NotFound)", async () => {
214
+ const created = await stack.http.writeOk<{ id: string }>(
215
+ TemplateResolverHandlers.upsertTenant,
216
+ { ...basePayload, slug: "isolation-publish" },
217
+ tenantA_Admin,
218
+ );
219
+ const err = await stack.http.writeErr(
220
+ TemplateResolverHandlers.publish,
221
+ { id: created.id },
222
+ tenantB_Admin,
223
+ );
224
+ expectErrorIncludes(err, "not_found");
225
+ });
226
+
227
+ test("publish: nicht-existierender ID → NotFound", async () => {
228
+ const err = await stack.http.writeErr(
229
+ TemplateResolverHandlers.publish,
230
+ { id: "00000000-0000-4000-8000-000000000999" },
231
+ tenantA_Admin,
232
+ );
233
+ expectErrorIncludes(err, "not_found");
234
+ });
235
+
236
+ test("archive setzt status auf archived", async () => {
237
+ const created = await stack.http.writeOk<{ id: string }>(
238
+ TemplateResolverHandlers.upsertTenant,
239
+ { ...basePayload, slug: "archive-test", status: "active" },
240
+ tenantA_Admin,
241
+ );
242
+ const archived = await stack.http.writeOk<Record<string, unknown>>(
243
+ TemplateResolverHandlers.archive,
244
+ { id: created.id },
245
+ tenantA_Admin,
246
+ );
247
+ expect(archived).toMatchObject({ status: "archived" });
248
+ });
249
+
250
+ test("archive: tenant-isolation (NotFound bei fremdem Tenant)", async () => {
251
+ const created = await stack.http.writeOk<{ id: string }>(
252
+ TemplateResolverHandlers.upsertTenant,
253
+ { ...basePayload, slug: "isolation-archive" },
254
+ tenantA_Admin,
255
+ );
256
+ const err = await stack.http.writeErr(
257
+ TemplateResolverHandlers.archive,
258
+ { id: created.id },
259
+ tenantB_Admin,
260
+ );
261
+ expectErrorIncludes(err, "not_found");
262
+ });
263
+
264
+ // SystemAdmin-Cross-Tenant-Publish/Archive nicht implementiert: ctx.db ist
265
+ // tenant-scoped (createTenantDb in dispatcher). Braucht `tenantIdOverride`
266
+ // im Schema wie upsertTenant. M2-Erweiterung wenn Admin-UI das fordert.
267
+ });
268
+
269
+ describe("template-resolver :: findById query", () => {
270
+ test("findet eigenes Template", async () => {
271
+ const created = await stack.http.writeOk<{ id: string }>(
272
+ TemplateResolverHandlers.upsertTenant,
273
+ { ...basePayload, slug: "find-own" },
274
+ tenantA_Admin,
275
+ );
276
+ const result = await stack.http.queryOk<Record<string, unknown>>(
277
+ TemplateResolverQueries.findById,
278
+ { id: created.id },
279
+ tenantA_Admin,
280
+ );
281
+ expect(result).toMatchObject({ slug: "find-own", scope: "tenant" });
282
+ });
283
+
284
+ test("returnt null bei fremdem Tenant", async () => {
285
+ const created = await stack.http.writeOk<{ id: string }>(
286
+ TemplateResolverHandlers.upsertTenant,
287
+ { ...basePayload, slug: "find-isolation" },
288
+ tenantA_Admin,
289
+ );
290
+ const result = await stack.http.queryOk(
291
+ TemplateResolverQueries.findById,
292
+ { id: created.id },
293
+ tenantB_Admin,
294
+ );
295
+ expect(result).toBeNull();
296
+ });
297
+
298
+ test("System-Templates sind für alle authentifizierten User sichtbar", async () => {
299
+ const created = await stack.http.writeOk<{ id: string }>(
300
+ TemplateResolverHandlers.upsertSystem,
301
+ { ...basePayload, slug: "find-system" },
302
+ systemAdmin,
303
+ );
304
+ const result = await stack.http.queryOk<Record<string, unknown>>(
305
+ TemplateResolverQueries.findById,
306
+ { id: created.id },
307
+ tenantA_Admin,
308
+ );
309
+ expect(result).toMatchObject({ slug: "find-system", scope: "system" });
310
+ expect((result as { tenantId: string }).tenantId).toBe(SYSTEM_TENANT_ID);
311
+ });
312
+
313
+ // SystemAdmin-Cross-Tenant-FindById nicht implementiert — gleicher Grund
314
+ // wie publish/archive. ctx.db tenant-scoped, braucht tenantIdOverride im
315
+ // findById-Schema. M2-Erweiterung.
316
+ });
317
+
318
+ describe("template-resolver :: list query", () => {
319
+ test("includeSystem=true zeigt eigene + system", async () => {
320
+ await stack.http.writeOk(
321
+ TemplateResolverHandlers.upsertSystem,
322
+ { ...basePayload, slug: "list-system-1", locale: "de", kind: "mail-html" },
323
+ systemAdmin,
324
+ );
325
+ await stack.http.writeOk(
326
+ TemplateResolverHandlers.upsertTenant,
327
+ { ...basePayload, slug: "list-tenant-1", locale: "de", kind: "mail-html" },
328
+ tenantA_Admin,
329
+ );
330
+ const result = (await stack.http.queryOk(
331
+ TemplateResolverQueries.list,
332
+ { kind: "mail-html", locale: "de", includeSystem: true },
333
+ tenantA_Admin,
334
+ )) as Array<{ slug: string; scope: string }>;
335
+ const slugs = result.map((r) => r.slug);
336
+ expect(slugs).toContain("list-system-1");
337
+ expect(slugs).toContain("list-tenant-1");
338
+ });
339
+
340
+ test("includeSystem=false zeigt nur eigenen Tenant", async () => {
341
+ await stack.http.writeOk(
342
+ TemplateResolverHandlers.upsertSystem,
343
+ { ...basePayload, slug: "list-system-2", locale: "tr", kind: "notification" },
344
+ systemAdmin,
345
+ );
346
+ await stack.http.writeOk(
347
+ TemplateResolverHandlers.upsertTenant,
348
+ { ...basePayload, slug: "list-tenant-2", locale: "tr", kind: "notification" },
349
+ tenantA_Admin,
350
+ );
351
+ const result = (await stack.http.queryOk(
352
+ TemplateResolverQueries.list,
353
+ { kind: "notification", locale: "tr", includeSystem: false },
354
+ tenantA_Admin,
355
+ )) as Array<{ slug: string; scope: string }>;
356
+ const slugs = result.map((r) => r.slug);
357
+ expect(slugs).toContain("list-tenant-2");
358
+ expect(slugs).not.toContain("list-system-2");
359
+ });
360
+
361
+ test("tenant-isolation: TenantA's templates nicht für TenantB", async () => {
362
+ await stack.http.writeOk(
363
+ TemplateResolverHandlers.upsertTenant,
364
+ { ...basePayload, slug: "list-iso", locale: "fr", kind: "notification" },
365
+ tenantA_Admin,
366
+ );
367
+ const result = (await stack.http.queryOk(
368
+ TemplateResolverQueries.list,
369
+ { kind: "notification", locale: "fr", includeSystem: false },
370
+ tenantB_Admin,
371
+ )) as Array<{ slug: string }>;
372
+ expect(result.map((r) => r.slug)).not.toContain("list-iso");
373
+ });
374
+
375
+ test("status-Filter funktioniert", async () => {
376
+ const draft = await stack.http.writeOk<{ id: string }>(
377
+ TemplateResolverHandlers.upsertTenant,
378
+ { ...basePayload, slug: "filter-draft", locale: "es", kind: "notification", status: "draft" },
379
+ tenantA_Admin,
380
+ );
381
+ await stack.http.writeOk(
382
+ TemplateResolverHandlers.upsertTenant,
383
+ {
384
+ ...basePayload,
385
+ slug: "filter-active",
386
+ locale: "es",
387
+ kind: "notification",
388
+ status: "active",
389
+ },
390
+ tenantA_Admin,
391
+ );
392
+ const drafts = (await stack.http.queryOk(
393
+ TemplateResolverQueries.list,
394
+ { kind: "notification", locale: "es", status: "draft", includeSystem: false },
395
+ tenantA_Admin,
396
+ )) as Array<{ slug: string }>;
397
+ expect(drafts.map((r) => r.slug)).toContain("filter-draft");
398
+ expect(drafts.map((r) => r.slug)).not.toContain("filter-active");
399
+
400
+ // Sicherstellen dass draft existiert
401
+ expect(draft.id).toBeTruthy();
402
+ });
403
+ });