@jskit-ai/users-web 0.1.70 → 0.1.72

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 (29) hide show
  1. package/package.descriptor.mjs +7 -7
  2. package/package.json +11 -8
  3. package/src/client/bootstrap/user-bootstrap-handler.js +53 -0
  4. package/src/client/composables/crud/crudSchemaFormHelpers.js +1 -22
  5. package/src/client/composables/internal/crudListParentTitleSupport.js +14 -15
  6. package/src/client/composables/records/useAddEdit.js +23 -4
  7. package/src/client/composables/records/useCrudAddEdit.js +5 -20
  8. package/src/client/composables/records/useList.js +22 -22
  9. package/src/client/composables/records/useView.js +13 -27
  10. package/src/client/composables/runtime/operationValidationHelpers.js +18 -19
  11. package/src/client/composables/runtime/useAddEditCore.js +6 -10
  12. package/src/client/composables/runtime/useCommandCore.js +3 -10
  13. package/src/client/composables/runtime/useEndpointResource.js +75 -14
  14. package/src/client/composables/runtime/useListCore.js +45 -17
  15. package/src/client/composables/support/requestQueryRuntimeSupport.js +100 -0
  16. package/src/client/composables/useAccountSettingsRuntime.js +26 -19
  17. package/src/client/composables/useCommand.js +4 -2
  18. package/src/client/composables/useCrudListFilters.js +58 -255
  19. package/src/client/providers/UsersWebClientProvider.js +4 -2
  20. package/test/bootstrap.test.js +130 -0
  21. package/test/operationValidationHelpers.test.js +64 -0
  22. package/test/requestTransportOptions.test.js +107 -0
  23. package/test/useAddEditCore.test.js +124 -0
  24. package/test/useAddEditRequestQueryParams.test.js +162 -0
  25. package/test/useCrudAddEdit.test.js +1 -51
  26. package/test/useCrudListFilters.test.js +33 -4
  27. package/test/useCrudListParentTitle.test.js +40 -27
  28. package/test/useViewRequestQueryParams.test.js +10 -10
  29. package/src/client/composables/support/requestQueryPathSupport.js +0 -31
