@cosmicdrift/kumiko-bundled-features 0.57.2 → 0.60.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 (53) hide show
  1. package/package.json +10 -7
  2. package/src/auth-email-password/i18n.ts +2 -0
  3. package/src/config/__tests__/app-override-visibility.integration.test.ts +6 -0
  4. package/src/config/__tests__/backing-secrets.integration.test.ts +38 -0
  5. package/src/config/__tests__/inherited-redaction.integration.test.ts +29 -0
  6. package/src/config/handlers/cascade.query.ts +1 -3
  7. package/src/config/handlers/readiness.query.ts +6 -0
  8. package/src/config/handlers/values.query.ts +1 -3
  9. package/src/config/read-redaction.ts +13 -2
  10. package/src/custom-fields/__tests__/feature.test.ts +57 -4
  11. package/src/custom-fields/feature.ts +19 -4
  12. package/src/files-provider-s3/__tests__/s3-provider.test.ts +61 -1
  13. package/src/files-provider-s3/s3-provider.ts +9 -3
  14. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +92 -1
  15. package/src/managed-pages/handlers/set.write.ts +14 -4
  16. package/src/subscription-stripe/__tests__/runtime.test.ts +59 -5
  17. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +105 -0
  18. package/src/subscription-stripe/feature.ts +2 -1
  19. package/src/tags/__tests__/drift.test.ts +46 -0
  20. package/src/tags/__tests__/feature.test.ts +155 -0
  21. package/src/tags/__tests__/tags.integration.test.ts +251 -0
  22. package/src/tags/aggregate-id.ts +23 -0
  23. package/src/tags/constants.ts +37 -0
  24. package/src/tags/entity.ts +35 -0
  25. package/src/tags/executor.ts +11 -0
  26. package/src/tags/feature.ts +75 -0
  27. package/src/tags/handlers/assign-tag.write.ts +48 -0
  28. package/src/tags/handlers/create-tag.write.ts +23 -0
  29. package/src/tags/handlers/remove-tag.write.ts +34 -0
  30. package/src/tags/index.ts +30 -0
  31. package/src/tags/schemas.ts +20 -0
  32. package/src/template-resolver/README.md +22 -0
  33. package/src/template-resolver/__tests__/conformance.integration.test.ts +79 -0
  34. package/src/template-resolver/testing.ts +192 -0
  35. package/src/tier-engine/__tests__/drift.test.ts +4 -0
  36. package/src/tier-engine/__tests__/resolver.integration.test.ts +30 -0
  37. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +118 -0
  38. package/src/tier-engine/constants.ts +13 -0
  39. package/src/tier-engine/entity.ts +5 -0
  40. package/src/tier-engine/feature.ts +51 -3
  41. package/src/tier-engine/handlers/get-tenant-tier.query.ts +36 -0
  42. package/src/tier-engine/handlers/set-tenant-tier.write.ts +99 -0
  43. package/src/tier-engine/i18n.ts +39 -0
  44. package/src/tier-engine/web/client-plugin.tsx +27 -0
  45. package/src/tier-engine/web/index.ts +8 -0
  46. package/src/tier-engine/web/tier-admin-screen.tsx +161 -0
  47. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  48. package/src/user-data-rights/deletion-token.ts +9 -3
  49. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +22 -3
  50. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +37 -43
  51. package/src/user-profile/__tests__/profile-screen.test.tsx +61 -3
  52. package/src/user-profile/i18n.ts +2 -3
  53. package/src/user-profile/web/profile-screen.tsx +29 -5
