@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
@@ -0,0 +1,105 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ defineWriteHandler,
4
+ SYSTEM_TENANT_ID,
5
+ type TenantId,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { eq } from "drizzle-orm";
9
+ import { z } from "zod";
10
+ import type { TemplateResourceRow } from "../table";
11
+ import { templateResourcesTable } from "../table";
12
+ import { executor, upsertPayloadSchema } from "./shared";
13
+
14
+ // Tenant-Override anlegen/updaten. Liegt unter event.user.tenantId,
15
+ // scope='tenant'. Default-Status='draft' — User publisht explizit
16
+ // via publish-Handler. SystemAdmin kann via tenantIdOverride für
17
+ // einen anderen Tenant schreiben (typisch: Plattform-Admin-UI das
18
+ // Tenant-Templates kuratiert).
19
+ export const upsertTenantWrite = defineWriteHandler({
20
+ name: "upsert-tenant",
21
+ schema: upsertPayloadSchema.extend({
22
+ tenantIdOverride: z.string().min(1).optional(),
23
+ status: z.enum(["draft", "active"]).default("draft"),
24
+ }),
25
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
26
+ handler: async (event, ctx) => {
27
+ const db = ctx.db;
28
+ const override = event.payload.tenantIdOverride;
29
+ if (override !== undefined && !event.user.roles.includes("SystemAdmin")) {
30
+ return writeFailure(
31
+ new AccessDeniedError({
32
+ i18nKey: "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
33
+ details: { reason: "tenant_override_requires_system_admin" },
34
+ }),
35
+ );
36
+ }
37
+ // upsertTenant erzeugt scope='tenant'. SYSTEM_TENANT_ID-Override würde
38
+ // scope='tenant' unter SYSTEM_TENANT_ID schreiben → inkonsistenter Zustand
39
+ // (Resolver-Logik trennt sauber zwischen system+tenant). SystemAdmin muss
40
+ // upsertSystem für System-Defaults nutzen.
41
+ if (override === SYSTEM_TENANT_ID) {
42
+ return writeFailure(
43
+ new AccessDeniedError({
44
+ i18nKey: "templateResolver.errors.useUpsertSystemForSystemTenant",
45
+ details: { reason: "system_tenant_override_not_allowed_use_upsert_system" },
46
+ }),
47
+ );
48
+ }
49
+ // @cast-boundary engine-payload — override aus Zod-parsed string,
50
+ // event.user.tenantId schon TenantId-branded; union als TenantId casten
51
+ // ist legit (override ist UUID-Format-validiert in schema).
52
+ const tenantId = (override ?? event.user.tenantId) as TenantId;
53
+ const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
54
+
55
+ const existing = await fetchOne<TemplateResourceRow>(
56
+ db,
57
+ templateResourcesTable,
58
+ eq(templateResourcesTable["tenantId"], tenantId),
59
+ eq(templateResourcesTable["slug"], event.payload.slug),
60
+ eq(templateResourcesTable["kind"], event.payload.kind),
61
+ eq(templateResourcesTable["locale"], event.payload.locale),
62
+ );
63
+
64
+ const fields = {
65
+ slug: event.payload.slug,
66
+ kind: event.payload.kind,
67
+ locale: event.payload.locale,
68
+ content: event.payload.content,
69
+ contentFormat: event.payload.contentFormat,
70
+ variableSchema: JSON.stringify(event.payload.variableSchema),
71
+ linkedResources: JSON.stringify(event.payload.linkedResources),
72
+ scope: "tenant" as const,
73
+ parentTemplateId: event.payload.parentTemplateId ?? null,
74
+ status: event.payload.status,
75
+ };
76
+
77
+ if (existing) {
78
+ const result = await executor.update(
79
+ { id: existing.id, version: existing.version, changes: fields },
80
+ executorUser,
81
+ db,
82
+ );
83
+ if (!result.isSuccess) return result;
84
+ return {
85
+ isSuccess: true as const,
86
+ data: { id: String(existing.id), slug: event.payload.slug, isNew: false },
87
+ };
88
+ }
89
+
90
+ const result = await executor.create({ ...fields, tenantId }, executorUser, db);
91
+ if (!result.isSuccess) return result;
92
+ // @cast-boundary db-row — executor.create returnt Record-row aus
93
+ // INSERT RETURNING; shape { id } ist garantiert weil PK in der
94
+ // Returning-Klausel ist.
95
+ const createdRow = result.data as { id: string | number };
96
+ return {
97
+ isSuccess: true as const,
98
+ data: {
99
+ id: String(createdRow.id),
100
+ slug: event.payload.slug,
101
+ isNew: true,
102
+ },
103
+ };
104
+ },
105
+ });
@@ -0,0 +1,28 @@
1
+ export {
2
+ createTemplateResolverApi,
3
+ type ResolveRequest,
4
+ requireTemplateResolver,
5
+ TemplateNotFoundError,
6
+ type TemplateResolverApi,
7
+ type TemplateResource,
8
+ } from "./api";
9
+ export {
10
+ CONTENT_FORMATS,
11
+ type ContentFormat,
12
+ FALLBACK_LOCALE,
13
+ RENDER_KINDS,
14
+ type RenderKind,
15
+ SYSTEM_TENANT_ID,
16
+ TEMPLATE_SCOPES,
17
+ TEMPLATE_STATUSES,
18
+ type TemplateScope,
19
+ type TemplateStatus,
20
+ } from "./constants";
21
+ export { createTemplateResolverFeature } from "./feature";
22
+ export {
23
+ TEMPLATE_RESOLVER_FEATURE,
24
+ TemplateResolverErrors,
25
+ TemplateResolverHandlers,
26
+ TemplateResolverQueries,
27
+ } from "./qualified-names";
28
+ export { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "./table";
@@ -0,0 +1,24 @@
1
+ // @runtime client
2
+ // Feature name + qualified handler/query names (QN: scope:type:name).
3
+ export const TEMPLATE_RESOLVER_FEATURE = "template-resolver" as const;
4
+
5
+ export const TemplateResolverHandlers = {
6
+ upsertSystem: "template-resolver:write:upsert-system",
7
+ upsertTenant: "template-resolver:write:upsert-tenant",
8
+ publish: "template-resolver:write:publish",
9
+ archive: "template-resolver:write:archive",
10
+ } as const;
11
+
12
+ export const TemplateResolverQueries = {
13
+ findById: "template-resolver:query:find-by-id",
14
+ list: "template-resolver:query:list",
15
+ } as const;
16
+
17
+ export const TemplateResolverErrors = {
18
+ notFound: "template_resource_not_found",
19
+ invalidSlug: "invalid_slug",
20
+ invalidLocale: "invalid_locale",
21
+ systemAdminRequired: "system_admin_required",
22
+ alreadyActive: "template_already_active",
23
+ alreadyArchived: "template_already_archived",
24
+ } as const;
@@ -0,0 +1,67 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createEntity,
4
+ createLongTextField,
5
+ createSelectField,
6
+ createTextField,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { CONTENT_FORMATS, RENDER_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } from "./constants";
9
+
10
+ // TemplateResource — strukturierte Template-Definition mit Tenant-
11
+ // Override-Hierarchie, Locale-Fallback und Resource-Linking via
12
+ // file-foundation. Pro (tenantId, slug, kind, locale) genau eine Row;
13
+ // scope/status/version differenzieren Lifecycle.
14
+ //
15
+ // `variableSchema` + `linkedResources` sind JSON-Strings in longText
16
+ // (Pattern aus compliance-profiles — kein dedizierter jsonbField im
17
+ // createEntity-DSL). App-Layer parsed JSON, persistiert wieder als String.
18
+ export const templateResourceEntity = createEntity({
19
+ table: "read_template_resources",
20
+ fields: {
21
+ slug: createTextField({ required: true }),
22
+ kind: createSelectField({ required: true, options: [...RENDER_KINDS] }),
23
+ locale: createTextField({ required: true }),
24
+ content: createLongTextField({}),
25
+ contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
26
+ variableSchema: createLongTextField({}),
27
+ linkedResources: createLongTextField({}),
28
+ scope: createSelectField({ required: true, options: [...TEMPLATE_SCOPES] }),
29
+ parentTemplateId: createTextField({}),
30
+ status: createSelectField({ required: true, options: [...TEMPLATE_STATUSES] }),
31
+ },
32
+ indexes: [
33
+ {
34
+ unique: true,
35
+ columns: ["tenantId", "slug", "kind", "locale"],
36
+ name: "read_template_resources_unique",
37
+ },
38
+ ],
39
+ });
40
+
41
+ export const templateResourcesTable = buildDrizzleTable(
42
+ "template-resource",
43
+ templateResourceEntity,
44
+ );
45
+
46
+ // Concrete Row-Type — single-source dafür dass die unknown-Werte die
47
+ // Drizzle aus `Record<string, unknown>` liefert genau einmal benannt
48
+ // werden (statt 12× `row["x"] as Y` Casts in Handlern + Resolver).
49
+ export type TemplateResourceRow = {
50
+ readonly id: string | number;
51
+ readonly version: number;
52
+ readonly tenantId: string;
53
+ readonly slug: string;
54
+ readonly kind: string;
55
+ readonly locale: string;
56
+ readonly content: string | null;
57
+ readonly contentFormat: string;
58
+ readonly variableSchema: string | null;
59
+ readonly linkedResources: string | null;
60
+ readonly scope: string;
61
+ readonly parentTemplateId: string | null;
62
+ readonly status: string;
63
+ readonly createdAt: Date;
64
+ readonly updatedAt: Date;
65
+ readonly createdBy: string;
66
+ readonly updatedBy: string;
67
+ };
@@ -412,4 +412,58 @@ describe("text-content :: seedTextBlock", () => {
412
412
  });
413
413
  expect(a.id).toBe(b.id);
414
414
  });