@@ -1,17 +1,17 @@
1
- import { computed, reactive, toRef } from "vue";
2
- import { normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
1
+ import { computed, reactive } from "vue";
2
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import {
4
4
  defineCrudListFilters,
5
+ createCrudListFilterInitialValue,
6
+ isCrudListFilterMultiValue,
7
+ isCrudListFilterStructuredValue,
8
+ normalizeCrudListFilterUiValue,
9
+ areCrudListFilterUiValuesEqual,
10
+ listCrudListFilterChipValues,
11
+ formatCrudListFilterDefaultChipLabel,
12
+ formatCrudListFilterQueryValue,
5
13
  resolveCrudListFilterOptionLabel,
6
- CRUD_LIST_FILTER_TYPE_FLAG,
7
- CRUD_LIST_FILTER_TYPE_ENUM,
8
- CRUD_LIST_FILTER_TYPE_ENUM_MANY,
9
- CRUD_LIST_FILTER_TYPE_RECORD_ID,
10
- CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY,
11
- CRUD_LIST_FILTER_TYPE_DATE,
12
- CRUD_LIST_FILTER_TYPE_DATE_RANGE,
13
- CRUD_LIST_FILTER_TYPE_NUMBER_RANGE,
14
- CRUD_LIST_FILTER_TYPE_PRESENCE
14
+ CRUD_LIST_FILTER_TYPE_FLAG
15
15
  } from "@jskit-ai/kernel/shared/support/crudListFilters";
16
16
 
17
17
  function normalizeFunctionMap(value = {}) {
@@ -32,29 +32,6 @@ function normalizeFunctionMap(value = {}) {
32
32
  return Object.freeze(normalized);
33
33
  }
34
34
 
35
- function createInitialFilterValue(filter = {}) {
36
- if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
37
- return false;
38
- }
39
- if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
40
- return [];
41
- }
42
- if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
43
- return reactive({
44
- from: "",
45
- to: ""
46
- });
47
- }
48
- if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
49
- return reactive({
50
- min: "",
51
- max: ""
52
- });
53
- }
54
-
55
- return "";
56
- }
57
-
58
35
  function normalizePresetEntries(presets = []) {
59
36
  const source = Array.isArray(presets) ? presets : [];
60
37
  const normalized = [];
@@ -105,150 +82,52 @@ function resolvePresetValues(preset = {}, { values = {}, filters = {} } = {}) {
105
82
  : {};
106
83
  }
107
84
 
108
- function normalizePresetFilterValue(filter = {}, rawValue) {
109
- if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
110
- return rawValue === true;
111
- }
112
-
113
- if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
114
- const allowedValues = filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY
115
- ? new Set((filter.options || []).map((entry) => entry.value))
116
- : null;
117
- const normalizedList = normalizeUniqueTextList(rawValue, {
118
- acceptSingle: true
119
- });
120
- if (!allowedValues) {
121
- return normalizedList;
122
- }
123
-
124
- return normalizedList.filter((entry) => allowedValues.has(entry));
125
- }
126
-
127
- if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
128
- const source = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)
129
- ? rawValue
130
- : {};
131
- return {
132
- from: normalizeText(source.from),
133
- to: normalizeText(source.to)
134
- };
135
- }
136
-
137
- if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
138
- const source = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)
139
- ? rawValue
140
- : {};
141
- return {
142
- min: normalizeText(source.min),
143
- max: normalizeText(source.max)
144
- };
145
- }
146
-
147
- const normalized = normalizeText(rawValue);
148
- if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM || filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE) {
149
- const allowedValues = new Set((filter.options || []).map((entry) => entry.value));
150
- return allowedValues.has(normalized) ? normalized : "";
151
- }
152
-
153
- return normalized;
154
- }
155
-
156
- function normalizeCurrentManyFilterValues(value) {
157
- const source = Array.isArray(value) ? value : [value];
158
- return source
159
- .map((entry) => normalizeText(entry))
160
- .filter(Boolean);
161
- }
162
-
163
- function matchArrayValues(currentValue = [], expectedValue = []) {
164
- const currentList = Array.isArray(currentValue) ? [...currentValue].sort() : [];
165
- const expectedList = Array.isArray(expectedValue) ? [...expectedValue].sort() : [];
166
- if (currentList.length !== expectedList.length) {
167
- return false;
168
- }
169
-
170
- return currentList.every((entry, index) => entry === expectedList[index]);
171
- }
172
-
173
- function matchesPresetFilterValue(filter = {}, currentValue, rawExpectedValue) {
174
- const expectedValue = normalizePresetFilterValue(filter, rawExpectedValue);
175
-
176
- if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
177
- return matchArrayValues(normalizeCurrentManyFilterValues(currentValue), expectedValue);
178
- }
179
-
180
- const normalizedCurrentValue = normalizePresetFilterValue(filter, currentValue);
181
-
182
- if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
183
- return (
184
- normalizeText(normalizedCurrentValue?.from) === normalizeText(expectedValue?.from) &&
185
- normalizeText(normalizedCurrentValue?.to) === normalizeText(expectedValue?.to)
186
- );
187
- }
188
-
189
- if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
190
- return (
191
- normalizeText(normalizedCurrentValue?.min) === normalizeText(expectedValue?.min) &&
192
- normalizeText(normalizedCurrentValue?.max) === normalizeText(expectedValue?.max)
193
- );
194
- }
195
-
196
- return normalizedCurrentValue === expectedValue;
85
+ function createRuntimeFilterValue(filter = {}) {
86
+ const initialValue = createCrudListFilterInitialValue(filter);
87
+ return isCrudListFilterStructuredValue(filter)
88
+ ? reactive(initialValue)
89
+ : initialValue;
197
90
  }
198
91
 