@@ -0,0 +1,23 @@
1
+ import { v5 as uuidv5 } from "uuid";
2
+
3
+ // Fixed UUID namespace for tag-assignment aggregate-id derivation. Generated
4
+ // once (2026-06-18), frozen: changing it would re-key every existing assignment
5
+ // stream → broken replay. Drift-pinned in __tests__.
6
+ const TAG_ASSIGNMENT_NAMESPACE = "a7f3c9d2-1b4e-4c8a-9f6d-2e5b8a1c3f7d";
7
+
8
+ /**
9
+ * Deterministic aggregate-id for a tag-assignment from the tuple
10
+ * (tenantId, tagId, entityType, entityId). Exactly one aggregate exists per
11
+ * (tenant, tag, entity), so assigning the same tag twice collides on the same
12
+ * stream → version_conflict instead of a duplicate row. The assign handler
13
+ * pre-checks existence to keep re-assign idempotent (TX-safe).
14
+ */
15
+ // @wrapper-known uuid-domain
16
+ export function tagAssignmentAggregateId(
17
+ tenantId: string,
18
+ tagId: string,
19
+ entityType: string,
20
+ entityId: string,
21
+ ): string {
22
+ return uuidv5(`${tenantId}|${tagId}|${entityType}|${entityId}`, TAG_ASSIGNMENT_NAMESPACE);
23
+ }
@@ -0,0 +1,37 @@
1
+ // @runtime client
2
+ // tags bundle constants — feature-name + qualified handler/query names.
3
+ //
4
+ // Spec: kumiko-platform/docs/plans/features/tags.md
5
+ // C#1 design: money-horse/docs/plans/cashcolt-vertragspakete.md
6
+
7
+ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
8
+
9
+ export const TAGS_FEATURE_NAME = "tags";
10
+
11
+ // Qualified handler names (QN format: scope:type:name). Clients reference the
12
+ // object instead of magic strings (mirror custom-fields' Handlers/Queries).
13
+ export const TagsHandlers = {
14
+ createTag: "tags:write:create-tag",
15
+ assignTag: "tags:write:assign-tag",
16
+ removeTag: "tags:write:remove-tag",
17
+ } as const;
18
+
19
+ export const TagsQueries = {
20
+ // defineEntityListHandler("tag", ...) → "tag:list", qualified by the feature
21
+ // to "tags:query:tag:list".
22
+ tagList: "tags:query:tag:list",
23
+ assignmentList: "tags:query:tag-assignment:list",
24
+ } as const;
25
+
26
+ // Default RBAC for every tag write/read path. Tags are a low-sensitivity
27
+ // collaboration tool, so both tenant roles may use them. Apps with their own
28
+ // role vocabulary (e.g. "Admin"/"Editor") override via createTagsFeature({ roles }),
29
+ // or adopt the host's whole access model with createTagsFeature({ access }) —
30
+ // otherwise the hard-wired QNs are access_denied for their users.
31
+ export const DEFAULT_TAG_ROLES = ["TenantAdmin", "TenantMember"] as const;
32
+
33
+ // The default access rule applied to every tag handler when the app passes
34
+ // neither `access` nor `roles`. createTagsFeature({ access: { openToAll: true } })
35
+ // makes tagging reachable for any authenticated tenant user — matching apps
36
+ // whose other handlers are openToAll rather than role-gated.
37
+ export const DEFAULT_TAG_ACCESS: AccessRule = { roles: DEFAULT_TAG_ROLES };
@@ -0,0 +1,35 @@
1
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // tag — per-tenant tag catalog. Event-sourced entity (create/rename/delete via
4
+ // the standard executor); the framework projects `read_tags` from its own CRUD
5
+ // events. tenantId is a base column set by the framework → tenant-scoped.
6
+ export const tagEntity = createEntity({
7
+ table: "read_tags",
8
+ fields: {
9
+ name: createTextField({ required: true, maxLength: 64 }),
10
+ // Optional UI hint (hex or token). No enforcement — purely for rendering.
11
+ color: createTextField({ maxLength: 32 }),
12
+ },
13
+ });
14
+
15
+ // tag-assignment — host-agnostic join row keyed by (entityType, entityId). This
16
+ // is the event-sourced, feature-owned projection that replaces a relational
17
+ // pivot+JOIN: the framework projects `read_tag_assignments` from this entity's
18
+ // own CRUD events, so tagging needs NO column on the host entity.
19
+ //
20
+ // The assignment's aggregate-id is derived deterministically from
21
+ // (tenantId, tagId, entityType, entityId) — see aggregate-id.ts — so there is
22
+ // exactly one row per (tag, entity) and assign is idempotent.
23
+ //
24
+ // Cross-entity views compose in the read-layer (no JOIN):
25
+ // - tags of an entity → list assignments filter { field: "entityId", op: "eq" }
26
+ // - entities with a tag → list assignments filter { field: "tagId", op: "eq" }
27
+ export const tagAssignmentEntity = createEntity({
28
+ table: "read_tag_assignments",
29
+ fields: {
30
+ tagId: createTextField({ required: true, maxLength: 64 }),
31
+ entityType: createTextField({ required: true, maxLength: 64 }),
32
+ // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
33
+ entityId: createTextField({ required: true, maxLength: 128 }),
34
+ },
35
+ });
@@ -0,0 +1,11 @@
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { tagAssignmentEntity, tagEntity } from "./entity";
3
+
4
+ // Shared executors for the tag + tag-assignment write-handlers.
5
+ // createEntityExecutor is side-effect-free; instantiating once keeps the
6
+ // table+executor pair in one place instead of rebuilding it per handler module.
7
+ export const { executor: tagExecutor } = createEntityExecutor("tag", tagEntity);
8
+ export const { executor: tagAssignmentExecutor } = createEntityExecutor(
9
+ "tag-assignment",
10
+ tagAssignmentEntity,
11
+ );
@@ -0,0 +1,75 @@
1
+ // tags — generic, host-agnostic tagging for ANY entity.
2
+ //
3
+ // **Event-sourced, not relational.** There is no pivot table with foreign keys
4
+ // and JOINs. The feature owns two event-sourced entities:
5
+ // 1. `tag` (read_tags) — per-tenant tag catalog.
6
+ // 2. `tag-assignment` (read_tag_assignments) — join rows keyed by
7
+ // (entityType, entityId), with a deterministic aggregate-id so assign is
8
+ // idempotent. The framework projects both tables from their own CRUD
9
+ // events; no host column and no hand-written MSP are needed.
10
+ //
11
+ // Cross-entity views compose in the read-layer (no JOIN) by listing
12
+ // tag-assignments filtered on entityId (tags of an entity) or tagId (entities
13
+ // with a tag). See entity.ts.
14
+ //
15
+ // v1 scope: create-tag, assign-tag, remove-tag, list tags, list assignments.
16
+ // Deferred: rename/delete-tag, optional host-projection decoration
17
+ // (`wireTagsFor`), search indexing, user-data-rights anonymization.
18
+
19
+ import {
20
+ type AccessRule,
21
+ defineEntityListHandler,
22
+ defineFeature,
23
+ type FeatureRegistrar,
24
+ } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants";
26
+ import { tagAssignmentEntity, tagEntity } from "./entity";
27
+ import { createAssignTagHandler } from "./handlers/assign-tag.write";
28
+ import { createCreateTagHandler } from "./handlers/create-tag.write";
29
+ import { createRemoveTagHandler } from "./handlers/remove-tag.write";
30
+
31
+ function registerTags(r: FeatureRegistrar<typeof TAGS_FEATURE_NAME>, access: AccessRule): void {
32
+ r.describe(
33
+ "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }).",
34
+ );
35
+
36
+ r.entity("tag", tagEntity);
37
+ r.entity("tag-assignment", tagAssignmentEntity);
38
+
39
+ r.writeHandler(createCreateTagHandler(access));
40
+ r.writeHandler(createAssignTagHandler(access));
41
+ r.writeHandler(createRemoveTagHandler(access));
42
+
43
+ r.queryHandler(defineEntityListHandler("tag", tagEntity, { access }));
44
+ r.queryHandler(defineEntityListHandler("tag-assignment", tagAssignmentEntity, { access }));
45
+ }
46
+
47
+ export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
48
+ registerTags(r, DEFAULT_TAG_ACCESS),
49
+ );
50
+
51
+ export type TagsFeatureOptions = {
52
+ /** Access rule for all tag write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
53
+ * Adopt the host's model — e.g. { openToAll: true } when the host lets any
54
+ * authenticated tenant user tag (like the rest of its handlers), or
55
+ * { roles: ["Admin"] } for a custom role vocabulary. Takes precedence over `roles`. */
56
+ readonly access?: AccessRule;
57
+ /** Shorthand for { access: { roles } }. Ignored when `access` is set. */
58
+ readonly roles?: readonly string[];
59
+ };
60
+
61
+ function resolveAccess(opts: TagsFeatureOptions): AccessRule {
62
+ if (opts.access !== undefined) return opts.access;
63
+ if (opts.roles !== undefined) return { roles: opts.roles };
64
+ return DEFAULT_TAG_ACCESS;
65
+ }
66
+
67
+ // Backwards-compat / options wrapper. Without options returns the module-level
68
+ // singleton (no rebuild). access/roles build a fresh feature-definition.
69
+ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFeature {
70
+ if (opts.access === undefined && opts.roles === undefined) {
71
+ return tagsFeature;
72
+ }
73
+ const access = resolveAccess(opts);
74
+ return defineFeature(TAGS_FEATURE_NAME, (r) => registerTags(r, access));
75
+ }
@@ -0,0 +1,48 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { tagAssignmentAggregateId } from "../aggregate-id";
3
+ import { DEFAULT_TAG_ACCESS } from "../constants";
4
+ import { tagAssignmentExecutor } from "../executor";
5
+ import { type AssignTagPayload, assignTagPayloadSchema } from "../schemas";
6
+
7
+ // assign-tag — links a tag to a host entity by (entityType, entityId). The
8
+ // assignment id is deterministic, so the row is unique per (tag, entity).
9
+ //
10
+ // Idempotency: a re-assign hits the existing stream and would version_conflict,
11
+ // which leaves the transaction aborted — so we pre-check existence and return
12
+ // success when the assignment is already present (the requested end state). A
13
+ // concurrent first-time race still version_conflicts (409); acceptable, since
14
+ // assigning is a low-frequency UI action.
15
+ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
16
+ return {
17
+ name: "assign-tag",
18
+ schema: assignTagPayloadSchema,
19
+ access,
20
+ handler: async (event, ctx) => {
21
+ const payload = event.payload as AssignTagPayload; // @cast-boundary engine-payload
22
+ const id = tagAssignmentAggregateId(
23
+ event.user.tenantId,
24
+ payload.tagId,
25
+ payload.entityType,
26
+ payload.entityId,
27
+ );
28
+
29
+ const existing = await tagAssignmentExecutor.detail({ id }, event.user, ctx.db);
30
+ if (existing) {
31
+ return { isSuccess: true as const, data: { id } };
32
+ }
33
+
34
+ return tagAssignmentExecutor.create(
35
+ {
36
+ id,
37
+ tagId: payload.tagId,
38
+ entityType: payload.entityType,
39
+ entityId: payload.entityId,
40
+ },
41
+ event.user,
42
+ ctx.db,
43
+ );
44
+ },
45
+ };
46
+ }
47
+
48
+ export const assignTagHandler: WriteHandlerDef = createAssignTagHandler();
@@ -0,0 +1,23 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { DEFAULT_TAG_ACCESS } from "../constants";
3
+ import { tagExecutor } from "../executor";
4
+ import { type CreateTagPayload, createTagPayloadSchema } from "../schemas";
5
+
6
+ // create-tag — adds a tag to the tenant's catalog. The framework mints a fresh
7
+ // UUIDv7 id (no explicit id passed). Tag names are not unique by design: the
8
+ // catalog is a free list and dedup is a UI concern (autocomplete from existing
9
+ // tags). Rename/delete are deferred to a later iteration (v1 scope: create,
10
+ // assign, remove, list).
11
+ export function createCreateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
12
+ return {
13
+ name: "create-tag",
14
+ schema: createTagPayloadSchema,
15
+ access,
16
+ handler: async (event, ctx) => {
17
+ const payload = event.payload as CreateTagPayload; // @cast-boundary engine-payload
18
+ return tagExecutor.create(payload, event.user, ctx.db);
19
+ },
20
+ };
21
+ }
22
+
23
+ export const createTagHandler: WriteHandlerDef = createCreateTagHandler();
@@ -0,0 +1,34 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { tagAssignmentAggregateId } from "../aggregate-id";
3
+ import { DEFAULT_TAG_ACCESS } from "../constants";
4
+ import { tagAssignmentExecutor } from "../executor";
5
+ import { type RemoveTagPayload, removeTagPayloadSchema } from "../schemas";
6
+
7
+ // remove-tag — unlinks a tag from a host entity. Idempotent: removing an
8
+ // assignment that doesn't exist is already the requested end state (not
9
+ // assigned), so we pre-check and return success without a delete.
10
+ export function createRemoveTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
11
+ return {
12
+ name: "remove-tag",
13
+ schema: removeTagPayloadSchema,
14
+ access,
15
+ handler: async (event, ctx) => {
16
+ const payload = event.payload as RemoveTagPayload; // @cast-boundary engine-payload
17
+ const id = tagAssignmentAggregateId(
18
+ event.user.tenantId,
19
+ payload.tagId,
20
+ payload.entityType,
21
+ payload.entityId,
22
+ );
23
+
24
+ const existing = await tagAssignmentExecutor.detail({ id }, event.user, ctx.db);
25
+ if (!existing) {
26
+ return { isSuccess: true as const, data: { id } };
27
+ }
28
+
29
+ return tagAssignmentExecutor.delete({ id }, event.user, ctx.db);
30
+ },
31
+ };
32
+ }
33
+
34
+ export const removeTagHandler: WriteHandlerDef = createRemoveTagHandler();
@@ -0,0 +1,30 @@
1
+ export { tagAssignmentAggregateId } from "./aggregate-id";
2
+ export {
3
+ DEFAULT_TAG_ACCESS,
4
+ DEFAULT_TAG_ROLES,
5
+ TAGS_FEATURE_NAME,
6
+ TagsHandlers,
7
+ TagsQueries,
8
+ } from "./constants";
9
+ export { tagAssignmentEntity, tagEntity } from "./entity";
10
+ export { createTagsFeature, type TagsFeatureOptions, tagsFeature } from "./feature";
11
+ export {
12
+ assignTagHandler,
13
+ createAssignTagHandler,
14
+ } from "./handlers/assign-tag.write";
15
+ export {
16
+ createCreateTagHandler,
17
+ createTagHandler,
18
+ } from "./handlers/create-tag.write";
19
+ export {
20
+ createRemoveTagHandler,
21
+ removeTagHandler,
22
+ } from "./handlers/remove-tag.write";
23
+ export {
24
+ type AssignTagPayload,
25
+ assignTagPayloadSchema,
26
+ type CreateTagPayload,
27
+ createTagPayloadSchema,
28
+ type RemoveTagPayload,
29
+ removeTagPayloadSchema,
30
+ } from "./schemas";
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+
3
+ export const createTagPayloadSchema = z.object({
4
+ name: z.string().min(1).max(64),
5
+ color: z.string().max(32).optional(),
6
+ });
7
+ export type CreateTagPayload = z.infer<typeof createTagPayloadSchema>;
8
+
9
+ // assign + remove share the (tag, entity) reference shape.
10
+ const entityTagRef = {
11
+ tagId: z.string().min(1).max(64),
12
+ entityType: z.string().min(1).max(64),
13
+ entityId: z.string().min(1).max(128),
14
+ } as const;
15
+
16
+ export const assignTagPayloadSchema = z.object(entityTagRef);
17
+ export type AssignTagPayload = z.infer<typeof assignTagPayloadSchema>;
18
+
19
+ export const removeTagPayloadSchema = z.object(entityTagRef);
20
+ export type RemoveTagPayload = z.infer<typeof removeTagPayloadSchema>;
@@ -81,6 +81,28 @@ archive ───────► status: "archived"
81
81
 
