@cosmicdrift/kumiko-bundled-features 0.12.2 → 0.13.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 (37) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/package.json +5 -5
  3. package/src/channel-email/feature.ts +1 -1
  4. package/src/channel-in-app/constants.ts +1 -1
  5. package/src/channel-in-app/feature.ts +1 -1
  6. package/src/channel-push/feature.ts +1 -1
  7. package/src/custom-fields/__tests__/audit-integration.integration.ts +277 -0
  8. package/src/custom-fields/__tests__/custom-fields.integration.ts +261 -0
  9. package/src/custom-fields/__tests__/feature.test.ts +8 -1
  10. package/src/custom-fields/__tests__/field-access.integration.ts +268 -0
  11. package/src/custom-fields/__tests__/quota.integration.ts +162 -0
  12. package/src/custom-fields/__tests__/retention.integration.ts +262 -0
  13. package/src/custom-fields/__tests__/user-data-rights.integration.ts +290 -0
  14. package/src/custom-fields/__tests__/wire-for-entity.test.ts +123 -0
  15. package/src/custom-fields/constants.ts +19 -4
  16. package/src/custom-fields/events.ts +21 -0
  17. package/src/custom-fields/feature.ts +135 -29
  18. package/src/custom-fields/handlers/clear-custom-field.write.ts +57 -0
  19. package/src/custom-fields/handlers/define-tenant-field.write.ts +72 -35
  20. package/src/custom-fields/handlers/delete-system-field.write.ts +15 -1
  21. package/src/custom-fields/handlers/delete-tenant-field.write.ts +16 -1
  22. package/src/custom-fields/handlers/set-custom-field.write.ts +77 -0
  23. package/src/custom-fields/index.ts +17 -2
  24. package/src/custom-fields/lib/field-access.ts +75 -0
  25. package/src/custom-fields/lib/parse-serialized-field.ts +45 -0
  26. package/src/custom-fields/lib/quota.ts +28 -0
  27. package/src/custom-fields/run-retention.ts +215 -0
  28. package/src/custom-fields/schemas.ts +37 -4
  29. package/src/custom-fields/wire-for-entity.ts +162 -0
  30. package/src/custom-fields/wire-user-data-rights.ts +169 -0
  31. package/src/rate-limiting/constants.ts +1 -1
  32. package/src/rate-limiting/feature.ts +1 -1
  33. package/src/renderer-simple/feature.ts +1 -1
  34. package/src/template-resolver/table.ts +3 -1
  35. package/src/tenant/invitation-table.ts +2 -1
  36. package/src/text-content/table.ts +3 -1
  37. package/src/user/schema/user.ts +4 -2