415
+
416
+ // Drift-Documentation: seedTextBlock geht direkt durch den Executor
417
+ // OHNE slugSchema-Validation, set.write läuft DURCH die Validation.
418
+ // Folge: seedTextBlock akzeptiert Slugs mit ":" oder "/" (legal-pages
419
+ // Plattform-Seeds nutzen das für "page:index:hero.title" etc.), aber
420
+ // ein User-Edit derselben Block über set.write würde mit
421
+ // validation_error fail (regex `^[a-z0-9][a-z0-9-]*$`). Drift ist
422
+ // **bewusst** in V.1.3 — seedTextBlock ist system-trusted (boot-fixture,
423
+ // kein User-Input). V.1.4 plant ein echtes `folder`-Field statt
424
+ // `:`-Separator-im-Slug, dann fällt die Drift weg.
425
+ //
426
+ // Dieser Test pinnt den Status quo: Editor-Form via set.write rejected
427
+ // ":"-Slugs auch wenn seedTextBlock sie angelegt hat. Plus-Test
428
+ // verhindert dass jemand silent seedTextBlock-Validation hinzufügt
429
+ // ohne app-side seed-Slugs (z.B. legal-pages-Plattform-Seeds) zu
430
+ // konvertieren.
431
+ test("seedTextBlock + set.write drift: `:`-slugs sind seed-only, set.write rejected sie", async () => {
432
+ // Seed mit `:`-Slug funktioniert (legal-pages-Pattern)
433
+ const seeded = await seedTextBlock(db, {
434
+ tenantId: tenantAdmin.tenantId,
435
+ slug: "page:hero",
436
+ lang: "de",
437
+ title: "Seeded",
438
+ body: "from-seed",
439
+ });
440
+ expect(seeded.id).toBeDefined();
441
+
442
+ // Set.write auf demselben Slug → validation_error (kebab-only regex)
443
+ const error = await stack.http.writeErr(
444
+ TextContentHandlers.set,
445
+ { slug: "page:hero", lang: "de", title: "User-edit", body: "from-write" },
446
+ tenantAdmin,
447
+ );
448
+ expectErrorIncludes(error, "validation_error");
449
+ });
450
+
451
+ test("seedTextBlock + set.write parity: kebab-only Slugs durchlaufen beide Pfade", async () => {
452
+ // Inverse-Test: für kebab-only Slugs (`page-hero`) klappen beide
453
+ // Pfade. App-Builder die Edit-Form-fähige Seeds wollen, müssen
454
+ // kebab-only verwenden (siehe publicstatus/bin/seed-demo.ts).
455
+ await seedTextBlock(db, {
456
+ tenantId: tenantAdmin.tenantId,
457
+ slug: "page-hero",
458
+ lang: "de",
459
+ title: "Seeded",
460
+ body: "from-seed",
461
+ });
462
+ const result = await stack.http.writeOk<{ slug: string; lang: string }>(
463
+ TextContentHandlers.set,
464
+ { slug: "page-hero", lang: "de", title: "User-edit", body: "from-write" },
465
+ tenantAdmin,
466
+ );
467
+ expect(result.slug).toBe("page-hero");
468
+ });
415
469
  });