82
82
  Resolver returnt **nur** Templates mit `status: "active"`. draft/archived werden ignoriert.
83
83
 
84
+ ## Consumer Conformance
85
+
86
+ Plugins and features that call `resolveTemplate` can verify correct edge-case handling:
87
+
88
+ ```typescript
89
+ import { describe, test } from "bun:test";
90
+ import { runTemplateConsumerConformance } from "@cosmicdrift/kumiko-bundled-features/template-resolver/testing";
91
+
92
+ describe("my-mail-renderer :: template-resolver conformance", () => {
93
+ runTemplateConsumerConformance(
94
+ test,
95
+ {
96
+ resolve: (args) => templateResolver.resolveTemplate(args),
97
+ resolveResources: async (template) => resolveLinkedResources(ctx, template),
98
+ },
99
+ { getDb: () => db, tenantId: ctx.user.tenantId },
100
+ );
101
+ });
102
+ ```
103
+
104
+ The harness checks `TemplateNotFoundError` propagation, locale-fallback, and (when `resolveResources` is provided) missing resource keys.
105
+
84
106
  ## Out-of-Scope
85
107
 
86
108
  - Rendering (Markdown/MJML → HTML/PDF) — siehe `renderer-foundation`
@@ -0,0 +1,79 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ setupTestStack,
5
+ type TestStack,
6
+ unsafeCreateEntityTable,
7
+ } from "@cosmicdrift/kumiko-framework/stack";
8
+ import { createTemplateResolverApi, TemplateNotFoundError } from "../api";
9
+ import { createTemplateResolverFeature } from "../feature";
10
+ import { templateResourceEntity } from "../table";
11
+ import {
12
+ assertConsumerHandlesNotFound,
13
+ runTemplateConsumerConformance,
14
+ type TemplateConsumer,
15
+ } from "../testing";
16
+
17
+ const TENANT_A = "11111111-1111-4111-8111-111111111111";
18
+
19
+ let stack: TestStack;
20
+ let db: DbConnection;
21
+
22
+ const feature = createTemplateResolverFeature();
23
+
24
+ beforeAll(async () => {
25
+ stack = await setupTestStack({ features: [feature] });
26
+ db = stack.db;
27
+ await unsafeCreateEntityTable(db, templateResourceEntity);
28
+ });
29
+
30
+ afterAll(async () => {
31
+ await stack.cleanup();
32
+ });
33
+
34
+ describe("template-resolver :: conformance harness", () => {
35
+ const conformantConsumer: TemplateConsumer = {
36
+ resolve: (args) => createTemplateResolverApi(db).resolveTemplate(args),
37
+ resolveResources: async (template) => {
38
+ const resolved: Record<string, string> = {};
39
+ for (const key of Object.keys(template.linkedResources)) {
40
+ resolved[key] = `placeholder:${key}`;
41
+ }
42
+ return resolved;
43
+ },
44
+ };
45
+
46
+ describe("positive control — direct API consumer", () => {
47
+ runTemplateConsumerConformance(test, conformantConsumer, {
48
+ getDb: () => db,
49
+ tenantId: TENANT_A,
50
+ });
51
+ });
52
+
53
+ test("harness detects non-conformant not-found handling", async () => {
54
+ const badConsumer: TemplateConsumer = {
55
+ resolve: async () => {
56
+ throw new Error("generic failure instead of TemplateNotFoundError");
57
+ },
58
+ };
59
+
60
+ await expect(
61
+ assertConsumerHandlesNotFound(badConsumer, { getDb: () => db, tenantId: TENANT_A }),
62
+ ).rejects.toThrow("expected TemplateNotFoundError, received Error");
63
+ });
64
+
65
+ test("conformant consumer propagates TemplateNotFoundError", async () => {
66
+ const apiConsumer: TemplateConsumer = {
67
+ resolve: (args) => createTemplateResolverApi(db).resolveTemplate(args),
68
+ };
69
+
70
+ await expect(
71
+ apiConsumer.resolve({
72
+ tenantId: TENANT_A,
73
+ slug: "conformance-not-found-slug",
74
+ kind: "mail-html",
75
+ locale: "de",
76
+ }),
77
+ ).rejects.toBeInstanceOf(TemplateNotFoundError);
78
+ });
79
+ });
@@ -0,0 +1,192 @@
1
+ // Test-only helpers for template-resolver consumers. Import via
2
+ // `@cosmicdrift/kumiko-bundled-features/template-resolver/testing`.
3
+ // No bun:test here — callers register cases with their own test runner.
4
+
5
+ import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
6
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { ResolveRequest, TemplateResource } from "./api";
8
+ import { TemplateNotFoundError } from "./api";
9
+ import {
10
+ type ContentFormat,
11
+ FALLBACK_LOCALE,
12
+ type RenderKind,
13
+ SYSTEM_TENANT_ID,
14
+ type TemplateScope,
15
+ type TemplateStatus,
16
+ } from "./constants";
17
+ import { templateResourcesTable } from "./table";
18
+
19
+ export type TemplateConsumer = {
20
+ readonly resolve: (args: ResolveRequest) => Promise<TemplateResource>;
21
+ readonly resolveResources?: (template: TemplateResource) => Promise<Record<string, string>>;
22
+ };
23
+
24
+ export type TemplateConsumerConformanceOptions = {
25
+ readonly getDb: () => DbConnection;
26
+ readonly tenantId: string;
27
+ };
28
+
29
+ export type ConformanceTestRegistrar = (name: string, fn: () => Promise<void>) => void;
30
+
31
+ export class ConformanceAssertionError extends Error {
32
+ constructor(message: string) {
33
+ super(message);
34
+ this.name = "ConformanceAssertionError";
35
+ }
36
+ }
37
+
38
+ type SeedTemplateArgs = {
39
+ tenantId: string;
40
+ slug: string;
41
+ kind: RenderKind;
42
+ locale: string;
43
+ scope: TemplateScope;
44
+ status?: TemplateStatus;
45
+ content?: string;
46
+ contentFormat?: ContentFormat;
47
+ variableSchema?: Record<string, unknown>;
48
+ linkedResources?: Record<string, string>;
49
+ parentTemplateId?: string;
50
+ };
51
+
52
+ async function seedTemplate(db: DbConnection, args: SeedTemplateArgs): Promise<void> {
53
+ await insertOne(db, templateResourcesTable, {
54
+ tenantId: args.tenantId,
55
+ slug: args.slug,
56
+ kind: args.kind,
57
+ locale: args.locale,
58
+ scope: args.scope,
59
+ status: args.status ?? "active",
60
+ content: args.content ?? `content for ${args.slug} (${args.locale})`,
61
+ contentFormat: args.contentFormat ?? "markdown",
62
+ variableSchema: JSON.stringify(args.variableSchema ?? {}),
63
+ linkedResources: JSON.stringify(args.linkedResources ?? {}),
64
+ parentTemplateId: args.parentTemplateId ?? null,
65
+ createdBy: "conformance",
66
+ updatedBy: "conformance",
67
+ });
68
+ }
69
+
70
+ export async function assertConsumerHandlesNotFound(
71
+ consumer: TemplateConsumer,
72
+ opts: TemplateConsumerConformanceOptions,
73
+ ): Promise<void> {
74
+ const { tenantId } = opts;
75
+ let rejected = false;
76
+ let err: unknown;
77
+ try {
78
+ await consumer.resolve({
79
+ tenantId,
80
+ slug: "conformance-not-found-slug",
81
+ kind: "mail-html",
82
+ locale: "de",
83
+ });
84
+ } catch (e) {
85
+ rejected = true;
86
+ err = e;
87
+ }
88
+ if (!rejected) {
89
+ throw new ConformanceAssertionError("expected resolve to reject with TemplateNotFoundError");
90
+ }
91
+ if (!(err instanceof TemplateNotFoundError)) {
92
+ const label = err instanceof Error ? err.constructor.name : typeof err;
93
+ throw new ConformanceAssertionError(`expected TemplateNotFoundError, received ${label}`);
94
+ }
95
+ }
96
+
97
+ export async function assertConsumerRespectsLocaleFallback(
98
+ consumer: TemplateConsumer,
99
+ opts: TemplateConsumerConformanceOptions,
100
+ ): Promise<void> {
101
+ const db = opts.getDb();
102
+ const { tenantId } = opts;
103
+ const slug = `conformance-fallback-${crypto.randomUUID()}`;
104
+ await seedTemplate(db, {
105
+ tenantId: SYSTEM_TENANT_ID,
106
+ slug,
107
+ kind: "notification",
108
+ locale: FALLBACK_LOCALE,
109
+ scope: "system",
110
+ content: "conformance-fallback-content",
111
+ });
112
+ const result = await consumer.resolve({
113
+ tenantId,
114
+ slug,
115
+ kind: "notification",
116
+ locale: "tr",
117
+ });
118
+ if (result.locale !== FALLBACK_LOCALE) {
119
+ throw new ConformanceAssertionError(
120
+ `expected locale ${FALLBACK_LOCALE}, received ${result.locale}`,
121
+ );
122
+ }
123
+ if (result.content !== "conformance-fallback-content") {
124
+ throw new ConformanceAssertionError(`expected fallback content, received ${result.content}`);
125
+ }
126
+ }
127
+
128
+ export async function assertConsumerHandlesMissingResourceKeys(
129
+ consumer: TemplateConsumer,
130
+ opts: TemplateConsumerConformanceOptions,
131
+ ): Promise<void> {
132
+ const resolveResources = consumer.resolveResources;
133
+ if (!resolveResources) {
134
+ throw new ConformanceAssertionError(
135
+ "assertConsumerHandlesMissingResourceKeys requires consumer.resolveResources",
136
+ );
137
+ }
138
+
139
+ const db = opts.getDb();
140
+ const { tenantId } = opts;
141
+ const slug = `conformance-resources-${crypto.randomUUID()}`;
142
+ await seedTemplate(db, {
143
+ tenantId: SYSTEM_TENANT_ID,
144
+ slug,
145
+ kind: "mail-html",
146
+ locale: "de",
147
+ scope: "system",
148
+ linkedResources: { logo: "file_missing" },
149
+ });
150
+ const template = await consumer.resolve({
151
+ tenantId,
152
+ slug,
153
+ kind: "mail-html",
154
+ locale: "de",
155
+ });
156
+
157
+ try {
158
+ const resources = await resolveResources(template);
159
+ if (resources === undefined) {
160
+ throw new ConformanceAssertionError("resolveResources returned undefined");
161
+ }
162
+ } catch (err) {
163
+ if (err instanceof ConformanceAssertionError) throw err;
164
+ if (!(err instanceof Error)) {
165
+ throw new ConformanceAssertionError(`resolveResources threw non-Error: ${typeof err}`);
166
+ }
167
+ if (err instanceof TypeError) {
168
+ throw new ConformanceAssertionError(
169
+ `resolveResources threw TypeError (unhandled missing key?): ${err.message}`,
170
+ );
171
+ }
172
+ }
173
+ }
174
+
175
+ /** Register conformance cases with the caller's test runner (e.g. bun `test`). */
176
+ export function runTemplateConsumerConformance(
177
+ register: ConformanceTestRegistrar,
178
+ consumer: TemplateConsumer,
179
+ opts: TemplateConsumerConformanceOptions,
180
+ ): void {
181
+ register("consumer handles TemplateNotFoundError gracefully", () =>
182
+ assertConsumerHandlesNotFound(consumer, opts),
183
+ );
184
+ register("consumer respects locale-fallback", () =>
185
+ assertConsumerRespectsLocaleFallback(consumer, opts),
186
+ );
187
+ if (consumer.resolveResources) {
188
+ register("consumer handles missing resource keys", () =>
189
+ assertConsumerHandlesMissingResourceKeys(consumer, opts),
190
+ );
191
+ }
192
+ }
@@ -18,6 +18,10 @@ describe("tier-engine drift pins", () => {
18
18
  expect(TierEngineHandlers.update).toBe("tier-engine:write:tier-assignment:update");
19
19
  expect(TierEngineQueries.list).toBe("tier-engine:query:tier-assignment:list");
20
20
  expect(TierEngineQueries.getActiveTier).toBe("tier-engine:query:get-active-tier");
21
+ // Screen↔Handler-Contract: der TierAdminScreen dispatcht exakt diese QNs.
22
+ expect(TierEngineHandlers.setTenantTier).toBe("tier-engine:write:set-tenant-tier");
23
+ expect(TierEngineQueries.getTenantTier).toBe("tier-engine:query:get-tenant-tier");
24
+ expect(TierEngineQueries.tierOptions).toBe("tier-engine:query:tier-options");
21
25
 
22
26
  // Every QN must start with the feature-name as scope.
23
27
  for (const qn of [...Object.values(TierEngineHandlers), ...Object.values(TierEngineQueries)]) {