@@ -0,0 +1,261 @@
1
+ // T1 — full-stack integration tests for the custom-fields bundle.
2
+ //
3
+ // Drives define→set→query→clear→delete-cascade through the real dispatcher +
4
+ // MSP-pipeline + DB. Verifies that the architecture actually works end-to-end:
5
+ // - r.defineEvent fires + MSP consumes + jsonb-projection lands
6
+ // - postQuery-entity-hook flattens customFields auf API-root
7
+ // - fieldDefinition-delete cascade-cleans orphan jsonb-keys
8
+ // - Multi-tenant isolation
9
+ //
10
+ // Pattern follows cap-counter.integration.ts: probe-feature mit own entity,
11
+ // wired via wireCustomFieldsFor.
12
+
13
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
14
+ import {
15
+ createEntity,
16
+ createEntityExecutor,
17
+ createTextField,
18
+ defineEntityListHandler,
19
+ defineFeature,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
21
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
22
+ import {
23
+ createTestUser,
24
+ setupTestStack,
25
+ type TestStack,
26
+ unsafeCreateEntityTable,
27
+ } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { sql } from "drizzle-orm";
29
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
30
+ import { z } from "zod";
31
+ import { fieldDefinitionEntity } from "../entity";
32
+ import { createCustomFieldsFeature } from "../feature";
33
+ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
34
+
35
+ // --- Probe-Feature: a tenant-owned "property" entity with customFields ---
36
+
37
+ const propertyEntity = createEntity({
38
+ table: "read_t1_properties",
39
+ fields: {
40
+ name: createTextField({ required: true }),
41
+ customFields: customFieldsField(),
42
+ },
43
+ });
44
+ const propertyTable = buildDrizzleTable("property", propertyEntity);
45
+
46
+ const propertyFeature = defineFeature("property-test", (r) => {
47
+ r.entity("property", propertyEntity);
48
+ r.requires("custom-fields");
49
+ wireCustomFieldsFor(r, "property", propertyTable);
50
+
51
+ // Standard CRUD: create + list via entity-handlers. Pure test-probe.
52
+ const { executor: propertyExecutor } = createEntityExecutor("property", propertyEntity);
53
+ r.writeHandler({
54
+ name: "property:create",
55
+ schema: z.object({ id: z.string(), name: z.string() }),
56
+ access: { roles: ["TenantAdmin"] },
57
+ handler: async (event, ctx) => {
58
+ const payload = event.payload as { id: string; name: string };
59
+ return propertyExecutor.create(
60
+ { id: payload.id, name: payload.name, customFields: {} },
61
+ event.user,
62
+ ctx.db,
63
+ );
64
+ },
65
+ });
66
+
67
+ r.queryHandler(
68
+ defineEntityListHandler("property", propertyEntity, { access: { roles: ["TenantAdmin"] } }),
69
+ );
70
+ });
71
+
72
+ // --- Stack ---
73
+
74
+ const customFieldsFeature = createCustomFieldsFeature();
75
+
76
+ let stack: TestStack;
77
+
78
+ beforeAll(async () => {
79
+ stack = await setupTestStack({
80
+ features: [customFieldsFeature, propertyFeature],
81
+ });
82
+
83
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
84
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
85
+ await createEventsTable(stack.db);
86
+ });
87
+
88
+ afterAll(async () => {
89
+ await stack.cleanup();
90
+ });
91
+
92
+ beforeEach(async () => {
93
+ // Clean slate per test — event-log + entity-rows.
94
+ await stack.db.execute(sql`DELETE FROM kumiko_events`);
95
+ await stack.db.execute(sql`DELETE FROM read_t1_properties`);
96
+ await stack.db.execute(sql`DELETE FROM read_custom_field_definitions`);
97
+ });
98
+
99
+ // --- Helpers ---
100
+
101
+ // TestUsers.admin hat role="Admin"; unsere handlers verlangen "TenantAdmin"
102
+ // (Memory: feedback_role_naming_drift — bundled-features-Convention vs.
103
+ // platform-Convention). Wir bauen einen tenant-admin für die Tests.
104
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
105
+
106
+ async function defineField(entityName: string, fieldKey: string, type = "text") {
107
+ return stack.http.writeOk(
108
+ "custom-fields:write:define-tenant-field",
109
+ {
110
+ entityName,
111
+ fieldKey,
112
+ serializedField: { type },
113
+ required: false,
114
+ searchable: false,
115
+ displayOrder: 0,
116
+ },
117
+ admin,
118
+ );
119
+ }
120
+
121
+ async function setCustomField(
122
+ entityName: string,
123
+ entityId: string,
124
+ fieldKey: string,
125
+ value: unknown,
126
+ ) {
127
+ return stack.http.writeOk(
128
+ "custom-fields:write:set-custom-field",
129
+ { entityName, entityId, fieldKey, value },
130
+ admin,
131
+ );
132
+ }
133
+
134
+ async function clearCustomField(entityName: string, entityId: string, fieldKey: string) {
135
+ return stack.http.writeOk(
136
+ "custom-fields:write:clear-custom-field",
137
+ { entityName, entityId, fieldKey },
138
+ admin,
139
+ );
140
+ }
141
+
142
+ async function createProperty(id: string, name: string) {
143
+ return stack.http.writeOk("property-test:write:property:create", { id, name }, admin);
144
+ }
145
+
146
+ async function listProperties() {
147
+ return (await stack.http.queryOk("property-test:query:property:list", {}, admin)) as {
148
+ rows: Array<Record<string, unknown>>;
149
+ };
150
+ }
151
+
152
+ // --- Tests ---
153
+
154
+ describe("custom-fields integration — define + set + query roundtrip", () => {
155
+ test("set → MSP → postQuery: customField value lands flat in entity response", async () => {
156
+ await defineField("property", "internalNumber");
157
+ await createProperty("11111111-1111-4000-8000-000000000001", "Hofgarten 12");
158
+ await setCustomField(
159
+ "property",
160
+ "11111111-1111-4000-8000-000000000001",
161
+ "internalNumber",
162
+ "X-2042",
163
+ );
164
+
165
+ await stack.eventDispatcher?.runOnce();
166
+
167
+ const { rows } = await listProperties();
168
+ const p1 = rows.find((r) => r["id"] === "11111111-1111-4000-8000-000000000001");
169
+ expect(p1).toBeDefined();
170
+ expect(p1?.["internalNumber"]).toBe("X-2042");
171
+ });
172
+
173
+ test("clear: fieldKey gone from response after clear-custom-field", async () => {
174
+ await defineField("property", "vipFlag", "boolean");
175
+ await createProperty("22222222-2222-4000-8000-000000000002", "BookStore");
176
+ await setCustomField("property", "22222222-2222-4000-8000-000000000002", "vipFlag", true);
177
+ await stack.eventDispatcher?.runOnce();
178
+
179
+ let p2 = (await listProperties()).rows.find(
180
+ (r) => r["id"] === "22222222-2222-4000-8000-000000000002",
181
+ );
182
+ expect(p2?.["vipFlag"]).toBe(true);
183
+
184
+ await clearCustomField("property", "22222222-2222-4000-8000-000000000002", "vipFlag");
185
+ await stack.eventDispatcher?.runOnce();
186
+
187
+ p2 = (await listProperties()).rows.find(
188
+ (r) => r["id"] === "22222222-2222-4000-8000-000000000002",
189
+ );
190
+ expect(p2?.["vipFlag"]).toBeUndefined();
191
+ });
192
+
193
+ test("multiple fields on same entity: all merge flat", async () => {
194
+ await defineField("property", "vendor");
195
+ await defineField("property", "tier", "number");
196
+ await createProperty("33333333-3333-4000-8000-000000000003", "MultiField");
197
+ await setCustomField("property", "33333333-3333-4000-8000-000000000003", "vendor", "Hetzner");
198
+ await setCustomField("property", "33333333-3333-4000-8000-000000000003", "tier", 2);
199
+
200
+ await stack.eventDispatcher?.runOnce();
201
+
202
+ const p3 = (await listProperties()).rows.find(
203
+ (r) => r["id"] === "33333333-3333-4000-8000-000000000003",
204
+ );
205
+ expect(p3?.["vendor"]).toBe("Hetzner");
206
+ expect(p3?.["tier"]).toBe(2);
207
+ });
208
+
209
+ test("entity without customField values: still queryable (no postQuery breakage)", async () => {
210
+ await createProperty("44444444-4444-4000-8000-000000000004", "NoCustomFields");
211
+
212
+ const p4 = (await listProperties()).rows.find(
213
+ (r) => r["id"] === "44444444-4444-4000-8000-000000000004",
214
+ );
215
+ expect(p4?.["name"]).toBe("NoCustomFields");
216
+ });
217
+ });
218
+
219
+ describe("custom-fields integration — fieldDefinition-delete cascade", () => {
220
+ test("fieldDef-delete: orphan values removed from all entity-rows", async () => {
221
+ await defineField("property", "ephemeral");
222
+ await createProperty("55555555-5555-4000-8000-000000000005", "WillLoseField");
223
+ await setCustomField("property", "55555555-5555-4000-8000-000000000005", "ephemeral", "doomed");
224
+ await stack.eventDispatcher?.runOnce();
225
+
226
+ let p5 = (await listProperties()).rows.find(
227
+ (r) => r["id"] === "55555555-5555-4000-8000-000000000005",
228
+ );
229
+ expect(p5?.["ephemeral"]).toBe("doomed");
230
+
231
+ // Delete fieldDef — cascade-MSP entfernt jsonb-key aus allen rows
232
+ await stack.http.writeOk(
233
+ "custom-fields:write:delete-tenant-field",
234
+ { entityName: "property", fieldKey: "ephemeral" },
235
+ admin,
236
+ );
237
+ await stack.eventDispatcher?.runOnce();
238
+
239
+ p5 = (await listProperties()).rows.find(
240
+ (r) => r["id"] === "55555555-5555-4000-8000-000000000005",
241
+ );
242
+ expect(p5?.["ephemeral"]).toBeUndefined();
243
+ expect(p5?.["name"]).toBe("WillLoseField"); // Stammfeld unverändert
244
+ });
245
+ });
246
+
247
+ describe("custom-fields integration — Last-Wins on concurrent set", () => {
248
+ test("two sequential sets on same field: last value wins", async () => {
249
+ await defineField("property", "status");
250
+ await createProperty("66666666-6666-4000-8000-000000000006", "StatusEntity");
251
+
252
+ await setCustomField("property", "66666666-6666-4000-8000-000000000006", "status", "draft");
253
+ await setCustomField("property", "66666666-6666-4000-8000-000000000006", "status", "published");
254
+ await stack.eventDispatcher?.runOnce();
255
+
256
+ const p6 = (await listProperties()).rows.find(
257
+ (r) => r["id"] === "66666666-6666-4000-8000-000000000006",
258
+ );
259
+ expect(p6?.["status"]).toBe("published");
260
+ });
261
+ });
@@ -9,7 +9,7 @@ import { defineFieldPayloadSchema, deleteFieldPayloadSchema } from "../schemas";
9
9
  // MSP + Read-Pipeline da ist — die testen ES-Loop end-to-end.
10
10
 
11
11
  describe("createCustomFieldsFeature shape", () => {
12
- test("registers field-definition entity + 4 write-handlers + 1 query-handler", () => {
12
+ test("registers field-definition entity + 6 write-handlers + 1 query-handler", () => {
13
13
  const feature = createCustomFieldsFeature();
14
14
 
15
15
  expect(Object.keys(feature.entities)).toContain("field-definition");
@@ -21,11 +21,18 @@ describe("createCustomFieldsFeature shape", () => {
21
21
  expect.stringMatching(/define-system-field/),
22
22
  expect.stringMatching(/delete-tenant-field/),
23
23
  expect.stringMatching(/delete-system-field/),
24
+ expect.stringMatching(/set-custom-field/),
25
+ expect.stringMatching(/clear-custom-field/),
24
26
  ]),
25
27
  );
26
28
 
27
29
  expect(Object.keys(feature.queryHandlers)).toHaveLength(1);
28
30
  });
31
+
32
+ test("registers customFields extension-name via extendsRegistrar (B2 wiring opt-in)", () => {
33
+ const feature = createCustomFieldsFeature();
34
+ expect(feature.registrarExtensions["customFields"]).toBeDefined();
35
+ });
29
36
  });
30
37
 
31
38
  describe("defineFieldPayloadSchema", () => {
@@ -0,0 +1,268 @@
1
+ // T1.5b — per-field fieldAccess.write enforcement for set/clear handlers.
2
+ //
3
+ // Verifies that when a fieldDefinition carries
4
+ // `serializedField.fieldAccess.write = [<role>, ...]`, the set and clear
5
+ // handlers reject calls whose user lacks any of the listed roles — even
6
+ // when handler-level RBAC (TenantAdmin/TenantMember) admits them.
7
+ //
8
+ // Inverse: when fieldAccess.write is absent or empty, the handlers behave
9
+ // exactly as in B2 (no extra gate) — the existing roundtrip-test suite
10
+ // stays green, and we add an explicit covers-the-no-op-path test here too.
11
+
12
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
13
+ import {
14
+ createEntity,
15
+ createEntityExecutor,
16
+ createTextField,
17
+ defineFeature,
18
+ } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
20
+ import {
21
+ createTestUser,
22
+ resetEventStore,
23
+ setupTestStack,
24
+ type TestStack,
25
+ unsafeCreateEntityTable,
26
+ } from "@cosmicdrift/kumiko-framework/stack";
27
+ import { sql } from "drizzle-orm";
28
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
29
+ import { z } from "zod";
30
+ import { fieldDefinitionEntity } from "../entity";
31
+ import { createCustomFieldsFeature } from "../feature";
32
+ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
33
+
34
+ const propertyEntity = createEntity({
35
+ table: "read_t15b_properties",
36
+ fields: {
37
+ name: createTextField({ required: true }),
38
+ customFields: customFieldsField(),
39
+ },
40
+ });
41
+ const propertyTable = buildDrizzleTable("property", propertyEntity);
42
+
43
+ const propertyFeature = defineFeature("property-t15b", (r) => {
44
+ r.entity("property", propertyEntity);
45
+ r.requires("custom-fields");
46
+ wireCustomFieldsFor(r, "property", propertyTable);
47
+
48
+ const { executor } = createEntityExecutor("property", propertyEntity);
49
+ r.writeHandler({
50
+ name: "property:create",
51
+ schema: z.object({ id: z.string(), name: z.string() }),
52
+ access: { roles: ["TenantAdmin", "TenantMember"] },
53
+ handler: async (event, ctx) =>
54
+ executor.create(
55
+ { id: event.payload.id, name: event.payload.name, customFields: {} },
56
+ event.user,
57
+ ctx.db,
58
+ ),
59
+ });
60
+ });
61
+
62
+ const customFieldsFeature = createCustomFieldsFeature();
63
+
64
+ // Two users on the same tenant — both pass handler-level RBAC (which
65
+ // accepts both roles), so the only difference that should matter is the
66
+ // per-field fieldAccess gate.
67
+ const tenantAdmin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
68
+ const tenantMember = createTestUser({ id: 2, roles: ["TenantMember"] });
69
+
70
+ let stack: TestStack;
71
+
72
+ beforeAll(async () => {
73
+ stack = await setupTestStack({
74
+ features: [customFieldsFeature, propertyFeature],
75
+ });
76
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
77
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
78
+ await createEventsTable(stack.db);
79
+ });
80
+
81
+ afterAll(async () => {
82
+ await stack.cleanup();
83
+ });
84
+
85
+ beforeEach(async () => {
86
+ await resetEventStore(stack);
87
+ await stack.db.execute(sql`DELETE FROM read_t15b_properties`);
88
+ await stack.db.execute(sql`DELETE FROM read_custom_field_definitions`);
89
+ });
90
+
91
+ async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
92
+ return stack.http.writeOk(
93
+ "custom-fields:write:define-tenant-field",
94
+ {
95
+ entityName: "property",
96
+ fieldKey,
97
+ serializedField,
98
+ required: false,
99
+ searchable: false,
100
+ displayOrder: 0,
101
+ },
102
+ tenantAdmin,
103
+ );
104
+ }
105
+
106
+ describe("T1.5b: per-field fieldAccess.write rejects users without required roles", () => {
107
+ test("set: TenantMember cannot set a field guarded by fieldAccess.write=['TenantAdmin']", async () => {
108
+ const propertyId = "11111111-1111-4000-8000-000000000001";
109
+
110
+ await defineField("adminOnly", {
111
+ type: "text",
112
+ fieldAccess: { write: ["TenantAdmin"] },
113
+ });
114
+ await stack.http.writeOk(
115
+ "property-t15b:write:property:create",
116
+ { id: propertyId, name: "BookStore" },
117
+ tenantAdmin,
118
+ );
119
+
120
+ const err = await stack.http.writeErr(
121
+ "custom-fields:write:set-custom-field",
122
+ {
123
+ entityName: "property",
124
+ entityId: propertyId,
125
+ fieldKey: "adminOnly",
126
+ value: "X-42",
127
+ },
128
+ tenantMember,
129
+ );
130
+
131
+ expect(err.code).toBe("unprocessable");
132
+ expect(err.details).toMatchObject({
133
+ reason: "field_access_denied",
134
+ fieldKey: "adminOnly",
135
+ requiredRoles: ["TenantAdmin"],
136
+ });
137
+ });
138
+
139
+ test("set: TenantAdmin passes the same fieldAccess gate", async () => {
140
+ const propertyId = "22222222-2222-4000-8000-000000000002";
141
+
142
+ await defineField("adminOnly", {
143
+ type: "text",
144
+ fieldAccess: { write: ["TenantAdmin"] },
145
+ });
146
+ await stack.http.writeOk(
147
+ "property-t15b:write:property:create",
148
+ { id: propertyId, name: "AdminAllowed" },
149
+ tenantAdmin,
150
+ );
151
+
152
+ const res = await stack.http.writeOk(
153
+ "custom-fields:write:set-custom-field",
154
+ {
155
+ entityName: "property",
156
+ entityId: propertyId,
157
+ fieldKey: "adminOnly",
158
+ value: "X-42",
159
+ },
160
+ tenantAdmin,
161
+ );
162
+
163
+ expect(res).toMatchObject({ entityName: "property", entityId: propertyId });
164
+ });
165
+
166
+ test("clear: same gate applies to clear-custom-field", async () => {
167
+ const propertyId = "33333333-3333-4000-8000-000000000003";
168
+
169
+ await defineField("adminOnly", {
170
+ type: "boolean",
171
+ fieldAccess: { write: ["TenantAdmin"] },
172
+ });
173
+ await stack.http.writeOk(
174
+ "property-t15b:write:property:create",
175
+ { id: propertyId, name: "ClearGated" },
176
+ tenantAdmin,
177
+ );
178
+ await stack.http.writeOk(
179
+ "custom-fields:write:set-custom-field",
180
+ { entityName: "property", entityId: propertyId, fieldKey: "adminOnly", value: true },
181
+ tenantAdmin,
182
+ );
183
+
184
+ const err = await stack.http.writeErr(
185
+ "custom-fields:write:clear-custom-field",
186
+ { entityName: "property", entityId: propertyId, fieldKey: "adminOnly" },
187
+ tenantMember,
188
+ );
189
+
190
+ expect(err.details).toMatchObject({
191
+ reason: "field_access_denied",
192
+ fieldKey: "adminOnly",
193
+ });
194
+ });
195
+
196
+ test("no-op: fields without fieldAccess.write let TenantMember through (back-compat)", async () => {
197
+ const propertyId = "44444444-4444-4000-8000-000000000004";
198
+
199
+ await defineField("openField", { type: "text" });
200
+ await stack.http.writeOk(
201
+ "property-t15b:write:property:create",
202
+ { id: propertyId, name: "OpenAccess" },
203
+ tenantAdmin,
204
+ );
205
+
206
+ const res = await stack.http.writeOk(
207
+ "custom-fields:write:set-custom-field",
208
+ {
209
+ entityName: "property",
210
+ entityId: propertyId,
211
+ fieldKey: "openField",
212
+ value: "anyone-can-write-this",
213
+ },
214
+ tenantMember,
215
+ );
216
+
217
+ expect(res).toMatchObject({ entityName: "property", entityId: propertyId });
218
+ });
219
+
220
+ test("any role in the list grants access — intersection, not subset", async () => {
221
+ const propertyId = "55555555-5555-4000-8000-000000000005";
222
+
223
+ await defineField("adminOrMember", {
224
+ type: "text",
225
+ fieldAccess: { write: ["TenantAdmin", "TenantMember"] },
226
+ });
227
+ await stack.http.writeOk(
228
+ "property-t15b:write:property:create",
229
+ { id: propertyId, name: "BothAllowed" },
230
+ tenantAdmin,
231
+ );
232
+
233
+ const res = await stack.http.writeOk(
234
+ "custom-fields:write:set-custom-field",
235
+ {
236
+ entityName: "property",
237
+ entityId: propertyId,
238
+ fieldKey: "adminOrMember",
239
+ value: "ok-from-member",
240
+ },
241
+ tenantMember,
242
+ );
243
+
244
+ expect(res).toMatchObject({ entityName: "property", entityId: propertyId });
245
+ });
246
+
247
+ test("missing fieldDefinition surfaces as not_found (different from access_denied)", async () => {
248
+ const propertyId = "66666666-6666-4000-8000-000000000006";
249
+ await stack.http.writeOk(
250
+ "property-t15b:write:property:create",
251
+ { id: propertyId, name: "NoSuchField" },
252
+ tenantAdmin,
253
+ );
254
+
255
+ const err = await stack.http.writeErr(
256
+ "custom-fields:write:set-custom-field",
257
+ {
258
+ entityName: "property",
259
+ entityId: propertyId,
260
+ fieldKey: "neverDefined",
261
+ value: "doesnt-matter",
262
+ },
263
+ tenantAdmin,
264
+ );
265
+
266
+ expect(err.code).toBe("not_found");
267
+ });
268
+ });