@@ -49,6 +49,7 @@ export const bySlugQuery = defineQueryHandler({
49
49
  lang: row.lang,
50
50
  title: row.title,
51
51
  body: row.body,
52
+ folder: row.folder,
52
53
  updatedAt: row.updatedAt,
53
54
  };
54
55
  },
@@ -20,6 +20,7 @@ export type TextBlockSummary = {
20
20
  readonly lang: string;
21
21
  readonly title: string;
22
22
  readonly body: string | null;
23
+ readonly folder: string | null;
23
24
  readonly updatedAt: Date;
24
25
  };
25
26
 
@@ -49,6 +50,7 @@ export const byTenantQuery = defineQueryHandler({
49
50
  lang: row.lang,
50
51
  title: row.title,
51
52
  body: row.body,
53
+ folder: row.folder,
52
54
  updatedAt: row.updatedAt,
53
55
  })),
54
56
  };
@@ -11,6 +11,17 @@ const slugSchema = z
11
11
  .max(64)
12
12
  .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
13
13
 
14
+ // Folder-Convention V.1.4: gleiches kebab-Pattern wie slug, optional
15
+ // `/`-Separator für nested folders ("legal/imprint", "page/marketing").
16
+ // Multi-level wird vom Visual-Tree-Grouping flat-rendered bis V.1.5
17
+ // rekursive Hierarchie braucht — dann erweitert sich nur der UI-Render,
18
+ // nicht das Schema.
19
+ const folderSchema = z
20
+ .string()
21
+ .min(1)
22
+ .max(128)
23
+ .regex(/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/, "folder must be kebab-case path");
24
+
14
25
  const langSchema = z