199
- function resetFilterValue(values, filter = {}) {
200
- if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
201
- values[filter.key] = false;
202
- return;
203
- }
92
+ function assignFilterValue(values, filter = {}, rawValue) {
93
+ const normalizedValue = normalizeCrudListFilterUiValue(filter, rawValue);
204
94
 
205
- if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
206
- values[filter.key] = [];
95
+ if (!isCrudListFilterStructuredValue(filter)) {
96
+ values[filter.key] = normalizedValue;
207
97
  return;
208
98
  }
209
99
 
210
- if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
211
- values[filter.key].from = "";
212
- values[filter.key].to = "";
100
+ if (values[filter.key] && typeof values[filter.key] === "object" && !Array.isArray(values[filter.key])) {
101
+ Object.assign(values[filter.key], normalizedValue);
213
102
  return;
214
103
  }
215
104
 
216
- if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
217
- values[filter.key].min = "";
218
- values[filter.key].max = "";
219
- return;
220
- }
105
+ values[filter.key] = reactive(normalizedValue);
106
+ }
221
107
 
222
- values[filter.key] = "";
108
+ function resetFilterValue(values, filter = {}) {
109
+ assignFilterValue(values, filter, createCrudListFilterInitialValue(filter));
223
110
  }
224
111
 
225
112
  function applyPresetFilterValue(values, filter = {}, rawValue) {
226
- if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE || filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
227
- const nextValue = normalizePresetFilterValue(filter, rawValue);
228
- Object.assign(values[filter.key], nextValue);
229
- return;
230
- }
231
-
232
- values[filter.key] = normalizePresetFilterValue(filter, rawValue);
113
+ assignFilterValue(values, filter, rawValue);
233
114
  }
234
115
 
235
116
  function createQueryParams(values, filterEntries = []) {
236
117
  const queryParams = {};
237
118
 
238
119
  for (const filter of filterEntries) {
239
- if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
240
- queryParams[filter.fromKey] = toRef(values[filter.key], "from");
241
- queryParams[filter.toKey] = toRef(values[filter.key], "to");
242
- continue;
243
- }
244
-
245
- if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
246
- queryParams[filter.minKey] = toRef(values[filter.key], "min");
247
- queryParams[filter.maxKey] = toRef(values[filter.key], "max");
248
- continue;
249
- }
250
-
251
- queryParams[filter.queryKey] = toRef(values, filter.key);
120
+ queryParams[filter.queryKey] = computed({
121
+ get() {
122
+ return formatCrudListFilterQueryValue(
123
+ filter,
124
+ normalizeCrudListFilterUiValue(filter, values[filter.key])
125
+ );
126
+ },
127
+ set(nextValue) {
128
+ assignFilterValue(values, filter, nextValue);
129
+ }
130
+ });
252
131
  }
253
132
 
254
133
  return Object.freeze(queryParams);
@@ -268,46 +147,6 @@ function resolveAtomicValueLabel(filter = {}, value = "", labelResolvers = {}) {
268
147
  });
269
148
  }
270
149
 
271
- function defaultChipLabel(filter = {}, value, labelResolvers = {}) {
272
- if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
273
- return filter.label;
274
- }
275
-
276
- if (
277
- filter.type === CRUD_LIST_FILTER_TYPE_ENUM ||
278
- filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE ||
279
- filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID
280
- ) {
281
- return `${filter.label}: ${resolveAtomicValueLabel(filter, value, labelResolvers)}`;
282
- }
283
-
284
- if (filter.type === CRUD_LIST_FILTER_TYPE_DATE) {
285
- return `${filter.label}: ${value}`;
286
- }
287
-
288
- if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
289
- if (value?.from && value?.to) {
290
- return `${filter.label}: ${value.from} to ${value.to}`;
291
- }
292
- if (value?.from) {
293
- return `${filter.label}: from ${value.from}`;
294
- }
295
- return `${filter.label}: to ${value?.to || ""}`;
296
- }
297
-
298
- if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
299
- if (value?.min && value?.max) {
300
- return `${filter.label}: ${value.min} to ${value.max}`;
301
- }
302
- if (value?.min) {
303
- return `${filter.label}: min ${value.min}`;
304
- }
305
- return `${filter.label}: max ${value?.max || ""}`;
306
- }
307
-
308
- return filter.label;
309
- }
310
-
311
150
  function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels = {}, presets = [] } = {}) {
312
151
  const filters = defineCrudListFilters(definitions);
313
152
  const filterEntries = Object.values(filters);
@@ -318,7 +157,7 @@ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels
318
157
  const options = {};
319
158
 
320
159
  for (const filter of filterEntries) {
321
- values[filter.key] = createInitialFilterValue(filter);
160
+ values[filter.key] = createRuntimeFilterValue(filter);
322
161
  if (Array.isArray(filter.options) && filter.options.length > 0) {
323
162
  options[filter.key] = filter.options;
324
163
  }
@@ -331,62 +170,22 @@ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels
331
170
 
332
171
  for (const filter of filterEntries) {
333
172
  const customChipLabel = normalizedChipLabels[filter.key];
334
- const rawValue = values[filter.key];
335
-
336
- if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
337
- if (rawValue === true) {
338
- chips.push({
339
- id: filter.key,
340
- filterKey: filter.key,
341
- label: normalizeText(customChipLabel?.(rawValue, filter, values))
342
- || normalizeText(filter.chipLabel?.(rawValue, filter, values))
343
- || defaultChipLabel(filter, rawValue, normalizedLabelResolvers)
344
- });
345
- }
346
- continue;
347
- }
348
-
349
- if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
350
- for (const value of Array.isArray(rawValue) ? rawValue : []) {
351
- chips.push({
352
- id: `${filter.key}:${value}`,
353
- filterKey: filter.key,
354
- value,
355
- label: normalizeText(customChipLabel?.(value, filter, values))
356
- || normalizeText(filter.chipLabel?.(value, filter, values))
357
- || `${filter.label}: ${resolveAtomicValueLabel(filter, value, normalizedLabelResolvers)}`
358
- });
359
- }
360
- continue;
361
- }
362
-
363
- if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE || filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
364
- const hasValue = Boolean(rawValue?.from || rawValue?.to || rawValue?.min || rawValue?.max);
365
- if (!hasValue) {
366
- continue;
367
- }
173
+ const chipValues = listCrudListFilterChipValues(filter, values[filter.key]);
368
174
 
175
+ for (const chipValue of chipValues) {
369
176
  chips.push({
370
- id: filter.key,
177
+ id: isCrudListFilterMultiValue(filter) ? `${filter.key}:${chipValue}` : filter.key,
371
178
  filterKey: filter.key,
372
- label: normalizeText(customChipLabel?.(rawValue, filter, values))
373
- || normalizeText(filter.chipLabel?.(rawValue, filter, values))
374
- || defaultChipLabel(filter, rawValue, normalizedLabelResolvers)
179
+ ...(isCrudListFilterMultiValue(filter) ? { value: chipValue } : {}),
180
+ label: normalizeText(customChipLabel?.(chipValue, filter, values))
181
+ || normalizeText(filter.chipLabel?.(chipValue, filter, values))
182
+ || formatCrudListFilterDefaultChipLabel(filter, chipValue, {
183
+ resolveAtomicValue(value) {
184
+ return resolveAtomicValueLabel(filter, value, normalizedLabelResolvers);
185
+ }
186
+ })
375
187
  });
376
- continue;
377
188
  }
378
-
379
- if (!normalizeText(rawValue)) {
380
- continue;
381
- }
382
-
383
- chips.push({
384
- id: filter.key,
385
- filterKey: filter.key,
386
- label: normalizeText(customChipLabel?.(rawValue, filter, values))
387
- || normalizeText(filter.chipLabel?.(rawValue, filter, values))
388
- || defaultChipLabel(filter, rawValue, normalizedLabelResolvers)
389
- });
390
189
  }
391
190
 
392
191
  return chips;
@@ -415,9 +214,13 @@ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels
415
214
  return;
416
215
  }
417
216
 
418
- if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
419
- values[filter.key] = (Array.isArray(values[filter.key]) ? values[filter.key] : [])
420
- .filter((entry) => entry !== chip.value);
217
+ if (isCrudListFilterMultiValue(filter)) {
218
+ assignFilterValue(
219
+ values,
220
+ filter,
221
+ (Array.isArray(values[filter.key]) ? values[filter.key] : [])
222
+ .filter((entry) => entry !== chip.value)
223
+ );
421
224
  return;
422
225
  }
423
226
 
@@ -475,7 +278,7 @@ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels
475
278
  }
476
279
 
477
280
  matchedFilter = true;