15
26
  .string()
16
27
  .min(2)
@@ -36,6 +47,11 @@ export const setWrite = defineWriteHandler({
36
47
  lang: langSchema,
37
48
  title: z.string().min(1).max(200),
38
49
  body: z.string().max(100_000).nullable(),
50
+ /** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null
51
+ * → root-node (kein Folder). Tree groupt nach diesem Field, slug
52
+ * bleibt flach + kebab-validiert. Beispiele: "page", "legal",
53
+ * "page/marketing". */
54
+ folder: folderSchema.nullable().optional(),
39
55
  /** Optional cross-tenant write — nur für SystemAdmin. Typischer
40
56
  * use-case: legal-pages-Edit-UI lässt SystemAdmin auf
41
57
  * SYSTEM_TENANT_ID schreiben (sonst landet der text auf seinem
@@ -78,6 +94,11 @@ export const setWrite = defineWriteHandler({
78
94
  eq(textBlocksTable["lang"], event.payload.lang),
79
95
  );
80
96
 
97
+ // V.1.4 folder: optional + null erlaubt (root-node). Optional-Chain
98
+ // mapped undefined → null damit drizzle nullable-column konsistent
99
+ // schreibt (sonst SQL-default kicked-in vs. explicit-null Unterschied).
100
+ const folder = event.payload.folder ?? null;
101
+
81
102
  if (existing) {
82
103
  const result = await executor.update(
83
104
  {
@@ -86,6 +107,7 @@ export const setWrite = defineWriteHandler({
86
107
  changes: {
87
108
  title: event.payload.title,
88
109
  body: event.payload.body,
110
+ folder,
89
111
  },
90
112
  },
91
113
  executorUser,
@@ -104,6 +126,7 @@ export const setWrite = defineWriteHandler({
104
126
  lang: event.payload.lang,
105
127
  title: event.payload.title,
106
128
  body: event.payload.body,
129
+ folder,
107
130
  tenantId,
108
131
  },
109
132
  executorUser,
@@ -24,6 +24,11 @@ export type SeedTextBlockOptions = {
24
24
  readonly lang: string;
25
25
  readonly title: string;
26
26
  readonly body?: string | null;
27
+ /** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null =
28
+ * root-node. Seed-Pfad bypasst slugSchema/folderSchema-Validation
29
+ * (system-trusted), aber App-Builder sollten kebab-only nutzen damit
30
+ * set.write die geseedete Row später überschreiben kann. */
31
+ readonly folder?: string | null;
27
32
  readonly by?: SessionUser;
28
33
  };
29
34
 
@@ -50,12 +55,14 @@ export async function seedTextBlock(
50
55
  eq(textBlocksTable["lang"], opts.lang),
51
56
  );
52
57
 
58
+ const folder = opts.folder ?? null;
59
+
53
60
  if (existing) {
54
61
  const result = await executor.update(
55
62
  {
56
63
  id: existing.id,
57
64
  version: existing.version,
58
- changes: { title: opts.title, body: opts.body ?? null },
65
+ changes: { title: opts.title, body: opts.body ?? null, folder },
59
66
  },
60
67
  by,
61
68
  tdb,
@@ -72,6 +79,7 @@ export async function seedTextBlock(
72
79
  lang: opts.lang,
73
80
  title: opts.title,
74
81
  body: opts.body ?? null,
82
+ folder,
75
83
  tenantId: opts.tenantId,
76
84
  },
77
85
  by,
@@ -16,6 +16,11 @@ export const textBlockEntity = createEntity({
16
16
  lang: createTextField({ required: true }),
17
17
  title: createTextField({ required: true }),
18
18
  body: createTextField({}),
19
+ // V.1.4: explicit folder-Hierarchie statt `:`-Encoding im Slug.
20
+ // Visual-Tree gruppiert nach diesem Field; null/undefined → root.
21
+ // Pflicht-Constraint im set.write-Schema (kebab-only wie slug,
22
+ // damit App-Builder konsistente Naming-Conventions haben).
23
+ folder: createTextField({}),
19
24
  },
20
25
  indexes: [
21
26
  { unique: true, columns: ["tenantId", "slug", "lang"], name: "read_text_blocks_unique" },
@@ -38,6 +43,7 @@ export type TextBlockRow = {
38
43
  readonly lang: string;
39
44
  readonly title: string;
40
45
  readonly body: string | null;
46
+ readonly folder: string | null;
41
47
  readonly createdAt: Date;
42
48
  readonly updatedAt: Date;
43
49
  readonly createdBy: string;
@@ -0,0 +1,125 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ createStaticLocaleResolver,
5
+ LocaleProvider,
6
+ PrimitivesProvider,
7
+ } from "@cosmicdrift/kumiko-renderer";
8
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
9
+ import { render, screen } from "@testing-library/react";
10
+ import type { ReactNode } from "react";
11
+ import { describe, expect, test, vi } from "vitest";
12
+ import { textContentClient } from "../client-plugin";
13
+
14
+ // Mock-Setup für die drei externen Hooks die TextContentEditor benutzt.
15
+ // vi.mock + vi.fn() erlaubt pro-Test verschiedene Roles + Dispatcher-
16
+ // Antworten. Memory `[Keine Fake-Tests]`: wir testen echtes Rendering,
17
+ // nicht nur die canWrite-Bedingung.
18
+ vi.mock("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
19
+ useShellUser: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("@cosmicdrift/kumiko-renderer", async () => {
23
+ const actual = await vi.importActual<typeof import("@cosmicdrift/kumiko-renderer")>(
24
+ "@cosmicdrift/kumiko-renderer",
25
+ );
26
+ return {
27
+ ...actual,
28
+ useDispatcher: vi.fn(() => ({
29
+ write: vi.fn(),
30
+ query: vi.fn(),
31
+ })),
32
+ useQuery: vi.fn(() => ({
33
+ data: { slug: "imprint", lang: "de", title: "Impressum", body: "Inhalt" },
34
+ loading: false,
35
+ error: null,
36
+ refetch: vi.fn(),
37
+ })),
38
+ };
39
+ });
40
+
41
+ import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
42
+
43
+ const TARGET = {
44
+ featureId: "text-content",
45
+ action: "edit",
46
+ args: { slug: "imprint", lang: "de" },
47
+ } as const;
48
+
49
+ function getEditor() {
50
+ const def = textContentClient();
51
+ const Editor = def.resolvers?.["text-content:edit"];
52
+ if (!Editor) throw new Error("Editor not registered");
53
+ return Editor;
54
+ }
55
+
56
+ const localeResolver = createStaticLocaleResolver();
57
+
58
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
59
+ return (
60
+ <LocaleProvider resolver={localeResolver}>
61
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
62
+ </LocaleProvider>
63
+ );
64
+ }
65
+
66
+ describe("TextContentEditor — role-based write-access", () => {
67
+ test("TenantAdmin sieht Save-Button + editable inputs", () => {
68
+ vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
69
+ const Editor = getEditor();
70
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
71
+
72
+ // Save-Button gerendert (canWrite=true → Button.type=submit)
73
+ const saveButton = screen.getByRole("button", { name: /speichern/i });
74
+ expect(saveButton).toBeTruthy();
75
+ expect(saveButton.hasAttribute("disabled")).toBe(false);
76
+
77
+ // Read-only-Banner darf NICHT erscheinen
78
+ expect(screen.queryByText(/Read-only/)).toBeNull();
79
+ });
80
+
81
+ test("SystemAdmin sieht Save-Button (alternative write-role)", () => {
82
+ vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["SystemAdmin"] });
83
+ const Editor = getEditor();
84
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
85
+
86
+ expect(screen.getByRole("button", { name: /speichern/i })).toBeTruthy();
87
+ expect(screen.queryByText(/Read-only/)).toBeNull();
88
+ });
89
+
90
+ test("Editor-Role sieht Read-only-Banner + KEIN Save-Button", () => {
91
+ // Das ist der advisor-flagged Pfad — bisher unverifiziert. Editor-
92
+ // Role hat in publicstatus's Schema Zugriff auf visual-Workspace +
93
+ // by-slug-query (read), aber NICHT auf set.write. UI muss das
94
+ // explizit kommunizieren statt 403 erst beim save zu zeigen.
95
+ vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["Editor"] });
96
+ const Editor = getEditor();
97
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
98
+
99
+ expect(screen.getByText(/Read-only/)).toBeTruthy();
100
+ expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
101
+ });
102
+
103
+ test("Admin-Role (publicstatus-Convention, ohne TenantAdmin-dual-Tag) sieht Read-only-Banner", () => {
104
+ // Apps die NUR `Admin` (ohne `TenantAdmin`) im JWT haben, kriegen
105
+ // read-only. Dokumentiert das Dual-Role-Pattern aus publicstatus:
106
+ // wer "Admin" allein hat (Memory `[Role-Naming-Drift]`), muss
107
+ // explizit auch "TenantAdmin" im JWT tragen damit der Editor
108
+ // schreiben darf.
109
+ vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["Admin"] });
110
+ const Editor = getEditor();
111
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
112
+
113
+ expect(screen.getByText(/Read-only/)).toBeTruthy();
114
+ expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
115
+ });
116
+
117
+ test("Logged-out (useShellUser=undefined) sieht Read-only-Banner", () => {
118
+ vi.mocked(useShellUser).mockReturnValue(undefined);
119
+ const Editor = getEditor();
120
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
121
+
122
+ expect(screen.getByText(/Read-only/)).toBeTruthy();
123
+ expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
124
+ });
125
+ });