478
- if (!matchesPresetFilterValue(filter, values[filter.key], presetValues[filter.key])) {
281
+ if (!areCrudListFilterUiValuesEqual(filter, values[filter.key], presetValues[filter.key])) {
479
282
  return false;
480
283
  }
481
284
  }
@@ -1,17 +1,19 @@
1
1
  import UsersHomeToolsWidget from "../components/UsersHomeToolsWidget.vue";
2
2
  import ProfileClientElement from "../components/ProfileClientElement.vue";
3
+ import { registerUsersBootstrapPayloadHandlers } from "../bootstrap/user-bootstrap-handler.js";
3
4
 
4
5
  class UsersWebClientProvider {
5
6
  static id = "users.web.client";
6
7
  static dependsOn = ["shell.web.client"];
7
8
 
8
9
  register(app) {
9
- if (!app || typeof app.singleton !== "function") {
10
- throw new Error("UsersWebClientProvider requires application singleton().");
10
+ if (!app || typeof app.singleton !== "function" || typeof app.tag !== "function") {
11
+ throw new Error("UsersWebClientProvider requires application singleton()/tag().");
11
12
  }
12
13
 
13
14
  app.singleton("users.web.home.tools.widget", () => UsersHomeToolsWidget);
14
15
  app.singleton("users.web.profile.element", () => ProfileClientElement);
16
+ registerUsersBootstrapPayloadHandlers(app);
15
17
  }
16
18
  }
17
19
 
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { resolveBootstrapPayloadHandlers } from "@jskit-ai/shell-web/client/bootstrap";
3
4
  import { resolvePlacementUserFromBootstrapPayload } from "../src/client/lib/bootstrap.js";
5
+ import {
6
+ createUsersBootstrapUserHandler,
7
+ registerUsersBootstrapPayloadHandlers
8
+ } from "../src/client/bootstrap/user-bootstrap-handler.js";
4
9
 
5
10
  test("resolvePlacementUserFromBootstrapPayload returns null for anonymous sessions", () => {
6
11
  assert.equal(
@@ -36,3 +41,128 @@ test("resolvePlacementUserFromBootstrapPayload maps profile fields used by place
36
41
  avatarUrl: "https://cdn.example.com/ada.png"
37
42
  });
38
43
  });
44
+
45
+ function createPlacementRuntimeDouble() {
46
+ return {
47
+ context: Object.freeze({}),
48
+ getContext() {
49
+ return this.context;
50
+ },
51
+ setContext(patch = {}, { replace = false } = {}) {
52
+ this.context = Object.freeze(
53
+ replace
54
+ ? { ...patch }
55
+ : {
56
+ ...this.context,
57
+ ...patch
58
+ }
59
+ );
60
+ return this.context;
61
+ }
62
+ };
63
+ }
64
+
65
+ function createBootstrapRegistryAppDouble() {
66
+ const singletons = new Map();
67
+ const instances = new Map();
68
+ const tags = new Map();
69
+
70
+ return {
71
+ singleton(token, factory) {
72
+ singletons.set(token, factory);
73
+ },
74
+ tag(token, tagName) {
75
+ const current = tags.get(tagName) || [];
76
+ current.push(token);
77
+ tags.set(tagName, current);
78
+ },
79
+ resolveTag(tagName) {
80
+ return (tags.get(tagName) || []).map((token) => this.make(token));
81
+ },
82
+ make(token) {
83
+ if (instances.has(token)) {
84
+ return instances.get(token);
85
+ }
86
+ const factory = singletons.get(token);
87
+ if (typeof factory !== "function") {
88
+ throw new Error(`Unknown token ${String(token)}`);
89
+ }
90
+ const instance = factory(this);
91
+ instances.set(token, instance);
92
+ return instance;
93
+ }
94
+ };
95
+ }
96
+
97
+ test("users web bootstrap user handler writes placement user", async () => {
98
+ const handler = createUsersBootstrapUserHandler();
99
+ const placementRuntime = createPlacementRuntimeDouble();
100
+ await handler.applyBootstrapPayload({
101
+ payload: {
102
+ session: {
103
+ authenticated: true,
104
+ userId: "42"
105
+ },
106
+ profile: {
107
+ displayName: "Ada Lovelace",
108
+ email: "ADA@EXAMPLE.COM",
109
+ avatar: {
110
+ effectiveUrl: "https://cdn.example.com/ada.png"
111
+ }
112
+ }
113
+ },
114
+ placementRuntime,
115
+ source: "test"
116
+ });
117
+
118
+ assert.deepEqual(placementRuntime.getContext().user, {
119
+ id: "42",
120
+ displayName: "Ada Lovelace",
121
+ name: "Ada Lovelace",
122
+ email: "ada@example.com",
123
+ avatarUrl: "https://cdn.example.com/ada.png"
124
+ });
125
+ });
126
+
127
+ test("users web bootstrap user handler clears placement user on bootstrap 401 only", async () => {
128
+ const handler = createUsersBootstrapUserHandler();
129
+ const placementRuntime = createPlacementRuntimeDouble();
130
+ placementRuntime.setContext({
131
+ user: {
132
+ id: "42",
133
+ displayName: "Ada Lovelace"
134
+ }
135
+ });
136
+
137
+ await handler.handleBootstrapError({
138
+ error: {
139
+ statusCode: 404
140
+ },
141
+ placementRuntime,
142
+ source: "test"
143
+ });
144
+ assert.deepEqual(placementRuntime.getContext().user, {
145
+ id: "42",
146
+ displayName: "Ada Lovelace"
147
+ });
148
+
149
+ await handler.handleBootstrapError({
150
+ error: {
151
+ statusCode: 401
152
+ },
153
+ placementRuntime,
154
+ source: "test"
155
+ });
156
+ assert.equal(placementRuntime.getContext().user, null);
157
+ });
158
+
159
+ test("registerUsersBootstrapPayloadHandlers registers the users bootstrap user handler", () => {
160
+ const app = createBootstrapRegistryAppDouble();
161
+ registerUsersBootstrapPayloadHandlers(app);
162
+
163
+ const handlers = resolveBootstrapPayloadHandlers(app);
164
+ assert.equal(handlers.length, 1);
165
+ assert.equal(handlers[0]?.handlerId, "users.web.bootstrap.user");
166
+ assert.equal(typeof handlers[0]?.applyBootstrapPayload, "function");
167
+ assert.equal(typeof handlers[0]?.handleBootstrapError, "function");
168
+ });
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createSchema } from "json-rest-schema";
4
+ import { validateOperationInput } from "../src/client/composables/runtime/operationValidationHelpers.js";
5
+
6
+ test("validateOperationInput validates a schema definition and returns normalized input", () => {
7
+ const rawPayload = {
8
+ name: " Acme "
9
+ };
10
+
11
+ const result = validateOperationInput({
12
+ input: {
13
+ schema: createSchema({
14
+ name: { type: "string", required: true, minLength: 1 }
15
+ }),
16
+ mode: "patch"
17
+ },
18
+ rawPayload
19
+ });
20
+
21
+ assert.equal(result.ok, true);
22
+ assert.deepEqual(result.parsedInput, {
23
+ name: "Acme"
24
+ });
25
+ });
26
+
27
+ test("validateOperationInput returns validation failures for invalid schema input", () => {
28
+ const result = validateOperationInput({
29
+ input: {
30
+ schema: createSchema({
31
+ name: {
32
+ type: "string",
33
+ required: true,
34
+ minLength: 1,
35
+ messages: {
36
+ minLength: "Name is required."
37
+ }
38
+ }
39
+ }),
40
+ mode: "patch"
41
+ },
42
+ rawPayload: {
43
+ name: ""
44
+ }
45
+ });
46
+
47
+ assert.equal(result.ok, false);
48
+ assert.equal(result.failure.code, "validation_failed");
49
+ assert.equal(result.failure.fieldErrors.name, "Name is required.");
50
+ });
51
+
52
+ test("validateOperationInput rethrows malformed input contracts", () => {
53
+ assert.throws(
54
+ () =>
55
+ validateOperationInput({
56
+ input: {
57
+ schema: null,
58
+ mode: "patch"
59
+ },
60
+ rawPayload: {}
61
+ }),
62
+ /must be a json-rest-schema schema instance/
63
+ );
64
+ });