c8y-nitro 0.10.3 → 0.11.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 (41) hide show
  1. package/README.md +2 -0
  2. package/dist/{apiClient-kxv5KHYJ.mjs → apiClient-GMQqcgQe.mjs} +2 -3
  3. package/dist/{autoBootstrap-DUjHuHe_.mjs → autoBootstrap-D4vEytWW.mjs} +6 -8
  4. package/dist/{bootstrap-DrMjty-k.mjs → bootstrap-DpYAhM7C.mjs} +6 -7
  5. package/dist/cli/index.mjs +6 -7
  6. package/dist/{config-BVQHqlgV.mjs → config-Dozrdw3A.mjs} +11 -8
  7. package/dist/{credentials-eKnJ7m9Y.d.mts → credentials-C_3m4LVX.d.mts} +0 -2
  8. package/dist/defaults-CrrCinRJ.mjs +20 -0
  9. package/dist/{c8y-api-DSs1xIIh.mjs → env-fUAhIWsI.mjs} +76 -3
  10. package/dist/{env-file-BrOTmVjA.mjs → env-file-DWbaFlIp.mjs} +1 -2
  11. package/dist/{index-BEFoDy-y.d.mts → index-CmIQfBpj.d.mts} +56 -22
  12. package/dist/index.d.mts +4 -6
  13. package/dist/index.mjs +20 -21
  14. package/dist/{logging-BUYT8Z2V.mjs → logging-B1PuWdiF.mjs} +1 -2
  15. package/dist/{manifest-DV3UW9Dl.mjs → manifest-BhrHDm8Y.mjs} +4 -6
  16. package/dist/{options-54uJmuUV.mjs → options-CgTptIpI.mjs} +5 -6
  17. package/dist/{package-Dqlz6AoZ.mjs → package-y3p_br3i.mjs} +2 -3
  18. package/dist/{roles-CNCObfa3.mjs → roles-DEIHe2Xt.mjs} +4 -5
  19. package/dist/runtime/handlers/invalidateTenantOptions.mjs +2 -408
  20. package/dist/runtime/handlers/liveness-readiness.mjs +1 -2
  21. package/dist/runtime/handlers/openapi.mjs +4 -5
  22. package/dist/runtime/handlers/rewriteRequestUrl.mjs +3 -4
  23. package/dist/runtime/middlewares/c8y-client.mjs +3 -4
  24. package/dist/runtime/middlewares/dev-user.dev.mjs +2 -3
  25. package/dist/runtime/plugins/c8y-variables.dev.mjs +2 -3
  26. package/dist/runtime/plugins/enrich-logs.mjs +2 -3
  27. package/dist/scope-CxEPZ74p.mjs +2763 -0
  28. package/dist/{client-BmSKIcuq.mjs → tenant-0MBgBY_o.mjs} +115 -139
  29. package/dist/testing/setup.d.mts +1 -0
  30. package/dist/testing/setup.mjs +62 -0
  31. package/dist/testing/vite.d.mts +26 -0
  32. package/dist/testing/vite.mjs +58 -0
  33. package/dist/testing-D5VjeD-F.mjs +14 -0
  34. package/dist/testing.d.mts +521 -0
  35. package/dist/testing.mjs +494 -0
  36. package/dist/{typegen-BO_AkyNc.mjs → typegen-Bw4VxFmA.mjs} +4 -5
  37. package/dist/types-D663gk80.d.mts +140 -0
  38. package/dist/types.d.mts +3 -3
  39. package/dist/utils.d.mts +48 -66
  40. package/dist/utils.mjs +16 -27
  41. package/package.json +36 -19
@@ -0,0 +1,2763 @@
1
+ import { t as DEFAULTS } from "./defaults-CrrCinRJ.mjs";
2
+ import process from "node:process";
3
+ import { Buffer } from "node:buffer";
4
+ import { useRuntimeConfig } from "nitro/runtime-config";
5
+ import { TestRunner, beforeEach } from "vitest";
6
+ // src/testing/store.ts
7
+ const CHILD_KINDS = [
8
+ "childDevices",
9
+ "childAssets",
10
+ "childAdditions"
11
+ ];
12
+ const PARENT_KIND = {
13
+ childDevices: "deviceParents",
14
+ childAssets: "assetParents",
15
+ childAdditions: "additionParents"
16
+ };
17
+ function emptyTenant(tenant) {
18
+ return {
19
+ tenant,
20
+ managedObjects: /* @__PURE__ */ new Map(),
21
+ children: /* @__PURE__ */ new Map(),
22
+ externalIds: [],
23
+ measurements: /* @__PURE__ */ new Map(),
24
+ events: /* @__PURE__ */ new Map(),
25
+ alarms: /* @__PURE__ */ new Map(),
26
+ operations: /* @__PURE__ */ new Map(),
27
+ binaries: /* @__PURE__ */ new Map(),
28
+ eventBinaries: /* @__PURE__ */ new Map()
29
+ };
30
+ }
31
+ const store = {
32
+ nextId: 1,
33
+ tenants: /* @__PURE__ */ new Map(),
34
+ /**
35
+ * Refs from `c8y.device()`/`c8y.managedObject()` → ids in the current test.
36
+ */
37
+ refs: /* @__PURE__ */ new Map(),
38
+ /**
39
+ * True while a test's arranged data is rebuilt: no notifications then.
40
+ */
41
+ silent: false,
42
+ reset() {
43
+ this.nextId = 1;
44
+ this.tenants.clear();
45
+ this.refs.clear();
46
+ },
47
+ tenant(tenant) {
48
+ let data = this.tenants.get(tenant);
49
+ if (!data) {
50
+ data = emptyTenant(tenant);
51
+ this.tenants.set(tenant, data);
52
+ }
53
+ return data;
54
+ },
55
+ newId() {
56
+ return String(this.nextId++);
57
+ },
58
+ now() {
59
+ return (/* @__PURE__ */ new Date()).toISOString();
60
+ }
61
+ };
62
+ /**
63
+ * Child ids of a managed object.
64
+ * @param data - Tenant data
65
+ * @param parentId - Managed object id
66
+ * @param kind - Relation kind
67
+ */
68
+ function childIds(data, parentId, kind) {
69
+ return data.children.get(parentId)?.[kind] ?? [];
70
+ }
71
+ /**
72
+ * Parent ids of a managed object for a relation kind.
73
+ * @param data - Tenant data
74
+ * @param childId - Managed object id
75
+ * @param kind - Relation kind (the parent's children list)
76
+ */
77
+ function parentIds(data, childId, kind) {
78
+ const parents = [];
79
+ for (const [parentId, relations] of data.children) if (relations[kind].includes(childId)) parents.push(parentId);
80
+ return parents;
81
+ }
82
+ /**
83
+ * Adds a child reference (idempotent).
84
+ * @param data - Tenant data
85
+ * @param parentId - Parent managed object id
86
+ * @param kind - Relation kind
87
+ * @param childId - Child managed object id
88
+ */
89
+ function addChild(data, parentId, kind, childId) {
90
+ let relations = data.children.get(parentId);
91
+ if (!relations) {
92
+ relations = {
93
+ childDevices: [],
94
+ childAssets: [],
95
+ childAdditions: []
96
+ };
97
+ data.children.set(parentId, relations);
98
+ }
99
+ if (!relations[kind].includes(childId)) relations[kind].push(childId);
100
+ }
101
+ /**
102
+ * Removes a child reference.
103
+ * @param data - Tenant data
104
+ * @param parentId - Parent managed object id
105
+ * @param kind - Relation kind
106
+ * @param childId - Child managed object id
107
+ * @returns Whether the reference existed
108
+ */
109
+ function removeChild(data, parentId, kind, childId) {
110
+ const relations = data.children.get(parentId);
111
+ const index = relations?.[kind].indexOf(childId) ?? -1;
112
+ if (index === -1) return false;
113
+ relations[kind].splice(index, 1);
114
+ return true;
115
+ }
116
+ /**
117
+ * Deletes a managed object, its references (both directions), and its
118
+ * external ids. Measurements, events, alarms, and operations of the object are
119
+ * kept (documented assumption).
120
+ * @param data - Tenant data
121
+ * @param id - Managed object id
122
+ */
123
+ function deleteManagedObject(data, id) {
124
+ if (!data.managedObjects.delete(id)) return false;
125
+ data.children.delete(id);
126
+ for (const relations of data.children.values()) for (const kind of CHILD_KINDS) relations[kind] = relations[kind].filter((childId) => childId !== id);
127
+ data.externalIds = data.externalIds.filter((entry) => entry.managedObjectId !== id);
128
+ data.binaries.delete(id);
129
+ return true;
130
+ }
131
+ // src/testing/capabilities.generated.ts
132
+ /**
133
+ * Query parameters and required body fields of the endpoints the fake tenant emulates.
134
+ */
135
+ const C8Y_ENDPOINTS = {
136
+ "GET /inventory/managedObjects": { query: [
137
+ "childAdditionId",
138
+ "childAssetId",
139
+ "childDeviceId",
140
+ "currentPage",
141
+ "fragmentType",
142
+ "ids",
143
+ "onlyRoots",
144
+ "owner",
145
+ "pageSize",
146
+ "q",
147
+ "query",
148
+ "skipChildrenNames",
149
+ "text",
150
+ "type",
151
+ "withChildren",
152
+ "withChildrenCount",
153
+ "withGroups",
154
+ "withLatestValues",
155
+ "withParents",
156
+ "withTotalElements",
157
+ "withTotalPages"
158
+ ] },
159
+ "POST /inventory/managedObjects": { query: [] },
160
+ "GET /inventory/managedObjects/{id}": { query: [
161
+ "skipChildrenNames",
162
+ "withChildren",
163
+ "withChildrenCount",
164
+ "withLatestValues",
165
+ "withParents"
166
+ ] },
167
+ "PUT /inventory/managedObjects/{id}": { query: [] },
168
+ "DELETE /inventory/managedObjects/{id}": { query: [
169
+ "cascade",
170
+ "forceCascade",
171
+ "withDeviceUser"
172
+ ] },
173
+ "GET /inventory/managedObjects/{id}/childDevices": { query: [
174
+ "currentPage",
175
+ "pageSize",
176
+ "query",
177
+ "withChildren",
178
+ "withChildrenCount",
179
+ "withTotalElements",
180
+ "withTotalPages"
181
+ ] },
182
+ "POST /inventory/managedObjects/{id}/childDevices": { query: [] },
183
+ "GET /inventory/managedObjects/{id}/childAssets": { query: [
184
+ "currentPage",
185
+ "pageSize",
186
+ "query",
187
+ "withChildren",
188
+ "withChildrenCount",
189
+ "withTotalElements",
190
+ "withTotalPages"
191
+ ] },
192
+ "POST /inventory/managedObjects/{id}/childAssets": { query: [] },
193
+ "GET /inventory/managedObjects/{id}/childAdditions": { query: [
194
+ "currentPage",
195
+ "pageSize",
196
+ "query",
197
+ "withChildren",
198
+ "withChildrenCount",
199
+ "withTotalElements",
200
+ "withTotalPages"
201
+ ] },
202
+ "POST /inventory/managedObjects/{id}/childAdditions": { query: [] },
203
+ "DELETE /inventory/managedObjects/{id}/childDevices/{childId}": { query: [] },
204
+ "DELETE /inventory/managedObjects/{id}/childAssets/{childId}": { query: [] },
205
+ "DELETE /inventory/managedObjects/{id}/childAdditions/{childId}": { query: [] },
206
+ "GET /identity/externalIds/{type}/{externalId}": { query: [] },
207
+ "DELETE /identity/externalIds/{type}/{externalId}": { query: [] },
208
+ "GET /identity/globalIds/{id}/externalIds": { query: [] },
209
+ "POST /identity/globalIds/{id}/externalIds": {
210
+ query: [],
211
+ required: ["externalId", "type"]
212
+ },
213
+ "GET /measurement/measurements": { query: [
214
+ "currentPage",
215
+ "dateFrom",
216
+ "dateTo",
217
+ "pageSize",
218
+ "revert",
219
+ "source",
220
+ "type",
221
+ "valueFragmentSeries",
222
+ "valueFragmentType",
223
+ "withTotalElements",
224
+ "withTotalPages"
225
+ ] },
226
+ "POST /measurement/measurements": {
227
+ query: [],
228
+ required: [
229
+ "source",
230
+ "time",
231
+ "type"
232
+ ]
233
+ },
234
+ "GET /measurement/measurements/{id}": { query: [] },
235
+ "DELETE /measurement/measurements/{id}": { query: [] },
236
+ "GET /event/events": { query: [
237
+ "createdFrom",
238
+ "createdTo",
239
+ "currentPage",
240
+ "dateFrom",
241
+ "dateTo",
242
+ "fragmentType",
243
+ "fragmentValue",
244
+ "lastUpdatedFrom",
245
+ "lastUpdatedTo",
246
+ "pageSize",
247
+ "revert",
248
+ "source",
249
+ "type",
250
+ "withSourceAdditions",
251
+ "withSourceAssets",
252
+ "withSourceChildren",
253
+ "withSourceDevices",
254
+ "withTotalElements",
255
+ "withTotalPages"
256
+ ] },
257
+ "POST /event/events": {
258
+ query: [],
259
+ required: [
260
+ "source",
261
+ "text",
262
+ "time",
263
+ "type"
264
+ ]
265
+ },
266
+ "GET /event/events/{id}": { query: [] },
267
+ "PUT /event/events/{id}": { query: [] },
268
+ "DELETE /event/events/{id}": { query: [] },
269
+ "GET /alarm/alarms": { query: [
270
+ "createdFrom",
271
+ "createdTo",
272
+ "currentPage",
273
+ "dateFrom",
274
+ "dateTo",
275
+ "lastUpdatedFrom",
276
+ "lastUpdatedTo",
277
+ "pageSize",
278
+ "resolved",
279
+ "severity",
280
+ "source",
281
+ "status",
282
+ "type",
283
+ "withSourceAdditions",
284
+ "withSourceAssets",
285
+ "withSourceChildren",
286
+ "withSourceDevices",
287
+ "withTotalElements",
288
+ "withTotalPages"
289
+ ] },
290
+ "POST /alarm/alarms": {
291
+ query: [],
292
+ required: [
293
+ "severity",
294
+ "source",
295
+ "text",
296
+ "time",
297
+ "type"
298
+ ]
299
+ },
300
+ "PUT /alarm/alarms": { query: [
301
+ "createdFrom",
302
+ "createdTo",
303
+ "dateFrom",
304
+ "dateTo",
305
+ "resolved",
306
+ "severity",
307
+ "source",
308
+ "status",
309
+ "withSourceAdditions",
310
+ "withSourceAssets",
311
+ "withSourceChildren",
312
+ "withSourceDevices"
313
+ ] },
314
+ "GET /alarm/alarms/{id}": { query: [] },
315
+ "PUT /alarm/alarms/{id}": { query: [] },
316
+ "GET /devicecontrol/operations": { query: [
317
+ "agentId",
318
+ "bulkOperationId",
319
+ "currentPage",
320
+ "dateFrom",
321
+ "dateTo",
322
+ "deviceId",
323
+ "fragmentType",
324
+ "pageSize",
325
+ "revert",
326
+ "status",
327
+ "withTotalElements",
328
+ "withTotalPages"
329
+ ] },
330
+ "POST /devicecontrol/operations": {
331
+ query: [],
332
+ required: ["deviceId"]
333
+ },
334
+ "GET /devicecontrol/operations/{id}": { query: [] },
335
+ "PUT /devicecontrol/operations/{id}": {
336
+ query: [],
337
+ required: ["status"]
338
+ },
339
+ "DELETE /measurement/measurements": { query: [
340
+ "dateFrom",
341
+ "dateTo",
342
+ "fragmentType",
343
+ "source",
344
+ "type"
345
+ ] },
346
+ "GET /measurement/measurements/series": { query: [
347
+ "aggregationFunction",
348
+ "aggregationInterval",
349
+ "aggregationType",
350
+ "dateFrom",
351
+ "dateTo",
352
+ "revert",
353
+ "series",
354
+ "source"
355
+ ] },
356
+ "DELETE /event/events": { query: [
357
+ "createdFrom",
358
+ "createdTo",
359
+ "dateFrom",
360
+ "dateTo",
361
+ "fragmentType",
362
+ "source",
363
+ "type"
364
+ ] },
365
+ "POST /event/events/{id}/binaries": { query: [] },
366
+ "GET /event/events/{id}/binaries": { query: [] },
367
+ "PUT /event/events/{id}/binaries": { query: [] },
368
+ "DELETE /event/events/{id}/binaries": { query: [] },
369
+ "POST /inventory/binaries": {
370
+ query: [],
371
+ required: ["file", "object"]
372
+ },
373
+ "GET /inventory/binaries": { query: [
374
+ "childAdditionId",
375
+ "childAssetId",
376
+ "childDeviceId",
377
+ "currentPage",
378
+ "ids",
379
+ "owner",
380
+ "pageSize",
381
+ "type",
382
+ "withTotalPages"
383
+ ] },
384
+ "GET /inventory/binaries/{id}": { query: [] },
385
+ "PUT /inventory/binaries/{id}": { query: [] },
386
+ "DELETE /inventory/binaries/{id}": { query: [] },
387
+ "POST /notification2/subscriptions": {
388
+ query: [],
389
+ required: ["context", "subscription"]
390
+ },
391
+ "GET /notification2/subscriptions": { query: [
392
+ "context",
393
+ "currentPage",
394
+ "pageSize",
395
+ "source",
396
+ "subscription",
397
+ "typeFilter",
398
+ "withTotalElements",
399
+ "withTotalPages"
400
+ ] },
401
+ "DELETE /notification2/subscriptions": { query: ["context", "source"] },
402
+ "GET /notification2/subscriptions/{id}": { query: [] },
403
+ "DELETE /notification2/subscriptions/{id}": { query: [] },
404
+ "POST /notification2/token": {
405
+ query: [],
406
+ required: ["subscriber", "subscription"]
407
+ },
408
+ "POST /notification2/unsubscribe": { query: ["token"] }
409
+ };
410
+ /**
411
+ * Enum values from the spec.
412
+ */
413
+ const C8Y_ENUMS = {
414
+ alarmSeverity: [
415
+ "CRITICAL",
416
+ "MAJOR",
417
+ "MINOR",
418
+ "WARNING"
419
+ ],
420
+ alarmStatus: [
421
+ "ACTIVE",
422
+ "ACKNOWLEDGED",
423
+ "CLEARED"
424
+ ],
425
+ operationStatus: [
426
+ "SUCCESSFUL",
427
+ "FAILED",
428
+ "EXECUTING",
429
+ "PENDING"
430
+ ],
431
+ seriesAggregationType: [
432
+ "DAILY",
433
+ "HOURLY",
434
+ "MINUTELY"
435
+ ],
436
+ seriesAggregationFunction: [
437
+ "min",
438
+ "max",
439
+ "avg",
440
+ "sum",
441
+ "count",
442
+ "stdDevPop",
443
+ "stdDevSamp"
444
+ ]
445
+ };
446
+ // src/testing/api/http.ts
447
+ /**
448
+ * Thrown by an API handler for a request the fake cannot answer faithfully
449
+ * (an endpoint feature it does not emulate). The test fails with the reason.
450
+ */
451
+ var UnsupportedRequestError = class extends Error {};
452
+ const rawRequests = /* @__PURE__ */ new WeakMap();
453
+ /**
454
+ * The raw parts of a request.
455
+ * @param call - The request
456
+ */
457
+ function raw(call) {
458
+ return rawRequests.get(call) ?? {
459
+ bytes: /* @__PURE__ */ new Uint8Array(),
460
+ contentType: "",
461
+ params: new URLSearchParams(call.query)
462
+ };
463
+ }
464
+ /**
465
+ * Base URL used in `self`/`next` links.
466
+ */
467
+ function baseUrl() {
468
+ return (process.env.C8Y_BASEURL || DEFAULTS.baseUrl).replace(/\/+$/, "");
469
+ }
470
+ /**
471
+ * Cumulocity-style error response.
472
+ * @param status - HTTP status
473
+ * @param error - Error code, e.g. `inventory/Not Found`
474
+ * @param message - Human-readable message
475
+ */
476
+ function apiError(status, error, message) {
477
+ return Object.assign(Response.json({
478
+ error,
479
+ message,
480
+ info: "https://cumulocity.com/guides/reference/rest-implementation"
481
+ }, { status }), { message });
482
+ }
483
+ /**
484
+ * Validates query parameters against the spec: parameters the real endpoint
485
+ * knows but the fake does not emulate fail loudly; unknown parameters are
486
+ * ignored, like the real API does.
487
+ * @param endpoint - Endpoint key, e.g. `GET /inventory/managedObjects`
488
+ * @param call - The request
489
+ * @param supported - Parameters this fake endpoint emulates
490
+ */
491
+ function checkQuery(endpoint, call, supported) {
492
+ const known = C8Y_ENDPOINTS[endpoint].query;
493
+ for (const [name, value] of Object.entries(call.query)) if (known.includes(name) && !supported.includes(name) && value !== "" && value !== "false") throw new UnsupportedRequestError(`query parameter "${name}" of ${endpoint} is not emulated`);
494
+ }
495
+ /**
496
+ * Returns a 422 response if required body fields (from the spec) are missing.
497
+ * @param endpoint - Endpoint key
498
+ * @param body - Request body
499
+ */
500
+ function checkRequired(endpoint, body) {
501
+ const missing = (C8Y_ENDPOINTS[endpoint].required ?? []).filter((field) => body[field] === void 0 || body[field] === null);
502
+ if (missing.length > 0) return apiError(422, "undefined/validationError", `Following mandatory fields should be included: ${missing.join(",")}`);
503
+ }
504
+ /**
505
+ * Reads a boolean query parameter.
506
+ * @param call
507
+ * @param name
508
+ * @param fallback
509
+ */
510
+ function flag(call, name, fallback = false) {
511
+ const value = call.query[name];
512
+ return value === void 0 ? fallback : value === "true";
513
+ }
514
+ /**
515
+ * Builds a paged collection response like Cumulocity's: `statistics`, `self`,
516
+ * and `next`/`prev` links (`next` always, `prev` after the first page).
517
+ * @param call - The request (paging params)
518
+ * @param collection - Property name, e.g. `managedObjects`
519
+ * @param items - All matching items, in result order
520
+ * @param path - Collection path, e.g. `/inventory/managedObjects`
521
+ * @param options - Paging options
522
+ * @param options.statistics - `binaries`: always `totalPages`, never `totalElements`
523
+ */
524
+ function paged(call, collection, items, path, options = {}) {
525
+ for (const name of ["pageSize", "currentPage"]) {
526
+ const value = call.query[name];
527
+ if (value !== void 0 && !/^-?\d+$/.test(value)) return apiError(422, "undefined/validationError", `Parameter '${name}' must have an integer value!`);
528
+ }
529
+ const pageSize = Math.min(Math.max(Number.parseInt(call.query.pageSize ?? "5", 10), 1), 2e3);
530
+ const currentPage = Math.max(Number.parseInt(call.query.currentPage ?? "1", 10), 1);
531
+ const page = items.slice((currentPage - 1) * pageSize, currentPage * pageSize);
532
+ const link = (pageNumber) => {
533
+ const params = new URLSearchParams({
534
+ ...call.query,
535
+ pageSize: String(pageSize),
536
+ currentPage: String(pageNumber)
537
+ });
538
+ return `${baseUrl()}${path}?${params}`;
539
+ };
540
+ const statistics = {
541
+ currentPage,
542
+ pageSize
543
+ };
544
+ const totalPages = Math.ceil(items.length / pageSize);
545
+ const withTotalPages = options.statistics === "binaries" || flag(call, "withTotalPages");
546
+ if (withTotalPages) statistics.totalPages = totalPages;
547
+ if (options.statistics !== "binaries" && flag(call, "withTotalElements")) statistics.totalElements = items.length;
548
+ return Response.json({
549
+ self: link(currentPage),
550
+ [collection]: page,
551
+ statistics,
552
+ ...withTotalPages && currentPage >= totalPages ? {} : { next: link(currentPage + 1) },
553
+ ...currentPage > 1 ? { prev: link(currentPage - 1) } : {}
554
+ });
555
+ }
556
+ /**
557
+ * Filters documents by an inclusive-from, exclusive-to date range on a field.
558
+ * @param value - ISO date of the document
559
+ * @param from - Range start (inclusive)
560
+ * @param to - Range end (exclusive)
561
+ */
562
+ function inRange(value, from, to) {
563
+ const time = Date.parse(String(value));
564
+ if (from !== void 0 && time < Date.parse(from)) return false;
565
+ if (to !== void 0 && time >= Date.parse(to)) return false;
566
+ return true;
567
+ }
568
+ /**
569
+ * Sorts documents by a date field.
570
+ * @param items - Documents
571
+ * @param field - Date field
572
+ * @param newestFirst - Sort descending
573
+ */
574
+ function byTime(items, field, newestFirst) {
575
+ return [...items].sort((a, b) => {
576
+ const diff = Date.parse(String(a[field])) - Date.parse(String(b[field]));
577
+ return newestFirst ? -diff : diff;
578
+ });
579
+ }
580
+ /**
581
+ * The source id of a measurement/event/alarm.
582
+ * @param document
583
+ */
584
+ function sourceId(document) {
585
+ return document.source?.id;
586
+ }
587
+ // src/testing/api/query.ts
588
+ /**
589
+ * A query the platform would reject (answered with 422).
590
+ */
591
+ var QuerySyntaxError = class extends Error {};
592
+ const COMPARISONS = /* @__PURE__ */ new Set([
593
+ "eq",
594
+ "gt",
595
+ "ge",
596
+ "lt",
597
+ "le"
598
+ ]);
599
+ function tokenize(input) {
600
+ const tokens = [];
601
+ let i = 0;
602
+ while (i < input.length) {
603
+ const char = input[i];
604
+ if (/\s/.test(char)) i++;
605
+ else if (char === "(" || char === ")" || char === ",") {
606
+ tokens.push({
607
+ kind: "punct",
608
+ value: char
609
+ });
610
+ i++;
611
+ } else if (char === "'") {
612
+ let value = "";
613
+ i++;
614
+ while (i < input.length) if (input[i] === "'" && input[i + 1] === "'") {
615
+ value += "'";
616
+ i += 2;
617
+ } else if (input[i] === "'") break;
618
+ else value += input[i++];
619
+ if (i >= input.length) throw new QuerySyntaxError("unterminated string");
620
+ i++;
621
+ tokens.push({
622
+ kind: "string",
623
+ value
624
+ });
625
+ } else {
626
+ const value = /^[^\s(),']+/.exec(input.slice(i))[0];
627
+ tokens.push({
628
+ kind: "word",
629
+ value
630
+ });
631
+ i += value.length;
632
+ }
633
+ }
634
+ return tokens;
635
+ }
636
+ function literal(token) {
637
+ if (token.kind === "string") return token.value;
638
+ if (token.value === "null") return null;
639
+ if (token.value === "true" || token.value === "false") return token.value === "true";
640
+ if (/^-?\d+[fd]?$/i.test(token.value)) return Number.parseInt(token.value, 10);
641
+ const decimal = /^(-?\d+\.\d+)[fd]$/i.exec(token.value);
642
+ if (decimal) return Number(decimal[1]);
643
+ return token.value;
644
+ }
645
+ /**
646
+ * Reads a dotted path from a document. Arrays on the way are flattened, so the
647
+ * result is a list of all values at that path.
648
+ * @param document - The document
649
+ * @param path - e.g. `c8y_Hardware.serialNumber` or `customers.id`
650
+ */
651
+ function readValues(document, path) {
652
+ let values = [document];
653
+ for (const key of path.split(".")) values = values.flatMap((value) => {
654
+ return (Array.isArray(value) ? value : [value]).flatMap((item) => item && typeof item === "object" && key in item ? [item[key]] : []);
655
+ });
656
+ return values.flatMap((value) => Array.isArray(value) ? value : [value]);
657
+ }
658
+ /**
659
+ * Reads a single value from a dotted path (first match).
660
+ * @param document - The document
661
+ * @param path - Dotted path
662
+ */
663
+ function readPath(document, path) {
664
+ return readValues(document, path)[0];
665
+ }
666
+ function compareOne(actual, operator, expected, asDate) {
667
+ if (expected === null) return operator === "eq" && actual === null;
668
+ if (actual === null || actual === void 0 || typeof actual === "object") return false;
669
+ if (asDate) {
670
+ const left = Date.parse(String(actual));
671
+ const right = Date.parse(String(expected));
672
+ switch (operator) {
673
+ case "eq": return left === right;
674
+ case "gt": return left > right;
675
+ case "ge": return left >= right;
676
+ case "lt": return left < right;
677
+ default: return left <= right;
678
+ }
679
+ }
680
+ if (typeof actual !== typeof expected) return false;
681
+ if (operator === "eq") {
682
+ if (typeof expected === "string" && expected.includes("*")) {
683
+ const pattern = expected.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
684
+ return new RegExp(`^${pattern}$`, "i").test(String(actual));
685
+ }
686
+ return actual === expected;
687
+ }
688
+ if (typeof expected === "boolean") return false;
689
+ const left = actual;
690
+ const right = expected;
691
+ switch (operator) {
692
+ case "gt": return left > right;
693
+ case "ge": return left >= right;
694
+ case "lt": return left < right;
695
+ default: return left <= right;
696
+ }
697
+ }
698
+ function descendants(data, roots) {
699
+ const result = /* @__PURE__ */ new Set();
700
+ const queue = [...roots];
701
+ while (queue.length > 0) {
702
+ const id = queue.shift();
703
+ if (result.has(id)) continue;
704
+ result.add(id);
705
+ for (const kind of CHILD_KINDS) queue.push(...childIds(data, id, kind));
706
+ }
707
+ return result;
708
+ }
709
+ function parseFilter(input, data) {
710
+ const tokens = tokenize(input);
711
+ let position = 0;
712
+ const peek = () => tokens[position];
713
+ const next = () => {
714
+ const token = tokens[position++];
715
+ if (!token) throw new QuerySyntaxError("unexpected end");
716
+ return token;
717
+ };
718
+ const expect = (value) => {
719
+ if (next().value !== value) throw new QuerySyntaxError(`expected "${value}"`);
720
+ };
721
+ const argumentList = () => {
722
+ expect("(");
723
+ const args = [];
724
+ while (peek()?.value !== ")") {
725
+ args.push(next().value);
726
+ if (peek()?.value === ",") next();
727
+ }
728
+ expect(")");
729
+ if (args.length === 0) throw new QuerySyntaxError("missing function argument");
730
+ return args;
731
+ };
732
+ function parseOr() {
733
+ let left = parseAnd();
734
+ while (peek()?.kind === "word" && peek().value === "or") {
735
+ next();
736
+ const a = left;
737
+ const b = parseAnd();
738
+ left = (document) => a(document) || b(document);
739
+ }
740
+ return left;
741
+ }
742
+ function parseAnd() {
743
+ let left = parseUnary();
744
+ while (peek()?.kind === "word" && peek().value === "and") {
745
+ next();
746
+ const a = left;
747
+ const b = parseUnary();
748
+ left = (document) => a(document) && b(document);
749
+ }
750
+ return left;
751
+ }
752
+ function parseUnary() {
753
+ const token = next();
754
+ if (token.value === "(") {
755
+ const inner = parseOr();
756
+ expect(")");
757
+ return inner;
758
+ }
759
+ if (token.kind !== "word") throw new QuerySyntaxError(`unexpected "${token.value}"`);
760
+ if (token.value === "not") {
761
+ expect("(");
762
+ const inner = parseOr();
763
+ expect(")");
764
+ return (document) => !inner(document);
765
+ }
766
+ if (peek()?.value === "(") {
767
+ const args = argumentList();
768
+ switch (token.value) {
769
+ case "has":
770
+ if (args.length !== 1) throw new QuerySyntaxError("has() accepts one argument");
771
+ return (document) => readValues(document, args[0]).length > 0;
772
+ case "hasany": return (document) => args.some((fragment) => readValues(document, fragment).length > 0);
773
+ case "bygroupid": return (document) => args.some((group) => childIds(data, group, "childAssets").includes(String(document.id)));
774
+ case "isinhierarchyof": return (document) => descendants(data, args).has(String(document.id));
775
+ default: throw new QuerySyntaxError(`unknown function ${token.value}()`);
776
+ }
777
+ }
778
+ const operator = next();
779
+ if (!COMPARISONS.has(operator.value)) throw new QuerySyntaxError(`unknown operator "${operator.value}"`);
780
+ const value = literal(next());
781
+ const asDate = token.value.endsWith(".date");
782
+ const path = asDate ? token.value.slice(0, -5) : token.value;
783
+ return (document) => {
784
+ const values = readValues(document, path);
785
+ return values.length === 0 ? compareOne(void 0, operator.value, value, asDate) : values.some((actual) => compareOne(actual, operator.value, value, asDate));
786
+ };
787
+ }
788
+ const filter = parseOr();
789
+ if (position < tokens.length) throw new QuerySyntaxError(`unexpected "${tokens[position].value}"`);
790
+ return filter;
791
+ }
792
+ /**
793
+ * Parses a `query`/`q` parameter value.
794
+ * @param raw - e.g. `$filter=(name eq 'Pump*') $orderby=name asc`
795
+ * @param data - Tenant data (for `bygroupid()` and `isinhierarchyof()`)
796
+ */
797
+ function parseQuery(raw, data) {
798
+ const orderByMatch = /\$orderby=(.*)$/.exec(raw);
799
+ const filterSource = (/\$filter=(.*?)(?:\s*\$orderby=.*)?$/.exec(raw)?.[1] ?? (orderByMatch ? raw.slice(0, orderByMatch.index) : raw)).trim();
800
+ const orderBy = (orderByMatch?.[1] ?? "").split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
801
+ const [field, direction] = part.split(/\s+/);
802
+ return {
803
+ field,
804
+ descending: direction?.toLowerCase() === "desc"
805
+ };
806
+ });
807
+ return {
808
+ filter: filterSource ? parseFilter(filterSource, data) : () => true,
809
+ orderBy
810
+ };
811
+ }
812
+ /**
813
+ * Sorts documents by `$orderby` fields.
814
+ * @param items - Documents
815
+ * @param orderBy - Sort fields
816
+ */
817
+ function applyOrderBy(items, orderBy) {
818
+ if (orderBy.length === 0) return items;
819
+ const rank = (value) => value === void 0 || value === null ? 0 : typeof value === "number" ? 1 : 2;
820
+ return [...items].sort((a, b) => {
821
+ for (const { field, descending } of orderBy) {
822
+ let left = readPath(a, field);
823
+ let right = readPath(b, field);
824
+ if (field === "id") {
825
+ left = Number(left);
826
+ right = Number(right);
827
+ }
828
+ if (left === right || rank(left) === 0 && rank(right) === 0) continue;
829
+ const result = rank(left) !== rank(right) ? rank(left) - rank(right) : left < right ? -1 : 1;
830
+ return descending ? -result : result;
831
+ }
832
+ return 0;
833
+ });
834
+ }
835
+ // src/testing/realtime.ts
836
+ /**
837
+ * Notification 2.0 in the fake tenant: subscriptions, tokens, and consumer
838
+ * WebSockets (in memory). Data changes are published to matching
839
+ * subscriptions as frames in the platform's format:
840
+ *
841
+ * ```
842
+ * <ackId>
843
+ * /<tenant>/<api>/<sourceId>
844
+ * CREATE | UPDATE | DELETE
845
+ *
846
+ * <JSON payload>
847
+ * ```
848
+ *
849
+ * Unlike the data, this state lives as long as the test file's Nitro app: a
850
+ * Nitro plugin that subscribes at startup stays subscribed for every test.
851
+ * Undelivered notifications are dropped when the data is reset.
852
+ */
853
+ /**
854
+ * Set by the platform: requests being answered (handlers may be writing).
855
+ */
856
+ let platformInflight = () => 0;
857
+ function trackInflight(count) {
858
+ platformInflight = count;
859
+ }
860
+ const CONNECTING = 0;
861
+ const OPEN = 1;
862
+ const CLOSING = 2;
863
+ const CLOSED = 3;
864
+ /**
865
+ * Standard properties, which `fragmentsToCopy` always keeps (verified for
866
+ * events; assumed for the other APIs).
867
+ */
868
+ const STANDARD_FIELDS = /* @__PURE__ */ new Set([
869
+ "id",
870
+ "self",
871
+ "type",
872
+ "text",
873
+ "time",
874
+ "source",
875
+ "creationTime",
876
+ "lastUpdated",
877
+ "severity",
878
+ "status",
879
+ "count",
880
+ "firstOccurrenceTime",
881
+ "deviceId",
882
+ "deviceName",
883
+ "description",
884
+ "failureReason",
885
+ "name",
886
+ "owner"
887
+ ]);
888
+ /**
889
+ * With `fragmentsToCopy`, the platform delivers only the named fragments next
890
+ * to the standard properties.
891
+ * @param document - The rendered object
892
+ * @param fragments - The subscription's `fragmentsToCopy`
893
+ */
894
+ function copyFragments(document, fragments) {
895
+ if (!fragments || fragments.length === 0) return document;
896
+ return Object.fromEntries(Object.entries(document).filter(([key]) => STANDARD_FIELDS.has(key) || fragments.includes(key)));
897
+ }
898
+ function typesOf(typeFilter) {
899
+ if (!typeFilter) return;
900
+ const quoted = [...typeFilter.matchAll(/'([^']*)'/g)].map((match) => match[1]);
901
+ return quoted.length > 0 ? quoted : typeFilter.split(/\s+or\s+/).map((type) => type.trim());
902
+ }
903
+ const realtime = {
904
+ nextId: 1,
905
+ nextAck: 1,
906
+ subscriptions: [],
907
+ tokens: /* @__PURE__ */ new Map(),
908
+ sockets: /* @__PURE__ */ new Set(),
909
+ /**
910
+ * frames waiting for a consumer of a subscription name (persistent subscriptions)
911
+ */
912
+ backlog: /* @__PURE__ */ new Map(),
913
+ /**
914
+ * delivered but not yet acknowledged frames, per socket
915
+ */
916
+ unacked: /* @__PURE__ */ new Map(),
917
+ /**
918
+ * frames scheduled for delivery
919
+ */
920
+ scheduled: 0,
921
+ createSubscription(subscription) {
922
+ const created = {
923
+ ...subscription,
924
+ id: String(this.nextId++)
925
+ };
926
+ this.subscriptions.push(created);
927
+ return created;
928
+ },
929
+ createToken(token) {
930
+ const value = `fake-token-${this.nextId++}-${token.subscription}`;
931
+ this.tokens.set(value, token);
932
+ return value;
933
+ },
934
+ connect(socket) {
935
+ const token = this.tokens.get(new URL(socket.url).searchParams.get("token") ?? "");
936
+ if (!token || socket.readyState === CLOSED) {
937
+ socket.readyState = CLOSED;
938
+ socket.emit("error", /* @__PURE__ */ new Error("Invalid notification token"));
939
+ socket.emit("close", 1008, "invalid token");
940
+ return;
941
+ }
942
+ socket.token = token;
943
+ socket.readyState = OPEN;
944
+ this.sockets.add(socket);
945
+ this.unacked.set(socket, /* @__PURE__ */ new Set());
946
+ socket.emit("open");
947
+ const waiting = this.backlog.get(this.key(token.tenant, token.subscription)) ?? [];
948
+ this.backlog.delete(this.key(token.tenant, token.subscription));
949
+ for (const frame of waiting) this.deliver(socket, frame);
950
+ },
951
+ disconnect(socket) {
952
+ this.sockets.delete(socket);
953
+ this.unacked.delete(socket);
954
+ },
955
+ acknowledge(socket, ackId) {
956
+ this.unacked.get(socket)?.delete(ackId);
957
+ },
958
+ key(tenant, subscription) {
959
+ return `${tenant}\u0000${subscription}`;
960
+ },
961
+ deliver(socket, frame) {
962
+ this.scheduled++;
963
+ setTimeout(() => {
964
+ this.scheduled--;
965
+ if (socket.readyState !== OPEN) return;
966
+ this.unacked.get(socket)?.add(frame.ackId);
967
+ socket.emit("message", Buffer.from(frame.text));
968
+ }, 0);
969
+ },
970
+ /**
971
+ * Publishes a data change to every matching subscription.
972
+ * @param tenant - Tenant of the data
973
+ * @param api - Notification API, e.g. `measurements`
974
+ * @param action - CREATE, UPDATE, or DELETE
975
+ * @param document - The rendered object (for DELETE: `{ id }` plus its source)
976
+ * @param sourceId - Device id the notification is about
977
+ */
978
+ publish(tenant, api, action, document, sourceId) {
979
+ const names = /* @__PURE__ */ new Map();
980
+ for (const subscription of this.subscriptions) {
981
+ if (subscription.tenant !== tenant) continue;
982
+ const apis = subscription.subscriptionFilter?.apis ?? ["*"];
983
+ if (!apis.includes("*") && !apis.includes(api)) continue;
984
+ if (subscription.context === "mo" && subscription.source?.id !== sourceId) continue;
985
+ const types = typesOf(subscription.subscriptionFilter?.typeFilter);
986
+ if (types && !types.includes(String(document.type))) continue;
987
+ names.set(subscription.subscription, subscription);
988
+ }
989
+ for (const [name, subscription] of names) {
990
+ const ackId = String(this.nextAck++);
991
+ const payload = action === "DELETE" ? `com.cumulocity.notification.service.RealtimeDeletedObject$1@${(this.nextAck * 2654435761 >>> 0).toString(16)}` : JSON.stringify(copyFragments(document, subscription.fragmentsToCopy));
992
+ const frame = {
993
+ ackId,
994
+ text: `${ackId}\n/${tenant}/${api}/${sourceId ?? document.id}\n${action}\n\n${payload}`
995
+ };
996
+ const consumers = [...this.sockets].filter((socket) => socket.token?.tenant === tenant && socket.token.subscription === name);
997
+ if (consumers.length > 0) for (const socket of consumers) this.deliver(socket, frame);
998
+ else if (!subscription.nonPersistent) {
999
+ const key = this.key(tenant, name);
1000
+ this.backlog.set(key, [...this.backlog.get(key) ?? [], frame]);
1001
+ }
1002
+ }
1003
+ },
1004
+ /**
1005
+ * Notifications on their way to a consumer or not yet acknowledged.
1006
+ */
1007
+ pending() {
1008
+ let count = this.scheduled + platformInflight();
1009
+ for (const acks of this.unacked.values()) count += acks.size;
1010
+ for (const [key, frames] of this.backlog) {
1011
+ const [tenant, subscription] = key.split("\0");
1012
+ if ([...this.tokens.values()].some((token) => token.tenant === tenant && token.subscription === subscription)) count += frames.length;
1013
+ }
1014
+ return count;
1015
+ },
1016
+ /**
1017
+ * Drops undelivered notifications (data reset between tests).
1018
+ */
1019
+ dropPending() {
1020
+ this.backlog.clear();
1021
+ for (const acks of this.unacked.values()) acks.clear();
1022
+ }
1023
+ };
1024
+ /**
1025
+ * In-memory stand-in for the `ws` WebSocket c8y-realtime opens for a consumer.
1026
+ */
1027
+ var FakeWebSocket = class {
1028
+ static CONNECTING = CONNECTING;
1029
+ static OPEN = OPEN;
1030
+ static CLOSING = CLOSING;
1031
+ static CLOSED = CLOSED;
1032
+ readyState = CONNECTING;
1033
+ url;
1034
+ token;
1035
+ listeners = /* @__PURE__ */ new Map();
1036
+ constructor(url) {
1037
+ this.url = String(url);
1038
+ setTimeout(() => realtime.connect(this), 0);
1039
+ }
1040
+ on(event, listener) {
1041
+ let set = this.listeners.get(event);
1042
+ if (!set) {
1043
+ set = /* @__PURE__ */ new Set();
1044
+ this.listeners.set(event, set);
1045
+ }
1046
+ set.add(listener);
1047
+ return this;
1048
+ }
1049
+ addEventListener(event, listener) {
1050
+ this.on(event, listener);
1051
+ }
1052
+ removeAllListeners() {
1053
+ this.listeners.clear();
1054
+ return this;
1055
+ }
1056
+ emit(event, ...args) {
1057
+ for (const listener of [...this.listeners.get(event) ?? []]) listener(...args);
1058
+ }
1059
+ /**
1060
+ * The consumer acknowledges a frame by sending its ack id.
1061
+ * @param data
1062
+ */
1063
+ send(data) {
1064
+ realtime.acknowledge(this, String(data).trim());
1065
+ }
1066
+ ping() {
1067
+ setTimeout(() => this.emit("pong"), 0);
1068
+ }
1069
+ close() {
1070
+ if (this.readyState === CLOSED) return;
1071
+ this.readyState = CLOSED;
1072
+ realtime.disconnect(this);
1073
+ setTimeout(() => this.emit("close", 1e3, ""), 0);
1074
+ }
1075
+ terminate() {
1076
+ this.close();
1077
+ }
1078
+ };
1079
+ /**
1080
+ * Waits until the service has finished starting up its realtime clients: no
1081
+ * platform request in flight, and every minted token has a connected socket.
1082
+ * @param timeout - Milliseconds to wait at most
1083
+ */
1084
+ async function waitForRealtimeStartup(timeout = 3e3) {
1085
+ const start = Date.now();
1086
+ let quietTicks = 0;
1087
+ while (quietTicks < 3 && Date.now() - start < timeout) {
1088
+ await new Promise((resolve) => {
1089
+ setTimeout(resolve, 5);
1090
+ });
1091
+ const connected = new Set([...realtime.sockets].map((socket) => socket.token));
1092
+ quietTicks = platformInflight() === 0 && realtime.scheduled === 0 && [...realtime.tokens.values()].every((token) => connected.has(token)) ? quietTicks + 1 : 0;
1093
+ }
1094
+ }
1095
+ // src/testing/notify.ts
1096
+ /**
1097
+ * Publishes a data change as a Notification 2.0 notification, unless the
1098
+ * change is part of rebuilding a test's arranged data (`store.silent`).
1099
+ * @param data - Tenant data the change happened in
1100
+ * @param api - Notification API
1101
+ * @param action - CREATE, UPDATE, or DELETE
1102
+ * @param document - Rendered object
1103
+ * @param sourceId - Device the notification is about
1104
+ */
1105
+ function notify(data, api, action, document, sourceId) {
1106
+ if (!store.silent) realtime.publish(data.tenant, api, action, document, sourceId);
1107
+ }
1108
+ // src/testing/api/inventory.ts
1109
+ const SERVER_FIELDS$1 = [
1110
+ "id",
1111
+ "self",
1112
+ "creationTime",
1113
+ "lastUpdated",
1114
+ ...CHILD_KINDS,
1115
+ ...Object.values(PARENT_KIND)
1116
+ ];
1117
+ function moSelf(id) {
1118
+ return `${baseUrl()}/inventory/managedObjects/${id}`;
1119
+ }
1120
+ function notFound$2(id) {
1121
+ return apiError(404, "inventory/Not Found", `Finding device data from database failed : No managedObject for id '${id}'!`);
1122
+ }
1123
+ /**
1124
+ * Renders a stored managed object like the platform: `self`, child references
1125
+ * (`childDevices`, `childAssets`, `childAdditions`), and parent references.
1126
+ * @param data - Tenant data
1127
+ * @param document - Stored managed object
1128
+ * @param options - Rendering options
1129
+ * @param options.withChildren - Include child references (default true)
1130
+ * @param options.skipChildrenNames - Omit names in references
1131
+ * @param options.withParents
1132
+ */
1133
+ function renderManagedObject(data, document, options = {}) {
1134
+ const self = moSelf(document.id);
1135
+ const reference = (id, referenceSelf) => ({
1136
+ self: referenceSelf,
1137
+ managedObject: {
1138
+ id,
1139
+ self: moSelf(id),
1140
+ ...options.skipChildrenNames ? {} : { name: data.managedObjects.get(id)?.name }
1141
+ }
1142
+ });
1143
+ const rendered = {
1144
+ ...structuredClone(document),
1145
+ self
1146
+ };
1147
+ for (const kind of CHILD_KINDS) {
1148
+ const children = options.withChildren === false ? [] : childIds(data, document.id, kind);
1149
+ rendered[kind] = {
1150
+ self: `${self}/${kind}`,
1151
+ references: children.map((id) => reference(id, `${self}/${kind}/${id}`))
1152
+ };
1153
+ const parentKind = PARENT_KIND[kind];
1154
+ const parents = options.withParents ? parentIds(data, document.id, kind) : [];
1155
+ rendered[parentKind] = {
1156
+ self: `${self}/${parentKind}`,
1157
+ references: parents.flatMap((id) => {
1158
+ const parent = data.managedObjects.get(id);
1159
+ return parent ? [{ managedObject: renderManagedObject(data, parent, { withChildren: false }) }] : [];
1160
+ })
1161
+ };
1162
+ }
1163
+ return rendered;
1164
+ }
1165
+ /**
1166
+ * Creates a managed object (server fields assigned, relation fields ignored).
1167
+ * @param data - Tenant data
1168
+ * @param body - Managed object fragments
1169
+ * @param owner - Owner user name
1170
+ */
1171
+ function createManagedObject(data, body, owner) {
1172
+ const now = store.now();
1173
+ const document = {
1174
+ id: store.newId(),
1175
+ owner,
1176
+ ...omit(body, SERVER_FIELDS$1),
1177
+ creationTime: now,
1178
+ lastUpdated: now
1179
+ };
1180
+ if (typeof body.owner === "string") document.owner = body.owner;
1181
+ data.managedObjects.set(document.id, document);
1182
+ notify(data, "managedobjects", "CREATE", renderManagedObject(data, document), document.id);
1183
+ return document;
1184
+ }
1185
+ function omit(body, fields) {
1186
+ return Object.fromEntries(Object.entries(body).filter(([key]) => !fields.includes(key)));
1187
+ }
1188
+ /**
1189
+ * Full-text search as documented (and verified): the value is split into
1190
+ * tokens at whitespace and `/`, `-`, `.`; an object matches if any examined
1191
+ * property (id, type, name, owner, external ids) contains any of the tokens.
1192
+ * A value in double quotes searches for the exact phrase.
1193
+ * @param data - Tenant data
1194
+ * @param document - Managed object
1195
+ * @param text - Search value
1196
+ */
1197
+ function searchText(data, document, text) {
1198
+ const values = [
1199
+ document.id,
1200
+ document.type,
1201
+ document.name,
1202
+ document.owner,
1203
+ ...data.externalIds.filter((entry) => entry.managedObjectId === document.id).map((entry) => entry.externalId)
1204
+ ].filter((value) => typeof value === "string").map((value) => value.toLowerCase());
1205
+ const phrase = /^"(.*)"$/.exec(text.trim());
1206
+ if (phrase) return values.some((value) => value.includes(phrase[1].toLowerCase()));
1207
+ const tokenize = (value) => value.toLowerCase().split(/[\s/.-]+/).filter(Boolean);
1208
+ const tokens = new Set(values.flatMap(tokenize));
1209
+ return tokenize(text).some((token) => tokens.has(token));
1210
+ }
1211
+ function listManagedObjects(call, data, documents) {
1212
+ let items = documents;
1213
+ const { query } = call;
1214
+ if (query.type && query.query === void 0 && query.q === void 0) items = items.filter((document) => document.type === query.type);
1215
+ if (query.fragmentType) items = items.filter((document) => document[query.fragmentType] !== void 0);
1216
+ if (query.ids) {
1217
+ const ids = query.ids.split(",");
1218
+ items = items.filter((document) => ids.includes(document.id));
1219
+ }
1220
+ if (query.owner) items = items.filter((document) => document.owner === query.owner);
1221
+ if (query.text) items = items.filter((document) => searchText(data, document, query.text));
1222
+ for (const [param, kind] of [
1223
+ ["childDeviceId", "childDevices"],
1224
+ ["childAssetId", "childAssets"],
1225
+ ["childAdditionId", "childAdditions"]
1226
+ ]) if (query[param]) items = items.filter((document) => childIds(data, document.id, kind).includes(query[param]));
1227
+ if (flag(call, "onlyRoots")) items = items.filter((document) => CHILD_KINDS.every((kind) => parentIds(data, document.id, kind).length === 0));
1228
+ let orderBy = [];
1229
+ for (const param of ["query", "q"]) if (query[param]) {
1230
+ const parsed = parseQuery(query[param], data);
1231
+ items = items.filter((document) => parsed.filter(document));
1232
+ orderBy = parsed.orderBy;
1233
+ }
1234
+ if (query.q !== void 0) items = items.filter((document) => document.c8y_IsDevice !== void 0);
1235
+ return applyOrderBy(items, orderBy);
1236
+ }
1237
+ const LIST_PARAMS = [
1238
+ "childAdditionId",
1239
+ "childAssetId",
1240
+ "childDeviceId",
1241
+ "currentPage",
1242
+ "fragmentType",
1243
+ "ids",
1244
+ "onlyRoots",
1245
+ "owner",
1246
+ "pageSize",
1247
+ "q",
1248
+ "query",
1249
+ "skipChildrenNames",
1250
+ "text",
1251
+ "type",
1252
+ "withChildren",
1253
+ "withParents",
1254
+ "withTotalElements",
1255
+ "withTotalPages"
1256
+ ];
1257
+ const DETAIL_PARAMS = [
1258
+ "skipChildrenNames",
1259
+ "withChildren",
1260
+ "withParents"
1261
+ ];
1262
+ /**
1263
+ * Inventory API: managed objects and child references.
1264
+ * @param call - The request
1265
+ * @param segments - Path segments
1266
+ * @param data - Tenant data
1267
+ */
1268
+ function inventory(call, segments, data) {
1269
+ try {
1270
+ return inventoryRequest(call, segments, data);
1271
+ } catch (error) {
1272
+ if (error instanceof QuerySyntaxError) return apiError(422, "inventory/Invalid Data", `Find by filter query failed : Query '${call.query.query ?? call.query.q}' could not be understood. Please try again.`);
1273
+ throw error;
1274
+ }
1275
+ }
1276
+ function inventoryRequest(call, segments, data) {
1277
+ const [, , id, kind, childId] = segments;
1278
+ const { method } = call;
1279
+ const render = (document) => renderManagedObject(data, document, {
1280
+ withChildren: flag(call, "withChildren", true),
1281
+ skipChildrenNames: flag(call, "skipChildrenNames"),
1282
+ withParents: flag(call, "withParents")
1283
+ });
1284
+ if (id === void 0) {
1285
+ if (method === "GET") {
1286
+ checkQuery("GET /inventory/managedObjects", call, LIST_PARAMS);
1287
+ return paged(call, "managedObjects", listManagedObjects(call, data, [...data.managedObjects.values()]).map(render), "/inventory/managedObjects");
1288
+ }
1289
+ if (method === "POST") {
1290
+ checkQuery("POST /inventory/managedObjects", call, []);
1291
+ const document = createManagedObject(data, call.body ?? {}, call.user ?? "test-user");
1292
+ return Response.json(render(document), { status: 201 });
1293
+ }
1294
+ return;
1295
+ }
1296
+ const document = data.managedObjects.get(id);
1297
+ if (kind === void 0) {
1298
+ if (method === "GET") {
1299
+ checkQuery("GET /inventory/managedObjects/{id}", call, DETAIL_PARAMS);
1300
+ return document ? Response.json(render(document)) : notFound$2(id);
1301
+ }
1302
+ if (method === "PUT") {
1303
+ checkQuery("PUT /inventory/managedObjects/{id}", call, []);
1304
+ if (!document) return notFound$2(id);
1305
+ for (const [key, value] of Object.entries(omit(call.body ?? {}, SERVER_FIELDS$1))) if (value === null) delete document[key];
1306
+ else document[key] = value;
1307
+ document.lastUpdated = store.now();
1308
+ notify(data, "managedobjects", "UPDATE", renderManagedObject(data, document), document.id);
1309
+ return Response.json(render(document));
1310
+ }
1311
+ if (method === "DELETE") {
1312
+ checkQuery("DELETE /inventory/managedObjects/{id}", call, []);
1313
+ if (document?.c8y_IsBinary !== void 0) return apiError(422, "inventory/Invalid Data", "Cannot delete managed object. : Cannot change binary metadata through inventory api.");
1314
+ if (!deleteManagedObject(data, id)) return notFound$2(id);
1315
+ notify(data, "managedobjects", "DELETE", { id }, id);
1316
+ return new Response(null, { status: 204 });
1317
+ }
1318
+ return;
1319
+ }
1320
+ if (!CHILD_KINDS.includes(kind)) return;
1321
+ const childKind = kind;
1322
+ const endpoint = `/inventory/managedObjects/{id}/${childKind}`;
1323
+ if (childId === void 0 && method === "GET") {
1324
+ checkQuery(`GET ${endpoint}`, call, [
1325
+ "currentPage",
1326
+ "pageSize",
1327
+ "query",
1328
+ "withChildren",
1329
+ "withTotalElements",
1330
+ "withTotalPages"
1331
+ ]);
1332
+ if (!document) return notFound$2(id);
1333
+ let children = childIds(data, id, childKind).map((child) => data.managedObjects.get(child)).filter((child) => child !== void 0);
1334
+ if (call.query.query) {
1335
+ const parsed = parseQuery(call.query.query, data);
1336
+ children = applyOrderBy(children.filter((child) => parsed.filter(child)), parsed.orderBy);
1337
+ }
1338
+ return paged(call, "references", children.map((child) => ({
1339
+ self: `${moSelf(id)}/${childKind}/${child.id}`,
1340
+ managedObject: render(child)
1341
+ })), `/inventory/managedObjects/${id}/${childKind}`);
1342
+ }
1343
+ if (childId === void 0 && method === "POST") {
1344
+ checkQuery(`POST ${endpoint}`, call, []);
1345
+ if (!document) return notFound$2(id);
1346
+ const body = call.body ?? {};
1347
+ if ((call.headers["content-type"] ?? "").toLowerCase().includes("managedobject+json")) {
1348
+ const child = createManagedObject(data, body, call.user ?? "test-user");
1349
+ addChild(data, id, childKind, child.id);
1350
+ return Response.json(render(child), { status: 201 });
1351
+ }
1352
+ const requested = Array.isArray(body.references) ? body.references.map((reference) => String(reference.managedObject?.id)) : [String(body.managedObject?.id)];
1353
+ for (const child of requested) if (!data.managedObjects.has(child)) return notFound$2(child);
1354
+ const references = requested.map((child) => {
1355
+ addChild(data, id, childKind, child);
1356
+ return {
1357
+ self: `${moSelf(id)}/${childKind}/${child}`,
1358
+ managedObject: render(data.managedObjects.get(child))
1359
+ };
1360
+ });
1361
+ return Response.json(Array.isArray(body.references) ? { references } : references[0], { status: 201 });
1362
+ }
1363
+ if (childId !== void 0 && method === "DELETE") {
1364
+ if (!document) return notFound$2(id);
1365
+ return removeChild(data, id, childKind, childId) ? new Response(null, { status: 204 }) : apiError(422, "inventory/Invalid Data", `managedObject with id: ${childId}, is not a child of object with id: ${id} : Parent child relationship validation failed`);
1366
+ }
1367
+ }
1368
+ // src/testing/api/data.ts
1369
+ const RESOURCES = {
1370
+ measurement: {
1371
+ collection: "measurements",
1372
+ path: "/measurement/measurements",
1373
+ domain: "measurement",
1374
+ listParams: [
1375
+ "currentPage",
1376
+ "dateFrom",
1377
+ "dateTo",
1378
+ "pageSize",
1379
+ "revert",
1380
+ "source",
1381
+ "type",
1382
+ "valueFragmentSeries",
1383
+ "valueFragmentType",
1384
+ "withTotalElements",
1385
+ "withTotalPages"
1386
+ ]
1387
+ },
1388
+ event: {
1389
+ collection: "events",
1390
+ path: "/event/events",
1391
+ domain: "event",
1392
+ listParams: [
1393
+ "createdFrom",
1394
+ "createdTo",
1395
+ "currentPage",
1396
+ "dateFrom",
1397
+ "dateTo",
1398
+ "fragmentType",
1399
+ "lastUpdatedFrom",
1400
+ "lastUpdatedTo",
1401
+ "pageSize",
1402
+ "revert",
1403
+ "source",
1404
+ "type",
1405
+ "withTotalElements",
1406
+ "withTotalPages"
1407
+ ]
1408
+ },
1409
+ alarm: {
1410
+ collection: "alarms",
1411
+ path: "/alarm/alarms",
1412
+ domain: "alarm",
1413
+ listParams: [
1414
+ "createdFrom",
1415
+ "createdTo",
1416
+ "currentPage",
1417
+ "dateFrom",
1418
+ "dateTo",
1419
+ "lastUpdatedFrom",
1420
+ "lastUpdatedTo",
1421
+ "pageSize",
1422
+ "resolved",
1423
+ "severity",
1424
+ "source",
1425
+ "status",
1426
+ "type",
1427
+ "withTotalElements",
1428
+ "withTotalPages"
1429
+ ]
1430
+ },
1431
+ devicecontrol: {
1432
+ collection: "operations",
1433
+ path: "/devicecontrol/operations",
1434
+ domain: "devicecontrol",
1435
+ listParams: [
1436
+ "currentPage",
1437
+ "dateFrom",
1438
+ "dateTo",
1439
+ "deviceId",
1440
+ "fragmentType",
1441
+ "pageSize",
1442
+ "revert",
1443
+ "status",
1444
+ "withTotalElements",
1445
+ "withTotalPages"
1446
+ ]
1447
+ }
1448
+ };
1449
+ const SERVER_FIELDS = [
1450
+ "id",
1451
+ "self",
1452
+ "creationTime",
1453
+ "lastUpdated",
1454
+ "count",
1455
+ "firstOccurrenceTime"
1456
+ ];
1457
+ function renderDocument(data, resource, document) {
1458
+ const rendered = {
1459
+ ...structuredClone(document),
1460
+ self: `${baseUrl()}${resource.path}/${document.id}`
1461
+ };
1462
+ const source = sourceId(document);
1463
+ if (source !== void 0) rendered.source = {
1464
+ id: source,
1465
+ self: moSelf(source),
1466
+ name: data.managedObjects.get(source)?.name
1467
+ };
1468
+ if (resource.collection === "operations" && typeof document.deviceId === "string") rendered.deviceName = data.managedObjects.get(document.deviceId)?.name;
1469
+ return rendered;
1470
+ }
1471
+ function publish(data, resource, action, document) {
1472
+ const source = resource.collection === "operations" ? document.deviceId : sourceId(document);
1473
+ const payload = action === "DELETE" ? { id: document.id } : renderDocument(data, resource, document);
1474
+ notify(data, resource.collection, action, payload, source);
1475
+ }
1476
+ function notFound$1(resource, id) {
1477
+ const name = resource.collection.slice(0, -1);
1478
+ return apiError(404, `${resource.domain}/Not Found`, `Finding ${name} from database failed : No ${name} for gid '${id}'!`);
1479
+ }
1480
+ const DATE_PARAMS = [
1481
+ "dateFrom",
1482
+ "dateTo",
1483
+ "createdFrom",
1484
+ "createdTo",
1485
+ "lastUpdatedFrom",
1486
+ "lastUpdatedTo"
1487
+ ];
1488
+ /**
1489
+ * 422 for date query parameters that are not dates (verified for measurements and events).
1490
+ * @param call - The request
1491
+ */
1492
+ function checkDates(call) {
1493
+ for (const name of DATE_PARAMS) {
1494
+ const value = call.query[name];
1495
+ if (value !== void 0 && Number.isNaN(Date.parse(value))) return apiError(422, `${value} is not a valid date./Invalid Data`, `${value} is not a valid date.`);
1496
+ }
1497
+ }
1498
+ /**
1499
+ * Date range match: from inclusive, to exclusive; equal bounds match exactly
1500
+ * that instant (verified against the platform).
1501
+ * @param value - ISO date of the document
1502
+ * @param from - Range start
1503
+ * @param to - Range end
1504
+ */
1505
+ function inDateRange(value, from, to) {
1506
+ if (from !== void 0 && to !== void 0 && Date.parse(from) === Date.parse(to)) return Date.parse(String(value)) === Date.parse(from);
1507
+ return inRange(value, from, to);
1508
+ }
1509
+ function invalid(message) {
1510
+ return apiError(422, "undefined/validationError", message);
1511
+ }
1512
+ function checkEnum(field, value, allowed) {
1513
+ return value !== void 0 && !allowed.includes(String(value)) ? invalid(`Invalid value '${String(value)}' for ${field}; allowed: ${allowed.join(", ")}`) : void 0;
1514
+ }
1515
+ function omitServerFields(body) {
1516
+ return Object.fromEntries(Object.entries(body).filter(([key]) => !SERVER_FIELDS.includes(key)));
1517
+ }
1518
+ /**
1519
+ * Creates a measurement, event, alarm, or operation with platform semantics
1520
+ * (validation, alarm deduplication, operation status PENDING).
1521
+ * @param data - Tenant data
1522
+ * @param resource - The resource
1523
+ * @param body - Request body
1524
+ * @returns The stored document, or an error response
1525
+ */
1526
+ function createDocument(data, resource, body) {
1527
+ const endpoint = `POST ${resource.path}`;
1528
+ if (resource.collection === "events" && (body.source === void 0 || body.source === null)) return apiError(422, "event/Invalid Data", "Source of the event must not be null");
1529
+ const missing = checkRequired(endpoint, body);
1530
+ if (missing) return missing;
1531
+ if (resource.collection !== "operations" && Number.isNaN(Date.parse(String(body.time)))) return apiError(400, `${resource.domain}/Bad Request`, "Failed to parse JSON string");
1532
+ if (resource.collection !== "measurements") {
1533
+ const field = (resource.collection === "operations" ? [
1534
+ "id",
1535
+ "creationTime",
1536
+ "status",
1537
+ "failureReason"
1538
+ ] : ["id", "creationTime"]).find((name) => body[name] !== void 0);
1539
+ if (field) return apiError(422, "undefined/validationError", `post.input.${field}: is not allowed here`);
1540
+ }
1541
+ if (resource.collection === "operations") {
1542
+ if (Object.keys(body).every((key) => key === "deviceId")) return apiError(422, "devicecontrol/Invalid Data", "At least one weak-typed property must be included");
1543
+ }
1544
+ const now = store.now();
1545
+ const source = resource.collection === "operations" ? body.deviceId : sourceId(body);
1546
+ if (source === void 0 && resource.collection !== "operations") return apiError(422, `${resource.domain}/Invalid Data`, "Source id must not be null");
1547
+ if (!data.managedObjects.has(String(source)) && resource.collection !== "operations") return resource.collection === "alarms" ? apiError(404, "inventory/Not Found", `Finding device data from database failed : No managedObject for id '${String(source)}'!`) : apiError(422, `${resource.domain}/Unprocessable Entity`, "Source object does not exist in inventory.");
1548
+ if (resource.collection === "operations" && !hasAgent(data, String(source))) return apiError(422, "devicecontrol/Unprocessable Entity", `Agent for device '${String(source)}' does not exist in inventory!`);
1549
+ if (resource.collection === "alarms") {
1550
+ const existing = [...data.alarms.values()].find((alarm) => sourceId(alarm) === source && alarm.type === body.type && alarm.status !== "CLEARED");
1551
+ if (existing && body.status !== "CLEARED") {
1552
+ existing.firstOccurrenceTime ??= existing.time;
1553
+ existing.count = existing.count + 1;
1554
+ existing.time = body.time;
1555
+ existing.lastUpdated = now;
1556
+ publish(data, resource, "UPDATE", existing);
1557
+ return existing;
1558
+ }
1559
+ }
1560
+ const document = {
1561
+ ...omitServerFields(body),
1562
+ id: store.newId(),
1563
+ creationTime: now
1564
+ };
1565
+ if (source !== void 0 && resource.collection !== "operations") document.source = { id: String(source) };
1566
+ if (resource.collection !== "measurements") document.lastUpdated = now;
1567
+ if (resource.collection === "alarms") {
1568
+ document.status ??= "ACTIVE";
1569
+ document.count = 1;
1570
+ }
1571
+ if (resource.collection === "operations") {
1572
+ document.status = "PENDING";
1573
+ delete document.failureReason;
1574
+ }
1575
+ data[resource.collection].set(document.id, document);
1576
+ publish(data, resource, "CREATE", document);
1577
+ return document;
1578
+ }
1579
+ /**
1580
+ * Whether a device is an agent (`com_cumulocity_model_Agent`) or has one
1581
+ * among its device parents, which the platform requires for operations.
1582
+ * @param data - Tenant data
1583
+ * @param deviceId - Device id
1584
+ * @param seen
1585
+ */
1586
+ function hasAgent(data, deviceId, seen = /* @__PURE__ */ new Set()) {
1587
+ if (seen.has(deviceId)) return false;
1588
+ seen.add(deviceId);
1589
+ if (data.managedObjects.get(deviceId)?.com_cumulocity_model_Agent !== void 0) return true;
1590
+ return parentIds(data, deviceId, "childDevices").some((parent) => hasAgent(data, parent, seen));
1591
+ }
1592
+ /**
1593
+ * Applies an update to a stored document (fragments merged, `null` removes).
1594
+ * @param data
1595
+ * @param resource - The resource
1596
+ * @param document - Stored document
1597
+ * @param body - Update
1598
+ * @returns An error response, if invalid
1599
+ */
1600
+ function updateDocument(data, resource, document, body) {
1601
+ const readOnly = resource.collection === "operations" ? ["deviceId", "deviceName"] : [
1602
+ "source",
1603
+ "time",
1604
+ "type"
1605
+ ];
1606
+ if (resource.collection === "operations") {
1607
+ const error = checkEnum("status", body.status, C8Y_ENUMS.operationStatus);
1608
+ if (error) return error;
1609
+ }
1610
+ if (resource.collection === "operations") {
1611
+ const fixed = ["creationTime"].find((field) => body[field] !== void 0) ?? (body.deviceId !== void 0 && String(body.deviceId) !== document.deviceId ? "deviceId" : void 0);
1612
+ if (fixed) return apiError(422, "undefined/validationError", `put.operation.${fixed}: is not allowed here`);
1613
+ if (body.failureReason !== void 0 && body.failureReason !== null && body.status !== "FAILED") return apiError(422, "devicecontrol/Invalid Data", "failureReason can only be set for the status FAILED");
1614
+ if (body.status !== void 0 && body.status !== "FAILED") delete document.failureReason;
1615
+ }
1616
+ if (resource.collection === "alarms") {
1617
+ const fixed = [
1618
+ "type",
1619
+ "time",
1620
+ "count",
1621
+ "creationTime",
1622
+ "lastUpdated",
1623
+ "firstOccurrenceTime"
1624
+ ].find((field) => body[field] !== void 0);
1625
+ if (fixed) return apiError(422, "undefined/validationError", `put.alarm.${fixed}: is not allowed here`);
1626
+ }
1627
+ for (const [key, value] of Object.entries(omitServerFields(body))) {
1628
+ if (readOnly.includes(key)) continue;
1629
+ if (value === null && key === "text" && resource.collection === "events") continue;
1630
+ if (value === null) delete document[key];
1631
+ else document[key] = value;
1632
+ }
1633
+ if (resource.collection !== "measurements") document.lastUpdated = store.now();
1634
+ publish(data, resource, "UPDATE", document);
1635
+ }
1636
+ /**
1637
+ * Event updates the platform rejects (verified against the platform).
1638
+ * @param call - The request
1639
+ * @param id - Event id from the URL
1640
+ * @param body - Update
1641
+ */
1642
+ function checkEventUpdate(call, id, body) {
1643
+ const contentType = call.headers["content-type"] ?? "";
1644
+ if (contentType && !/json/i.test(contentType)) return apiError(415, "general/Unsupported Media Type", `Content type '${contentType}' not supported`);
1645
+ if (body.id !== void 0 && String(body.id) !== id) return apiError(422, "event/Invalid Data", "Id from representation must be the same like in url");
1646
+ if (body.source !== void 0) return apiError(422, "event/Invalid Data", "Invalid value for source argument! : Value must be null!");
1647
+ const forbidden = [
1648
+ "time",
1649
+ "type",
1650
+ "creationTime",
1651
+ "lastUpdated"
1652
+ ].find((field) => body[field] !== void 0);
1653
+ if (forbidden) return apiError(422, "undefined/validationError", `put.eventRepresentation.${forbidden}: is not allowed here`);
1654
+ }
1655
+ function list(call, data, resource) {
1656
+ const { query } = call;
1657
+ const values = (name) => query[name]?.split(",").map((value) => value.trim());
1658
+ let items = [...data[resource.collection].values()];
1659
+ if (query.source) items = items.filter((document) => sourceId(document) === query.source);
1660
+ if (query.deviceId) items = items.filter((document) => document.deviceId === query.deviceId);
1661
+ if (query.type) {
1662
+ const types = values("type");
1663
+ items = items.filter((document) => types.includes(String(document.type)));
1664
+ }
1665
+ if (query.status) {
1666
+ const statuses = resource.collection === "operations" ? [query.status] : values("status");
1667
+ items = items.filter((document) => statuses.includes(String(document.status)));
1668
+ }
1669
+ if (query.severity) {
1670
+ const severities = values("severity");
1671
+ items = items.filter((document) => severities.includes(String(document.severity)));
1672
+ }
1673
+ if (query.resolved !== void 0) {
1674
+ const resolved = flag(call, "resolved");
1675
+ items = items.filter((document) => document.status === "CLEARED" === resolved);
1676
+ }
1677
+ if (query.fragmentType) items = items.filter((document) => document[query.fragmentType] !== void 0);
1678
+ if (query.valueFragmentType) items = items.filter((document) => {
1679
+ const fragment = document[query.valueFragmentType];
1680
+ return fragment !== void 0 && (query.valueFragmentSeries === void 0 || fragment[query.valueFragmentSeries] !== void 0);
1681
+ });
1682
+ const timeField = resource.collection === "operations" ? "creationTime" : "time";
1683
+ if (query.dateFrom !== void 0 || query.dateTo !== void 0) items = items.filter((document) => inDateRange(document[timeField], query.dateFrom, query.dateTo));
1684
+ if (query.createdFrom !== void 0 || query.createdTo !== void 0) items = items.filter((document) => inDateRange(document.creationTime, query.createdFrom, query.createdTo));
1685
+ if (query.lastUpdatedFrom !== void 0 || query.lastUpdatedTo !== void 0) items = items.filter((document) => inDateRange(document.lastUpdated, query.lastUpdatedFrom, query.lastUpdatedTo));
1686
+ const revert = flag(call, "revert");
1687
+ const hasRange = query.dateFrom !== void 0 || query.dateTo !== void 0;
1688
+ switch (resource.collection) {
1689
+ case "measurements": return byTime(items, "time", !(hasRange && query.revert === "false"));
1690
+ case "events": return byTime(items, "time", !(hasRange && revert));
1691
+ case "alarms": return byTime(items, "time", true);
1692
+ default: return hasRange && revert ? [...items].reverse() : items;
1693
+ }
1694
+ }
1695
+ /**
1696
+ * Measurement, event, alarm, and operation APIs.
1697
+ * @param call - The request
1698
+ * @param segments - Path segments
1699
+ * @param data - Tenant data
1700
+ * @param resource - The resource
1701
+ */
1702
+ function documents(call, segments, data, resource) {
1703
+ const [, , id, extra] = segments;
1704
+ const { method } = call;
1705
+ const collection = data[resource.collection];
1706
+ const body = call.body ?? {};
1707
+ if (extra !== void 0) return;
1708
+ if (id === "series" && resource.collection === "measurements" && method === "GET") return series(call, data);
1709
+ if (id === void 0) {
1710
+ if (method === "DELETE" && (resource.collection === "measurements" || resource.collection === "events")) {
1711
+ const supported = resource.collection === "measurements" ? [
1712
+ "dateFrom",
1713
+ "dateTo",
1714
+ "fragmentType",
1715
+ "source",
1716
+ "type"
1717
+ ] : [
1718
+ "createdFrom",
1719
+ "createdTo",
1720
+ "dateFrom",
1721
+ "dateTo",
1722
+ "fragmentType",
1723
+ "source",
1724
+ "type"
1725
+ ];
1726
+ checkQuery(`DELETE ${resource.path}`, call, supported);
1727
+ const dateError = checkDates(call);
1728
+ if (dateError) return dateError;
1729
+ if (resource.collection === "measurements") {
1730
+ if (["dateFrom", "dateTo"].some((name) => call.query[name] !== void 0 && Date.parse(call.query[name]) % 36e5 !== 0)) return apiError(422, "measurement/Invalid Data", "Date from and date to parameters used for delete filter must be truncated to hour!");
1731
+ }
1732
+ let target = call;
1733
+ const { dateFrom, dateTo } = call.query;
1734
+ if (resource.collection === "measurements" && dateFrom !== void 0 && dateFrom === dateTo) target = {
1735
+ ...call,
1736
+ query: {
1737
+ ...call.query,
1738
+ dateTo: new Date(Date.parse(dateFrom) + 36e5).toISOString()
1739
+ }
1740
+ };
1741
+ for (const document of list(target, data, resource)) {
1742
+ collection.delete(document.id);
1743
+ data.eventBinaries.delete(document.id);
1744
+ publish(data, resource, "DELETE", document);
1745
+ }
1746
+ return new Response(null, { status: 204 });
1747
+ }
1748
+ if (method === "GET") {
1749
+ checkQuery(`GET ${resource.path}`, call, resource.listParams);
1750
+ const dateError = checkDates(call);
1751
+ if (dateError) return dateError;
1752
+ if (resource.collection === "operations" && call.query.status !== void 0 && !C8Y_ENUMS.operationStatus.includes(call.query.status)) return apiError(422, "devicecontrol/Invalid Data", `Invalid status value: ${call.query.status}`);
1753
+ return paged(call, resource.collection, list(call, data, resource).map((document) => renderDocument(data, resource, document)), resource.path);
1754
+ }
1755
+ if (method === "POST") {
1756
+ checkQuery(`POST ${resource.path}`, call, []);
1757
+ if (resource.collection === "measurements" && Array.isArray(body.measurements)) {
1758
+ const created = [];
1759
+ for (const item of body.measurements) {
1760
+ const result = createDocument(data, resource, item);
1761
+ if (result instanceof Response) return result;
1762
+ created.push(result);
1763
+ }
1764
+ return Response.json({ measurements: created.map((document) => renderDocument(data, resource, document)) }, { status: 201 });
1765
+ }
1766
+ const result = createDocument(data, resource, body);
1767
+ return result instanceof Response ? result : Response.json(renderDocument(data, resource, result), { status: 201 });
1768
+ }
1769
+ if (method === "PUT" && resource.collection === "alarms") {
1770
+ checkQuery("PUT /alarm/alarms", call, [
1771
+ "createdFrom",
1772
+ "createdTo",
1773
+ "dateFrom",
1774
+ "dateTo",
1775
+ "resolved",
1776
+ "severity",
1777
+ "source",
1778
+ "status"
1779
+ ]);
1780
+ const dateError = checkDates(call);
1781
+ if (dateError) return dateError;
1782
+ if (body.status === void 0) return apiError(422, "alarm/Invalid Data", "Missing status for bulk alarm update");
1783
+ for (const alarm of list(call, data, resource)) {
1784
+ const error = updateDocument(data, resource, alarm, { status: body.status });
1785
+ if (error) return error;
1786
+ }
1787
+ return new Response(null, { status: 200 });
1788
+ }
1789
+ return;
1790
+ }
1791
+ const document = collection.get(id);
1792
+ const detail = `${resource.path}/{id}`;
1793
+ if (method === "GET") {
1794
+ checkQuery(`GET ${detail}`, call, []);
1795
+ if (resource.collection === "measurements") return apiError(405, "measurement/Method Not Allowed", "Reading a single measurement is not supported with time series persistence");
1796
+ if (!document) return notFound$1(resource, id);
1797
+ const rendered = renderDocument(data, resource, document);
1798
+ if (resource.collection === "operations") delete rendered.deviceName;
1799
+ return Response.json(rendered);
1800
+ }
1801
+ if (method === "PUT" && resource.collection !== "measurements") {
1802
+ checkQuery(`PUT ${detail}`, call, []);
1803
+ if (!document) return notFound$1(resource, id);
1804
+ if (resource.collection === "operations" && body.status === void 0) return invalid("Following mandatory fields should be included: status");
1805
+ if (resource.collection === "events") {
1806
+ const error = checkEventUpdate(call, id, body);
1807
+ if (error) return error;
1808
+ }
1809
+ return updateDocument(data, resource, document, body) ?? Response.json(renderDocument(data, resource, document));
1810
+ }
1811
+ if (method === "DELETE" && (resource.collection === "measurements" || resource.collection === "events")) {
1812
+ checkQuery(`DELETE ${detail}`, call, []);
1813
+ if (!document || !collection.delete(id)) return notFound$1(resource, id);
1814
+ data.eventBinaries.delete(id);
1815
+ publish(data, resource, "DELETE", document);
1816
+ return new Response(null, { status: 204 });
1817
+ }
1818
+ }
1819
+ const INTERVAL_MS = {
1820
+ s: 1e3,
1821
+ m: 6e4,
1822
+ h: 36e5,
1823
+ d: 864e5,
1824
+ w: 6048e5
1825
+ };
1826
+ const AGGREGATION_TYPES = {
1827
+ MINUTELY: "1m",
1828
+ HOURLY: "1h",
1829
+ DAILY: "1d"
1830
+ };
1831
+ /**
1832
+ * Start of the aggregation bucket a time falls into (UTC; weeks start at the
1833
+ * Unix epoch's weekday, months/quarters/years on calendar boundaries).
1834
+ * @param time - Epoch milliseconds
1835
+ * @param interval - e.g. `15m`, `1h`, `1M`
1836
+ */
1837
+ function bucketStart(time, interval) {
1838
+ const match = /^([1-9]\d{0,2})([smhdwMqy])$/.exec(interval);
1839
+ if (!match) throw new TypeError(interval);
1840
+ const amount = Number(match[1]);
1841
+ const unit = match[2];
1842
+ if (unit in INTERVAL_MS) {
1843
+ const size = amount * INTERVAL_MS[unit];
1844
+ return Math.floor(time / size) * size;
1845
+ }
1846
+ const date = new Date(time);
1847
+ const months = {
1848
+ M: amount,
1849
+ q: amount * 3,
1850
+ y: amount * 12
1851
+ }[unit];
1852
+ const index = date.getUTCFullYear() * 12 + date.getUTCMonth();
1853
+ const start = Math.floor(index / months) * months;
1854
+ return Date.UTC(Math.floor(start / 12), start % 12, 1);
1855
+ }
1856
+ function aggregate(values, fn) {
1857
+ const sum = values.reduce((total, value) => total + value, 0);
1858
+ const avg = sum / values.length;
1859
+ const variance = (divisor) => values.reduce((total, value) => total + (value - avg) ** 2, 0) / divisor;
1860
+ switch (fn) {
1861
+ case "min": return Math.min(...values);
1862
+ case "max": return Math.max(...values);
1863
+ case "sum": return sum;
1864
+ case "avg": return avg;
1865
+ case "count": return values.length;
1866
+ case "stdDevPop": return Math.sqrt(variance(values.length));
1867
+ default: return values.length > 1 ? Math.sqrt(variance(values.length - 1)) : 0;
1868
+ }
1869
+ }
1870
+ /**
1871
+ * `GET /measurement/measurements/series`: values of a source's series in a
1872
+ * period, optionally aggregated per interval.
1873
+ * @param call - The request
1874
+ * @param data - Tenant data
1875
+ */
1876
+ function series(call, data) {
1877
+ checkQuery("GET /measurement/measurements/series", call, [
1878
+ "aggregationFunction",
1879
+ "aggregationInterval",
1880
+ "aggregationType",
1881
+ "dateFrom",
1882
+ "dateTo",
1883
+ "revert",
1884
+ "series",
1885
+ "source"
1886
+ ]);
1887
+ const { query } = call;
1888
+ const params = raw(call).params;
1889
+ if ([
1890
+ "source",
1891
+ "dateFrom",
1892
+ "dateTo"
1893
+ ].some((name) => !query[name])) return apiError(400, "measurement/Bad Request", "The query parameters source, dateFrom and dateTo are required for this request.");
1894
+ const dateError = checkDates(call);
1895
+ if (dateError) return dateError;
1896
+ const functions = params.getAll("aggregationFunction");
1897
+ if (query.aggregationType && !Object.hasOwn(AGGREGATION_TYPES, query.aggregationType)) return apiError(400, "measurement/Bad Request", `Invalid value '${query.aggregationType}' for aggregationType. Allowed values: HOURLY, DAILY, MINUTELY`);
1898
+ const unknownFunction = functions.find((fn) => !C8Y_ENUMS.seriesAggregationFunction.includes(fn));
1899
+ if (unknownFunction) return apiError(400, "measurement/Bad Request", `Invalid value '${unknownFunction}' for aggregationFunction. Allowed values: min, max, avg, sum, count, stdDevPop, stdDevSamp`);
1900
+ const interval = query.aggregationInterval ?? (query.aggregationType ? AGGREGATION_TYPES[query.aggregationType] : void 0);
1901
+ if (functions.length > 0 && !interval) return apiError(422, "measurements/Invalid Data", "Cannot specify aggregation functions if `aggregationType` is not provided!");
1902
+ if (interval && !/^[1-9]\d{0,2}[smhdwMqy]$/.test(interval)) return apiError(422, "measurement/Invalid Data", `Invalid aggregation interval: ${interval}`);
1903
+ const wanted = params.getAll("series");
1904
+ const points = [];
1905
+ for (const measurement of data.measurements.values()) {
1906
+ if (sourceId(measurement) !== query.source || !inDateRange(measurement.time, query.dateFrom, query.dateTo)) continue;
1907
+ for (const [fragment, fragmentValue] of Object.entries(measurement)) {
1908
+ if (!fragmentValue || typeof fragmentValue !== "object" || Array.isArray(fragmentValue) || fragment === "source") continue;
1909
+ for (const [name, entry] of Object.entries(fragmentValue)) if (entry && typeof entry === "object" && typeof entry.value === "number") {
1910
+ const key = `${fragment}.${name}`;
1911
+ if (wanted.length === 0 || wanted.includes(key)) points.push({
1912
+ time: Date.parse(String(measurement.time)),
1913
+ key,
1914
+ value: entry.value,
1915
+ unit: entry.unit,
1916
+ type: fragment,
1917
+ name
1918
+ });
1919
+ }
1920
+ }
1921
+ }
1922
+ const seriesList = [];
1923
+ for (const point of points) if (!seriesList.some((item) => item.key === point.key)) seriesList.push({
1924
+ key: point.key,
1925
+ name: point.name,
1926
+ type: point.type,
1927
+ unit: point.unit
1928
+ });
1929
+ const buckets = /* @__PURE__ */ new Map();
1930
+ for (const point of points) {
1931
+ const time = interval ? bucketStart(point.time, interval) : point.time;
1932
+ const bucket = buckets.get(time) ?? /* @__PURE__ */ new Map();
1933
+ bucket.set(point.key, [...bucket.get(point.key) ?? [], point.value]);
1934
+ buckets.set(time, bucket);
1935
+ }
1936
+ const times = [...buckets.keys()].sort((a, b) => a - b);
1937
+ const fns = functions.length > 0 ? functions : ["min", "max"];
1938
+ const values = {};
1939
+ for (const time of times) {
1940
+ const bucket = buckets.get(time);
1941
+ values[new Date(time).toISOString()] = seriesList.map((item) => {
1942
+ const numbers = bucket.get(item.key);
1943
+ if (numbers) return Object.fromEntries(fns.map((fn) => [fn, aggregate(numbers, fn)]));
1944
+ return fns.every((fn) => fn === "sum" || fn === "count") ? Object.fromEntries(fns.map((fn) => [fn, 0])) : null;
1945
+ });
1946
+ }
1947
+ return Response.json({
1948
+ values,
1949
+ series: seriesList.map(({ name, type, unit }) => ({
1950
+ name,
1951
+ type,
1952
+ ...unit !== void 0 ? { unit } : {}
1953
+ })),
1954
+ truncated: false
1955
+ });
1956
+ }
1957
+ // src/testing/api/identity.ts
1958
+ function renderExternalId(entry) {
1959
+ return {
1960
+ externalId: entry.externalId,
1961
+ type: entry.type,
1962
+ self: `${baseUrl()}/identity/externalIds/${encodeURIComponent(entry.type)}/${encodeURIComponent(entry.externalId)}`,
1963
+ managedObject: {
1964
+ id: entry.managedObjectId,
1965
+ self: moSelf(entry.managedObjectId)
1966
+ }
1967
+ };
1968
+ }
1969
+ /**
1970
+ * Adds an external id. Fails like the platform on unknown objects or duplicates.
1971
+ * @param data - Tenant data
1972
+ * @param managedObjectId - Managed object id
1973
+ * @param type - External id type, e.g. `c8y_Serial`
1974
+ * @param externalId - External id value
1975
+ */
1976
+ function addExternalId(data, managedObjectId, type, externalId) {
1977
+ if (!data.managedObjects.has(managedObjectId)) return apiError(404, "identity/Not Found", `Global id not found; global id = ${managedObjectId}`);
1978
+ const existing = data.externalIds.find((entry) => entry.type === type && entry.externalId === externalId);
1979
+ if (existing) return existing.managedObjectId === managedObjectId ? existing : apiError(409, "identity/Duplicate", `ID [type=${type}, value=${externalId}] was already bound to different Global ID.`);
1980
+ const entry = {
1981
+ type,
1982
+ externalId,
1983
+ managedObjectId
1984
+ };
1985
+ data.externalIds.push(entry);
1986
+ return entry;
1987
+ }
1988
+ /**
1989
+ * Identity API: external ids.
1990
+ * @param call - The request
1991
+ * @param segments - Path segments
1992
+ * @param data - Tenant data
1993
+ */
1994
+ function identity(call, segments, data) {
1995
+ const [, resource, a, b] = segments;
1996
+ const { method } = call;
1997
+ if (resource === "externalIds" && a !== void 0 && b !== void 0 && segments.length === 4) {
1998
+ const index = data.externalIds.findIndex((entry) => entry.type === a && entry.externalId === b);
1999
+ if (method === "GET") {
2000
+ checkQuery("GET /identity/externalIds/{type}/{externalId}", call, []);
2001
+ return index === -1 ? apiError(404, "identity/Not Found", `External id not found; external id = ID [type=${a}, value=${b}]`) : Response.json(renderExternalId(data.externalIds[index]));
2002
+ }
2003
+ if (method === "DELETE") {
2004
+ checkQuery("DELETE /identity/externalIds/{type}/{externalId}", call, []);
2005
+ if (index === -1) return apiError(404, "identity/Not Found", `External id not found; external id = ID [type=${a}, value=${b}]`);
2006
+ data.externalIds.splice(index, 1);
2007
+ return new Response(null, { status: 204 });
2008
+ }
2009
+ return;
2010
+ }
2011
+ if (resource === "globalIds" && a !== void 0 && b === "externalIds" && segments.length === 4) {
2012
+ if (method === "GET") {
2013
+ checkQuery("GET /identity/globalIds/{id}/externalIds", call, []);
2014
+ const externalIds = data.externalIds.filter((entry) => entry.managedObjectId === a).map(renderExternalId);
2015
+ return Response.json({
2016
+ self: `${baseUrl()}/identity/globalIds/${a}/externalIds`,
2017
+ externalIds
2018
+ });
2019
+ }
2020
+ if (method === "POST") {
2021
+ checkQuery("POST /identity/globalIds/{id}/externalIds", call, []);
2022
+ const body = call.body ?? {};
2023
+ const missing = ["type", "externalId"].find((field) => !body[field]);
2024
+ if (missing) return apiError(422, "undefined/validationError", `post.input.${missing}: must not be empty`);
2025
+ const result = addExternalId(data, a, body.type, body.externalId);
2026
+ return result instanceof Response ? result : Response.json(renderExternalId(result), { status: 201 });
2027
+ }
2028
+ }
2029
+ }
2030
+ // src/testing/api/binaries.ts
2031
+ /**
2032
+ * Reads an upload: a multipart body with `object` (JSON) and `file` parts, or
2033
+ * a raw body (event attachments, binary replacement).
2034
+ * @param call - The request
2035
+ * @returns The upload, or an error response
2036
+ */
2037
+ async function readUpload(call) {
2038
+ const { bytes, contentType } = raw(call);
2039
+ if (!contentType.startsWith("multipart/")) return {
2040
+ content: {
2041
+ bytes,
2042
+ contentType: contentType || "application/octet-stream",
2043
+ name: "bin"
2044
+ },
2045
+ object: {}
2046
+ };
2047
+ const form = await new Request("http://upload.invalid", {
2048
+ method: "POST",
2049
+ headers: { "content-type": contentType },
2050
+ body: bytes
2051
+ }).formData();
2052
+ const part = form.get("file");
2053
+ if (part === null) return apiError(422, "binaries/Invalid Data", "Missing file part");
2054
+ const file = typeof part === "string" ? new Blob([part]) : part;
2055
+ let object = {};
2056
+ const objectPart = form.get("object");
2057
+ if (typeof objectPart === "string" && objectPart) try {
2058
+ object = JSON.parse(objectPart);
2059
+ } catch {
2060
+ return apiError(422, "binaries/Invalid Data", "The object part is not valid JSON");
2061
+ }
2062
+ const name = typeof object.name === "string" ? object.name : file.name || "bin";
2063
+ return {
2064
+ content: {
2065
+ bytes: new Uint8Array(await file.arrayBuffer()),
2066
+ contentType: file.type || "application/octet-stream",
2067
+ name
2068
+ },
2069
+ object
2070
+ };
2071
+ }
2072
+ function download(content) {
2073
+ return new Response(content.bytes, { headers: {
2074
+ "content-type": content.contentType,
2075
+ "content-length": String(content.bytes.length),
2076
+ "content-disposition": `attachment; filename="${content.name}"`
2077
+ } });
2078
+ }
2079
+ /**
2080
+ * Creates an inventory binary (a managed object with `c8y_IsBinary`).
2081
+ * @param data - Tenant data
2082
+ * @param content - File content
2083
+ * @param object - Managed object fragments from the upload
2084
+ * @param owner - Owner user name
2085
+ */
2086
+ function createBinary(data, content, object, owner) {
2087
+ const document = createManagedObject(data, {
2088
+ ...object,
2089
+ name: object.name ?? content.name,
2090
+ type: object.type ?? content.contentType,
2091
+ contentType: content.contentType,
2092
+ length: content.bytes.length,
2093
+ c8y_IsBinary: ""
2094
+ }, owner);
2095
+ data.binaries.set(document.id, content);
2096
+ return document;
2097
+ }
2098
+ /**
2099
+ * Inventory binaries API: `/inventory/binaries[/{id}]`.
2100
+ * @param call - The request
2101
+ * @param segments - Path segments
2102
+ * @param data - Tenant data
2103
+ */
2104
+ async function inventoryBinaries(call, segments, data) {
2105
+ const [, , id] = segments;
2106
+ const { method } = call;
2107
+ if (id === void 0) {
2108
+ if (method === "POST") {
2109
+ checkQuery("POST /inventory/binaries", call, []);
2110
+ const upload = await readUpload(call);
2111
+ if (upload instanceof Response) return upload;
2112
+ const document = createBinary(data, upload.content, upload.object, call.user ?? "test-user");
2113
+ return Response.json(renderManagedObject(data, document), { status: 201 });
2114
+ }
2115
+ if (method === "GET") {
2116
+ checkQuery("GET /inventory/binaries", call, [
2117
+ "childAdditionId",
2118
+ "childAssetId",
2119
+ "childDeviceId",
2120
+ "currentPage",
2121
+ "ids",
2122
+ "owner",
2123
+ "pageSize",
2124
+ "type",
2125
+ "withTotalPages"
2126
+ ]);
2127
+ const { query } = call;
2128
+ const ids = query.ids?.split(",");
2129
+ return paged(call, "managedObjects", ([
2130
+ "childDeviceId",
2131
+ "childAssetId",
2132
+ "childAdditionId"
2133
+ ].some((name) => query[name] !== void 0) ? [] : [...data.managedObjects.values()].filter((document) => document.c8y_IsBinary !== void 0 && (ids === void 0 || ids.includes(document.id)) && (query.owner === void 0 || document.owner === query.owner) && (query.type === void 0 || document.type === query.type))).map((document) => renderManagedObject(data, document)), "/inventory/binaries", { statistics: "binaries" });
2134
+ }
2135
+ return;
2136
+ }
2137
+ const document = data.managedObjects.get(id);
2138
+ const content = data.binaries.get(id);
2139
+ if (method === "GET") {
2140
+ checkQuery("GET /inventory/binaries/{id}", call, []);
2141
+ if (!document || !content) return notFound$2(id);
2142
+ const type = typeof document.type === "string" && /^[\w.+-]+\/[\w.+-]+/.test(document.type) ? document.type : "application/octet-stream";
2143
+ return download({
2144
+ ...content,
2145
+ contentType: type
2146
+ });
2147
+ }
2148
+ if (method === "PUT") {
2149
+ checkQuery("PUT /inventory/binaries/{id}", call, []);
2150
+ if (!document || !content) return notFound$2(id);
2151
+ const upload = await readUpload(call);
2152
+ if (upload instanceof Response) return upload;
2153
+ data.binaries.set(id, {
2154
+ ...upload.content,
2155
+ name: content.name
2156
+ });
2157
+ document.type = upload.content.contentType;
2158
+ document.contentType = upload.content.contentType;
2159
+ document.length = upload.content.bytes.length;
2160
+ document.lastUpdated = store.now();
2161
+ notify(data, "managedobjects", "UPDATE", renderManagedObject(data, document), id);
2162
+ return new Response(null, { status: 201 });
2163
+ }
2164
+ if (method === "DELETE") {
2165
+ checkQuery("DELETE /inventory/binaries/{id}", call, []);
2166
+ if (!deleteManagedObject(data, id)) return notFound$2(id);
2167
+ data.binaries.delete(id);
2168
+ notify(data, "managedobjects", "DELETE", { id }, id);
2169
+ return new Response(null, { status: 204 });
2170
+ }
2171
+ if (method === "POST") return apiError(405, "general/Method Not Allowed", "Request method 'POST' is not supported");
2172
+ }
2173
+ function renderAttachment(eventId, content) {
2174
+ return {
2175
+ self: `${baseUrl()}/event/events/${eventId}/binaries`,
2176
+ source: eventId,
2177
+ name: content.name,
2178
+ type: content.contentType
2179
+ };
2180
+ }
2181
+ /**
2182
+ * Event attachments: `/event/events/{id}/binaries`.
2183
+ * @param call - The request
2184
+ * @param eventId - Event id
2185
+ * @param data - Tenant data
2186
+ */
2187
+ async function eventBinaries(call, eventId, data) {
2188
+ const { method } = call;
2189
+ if (!data.events.has(eventId)) return apiError(404, "event/Not Found", `Finding event from database failed : No event for gid '${eventId}'!`);
2190
+ const existing = data.eventBinaries.get(eventId);
2191
+ if (method === "POST" || method === "PUT") {
2192
+ checkQuery(`${method} /event/events/{id}/binaries`, call, []);
2193
+ if (method === "POST" && existing) return apiError(409, "event/Duplicate", "Binary already exists.");
2194
+ const { contentType } = raw(call);
2195
+ if (/^application\/json/i.test(contentType)) return apiError(400, "event/Bad Request", "Unsupported content type for an attachment: application/json");
2196
+ const upload = await readUpload(call);
2197
+ if (upload instanceof Response) return upload;
2198
+ const multipart = contentType.startsWith("multipart/");
2199
+ const content = {
2200
+ ...upload.content,
2201
+ contentType: contentType ? upload.content.contentType : "text/plain;charset=UTF-8",
2202
+ name: multipart ? upload.content.name : eventId
2203
+ };
2204
+ data.eventBinaries.set(eventId, content);
2205
+ return Response.json(renderAttachment(eventId, content), { status: 201 });
2206
+ }
2207
+ if (method === "GET") {
2208
+ checkQuery("GET /event/events/{id}/binaries", call, []);
2209
+ return existing ? download(existing) : apiError(404, "event/Not Found", "Attachment not found");
2210
+ }
2211
+ if (method === "DELETE") {
2212
+ checkQuery("DELETE /event/events/{id}/binaries", call, []);
2213
+ data.eventBinaries.delete(eventId);
2214
+ return new Response(null, { status: 204 });
2215
+ }
2216
+ }
2217
+ // src/testing/router.ts
2218
+ function compileRoute(pattern) {
2219
+ const trimmed = pattern.trim();
2220
+ const spaceIndex = trimmed.indexOf(" ");
2221
+ return {
2222
+ method: spaceIndex === -1 ? void 0 : trimmed.slice(0, spaceIndex).toUpperCase(),
2223
+ segments: splitPath(spaceIndex === -1 ? trimmed : trimmed.slice(spaceIndex + 1).trim())
2224
+ };
2225
+ }
2226
+ function splitPath(path) {
2227
+ return path.split("?")[0].split("/").filter(Boolean);
2228
+ }
2229
+ /**
2230
+ * Matches a request against a compiled route.
2231
+ * @param route - Compiled route
2232
+ * @param method - Request method (upper case)
2233
+ * @param segments - Request path segments (decoded)
2234
+ * @returns Captured params, or `undefined` when the route does not match
2235
+ */
2236
+ function matchRoute(route, method, segments) {
2237
+ if (route.method && route.method !== method) return;
2238
+ const params = {};
2239
+ for (let i = 0; i < route.segments.length; i++) {
2240
+ const part = route.segments[i];
2241
+ if (part === "**") {
2242
+ params._ = segments.slice(i).join("/");
2243
+ return params;
2244
+ }
2245
+ const value = segments[i];
2246
+ if (value === void 0) return;
2247
+ if (part === "*") continue;
2248
+ if (part.startsWith(":")) {
2249
+ params[part.slice(1)] = value;
2250
+ continue;
2251
+ }
2252
+ if (part !== value) return;
2253
+ }
2254
+ return segments.length === route.segments.length ? params : void 0;
2255
+ }
2256
+ // src/testing/api/notifications.ts
2257
+ const APIS = [
2258
+ "*",
2259
+ "managedobjects",
2260
+ "measurements",
2261
+ "events",
2262
+ "alarms",
2263
+ "operations"
2264
+ ];
2265
+ const UNSUPPORTED_APIS = ["alarmsWithChildren", "eventsWithChildren"];
2266
+ function render(subscription) {
2267
+ const { tenant, ...rest } = subscription;
2268
+ return {
2269
+ nonPersistent: false,
2270
+ ...rest,
2271
+ self: `${baseUrl()}/notification2/subscriptions/${subscription.id}`,
2272
+ source: subscription.source ? {
2273
+ id: subscription.source.id,
2274
+ self: moSelf(subscription.source.id)
2275
+ } : {
2276
+ id: tenant,
2277
+ self: `${baseUrl()}/tenant/tenants/${tenant}`
2278
+ }
2279
+ };
2280
+ }
2281
+ function notFound(id) {
2282
+ return apiError(404, "reliable-notification/Not Found", `Subscription with id '${id}' not found`);
2283
+ }
2284
+ const NAME = /^[a-z0-9]+$/i;
2285
+ /**
2286
+ * Notification 2.0 API: subscriptions, tokens, and unsubscribing consumers.
2287
+ * The consumer WebSocket itself is `FakeWebSocket` in `../realtime`.
2288
+ * @param call - The request
2289
+ * @param segments - Path segments
2290
+ * @param data - Data of the request's tenant
2291
+ */
2292
+ function notifications(call, segments, data) {
2293
+ const [, resource, id] = segments;
2294
+ const { method } = call;
2295
+ const tenant = data.tenant;
2296
+ const body = call.body ?? {};
2297
+ if (resource === "subscriptions" && id === void 0) {
2298
+ if (method === "POST") {
2299
+ checkQuery("POST /notification2/subscriptions", call, []);
2300
+ const missing = checkRequired("POST /notification2/subscriptions", body);
2301
+ if (missing) return missing;
2302
+ const apis = body.subscriptionFilter?.apis ?? ["*"];
2303
+ const unsupported = apis.filter((api) => UNSUPPORTED_APIS.includes(api));
2304
+ if (unsupported.length > 0) throw new UnsupportedRequestError(`subscription api "${unsupported.join(", ")}" is not emulated`);
2305
+ if (apis.some((api) => !APIS.includes(api)) || apis.includes("*") && apis.length > 1) return apiError(422, "reliable-notification/Invalid Data", `Invalid subscription filter apis: ${apis.join(", ")}`);
2306
+ if (!NAME.test(String(body.subscription))) return apiError(422, "reliable-notification/Invalid Data", "The subscription name must be alphanumeric");
2307
+ if (body.context !== "mo" && body.context !== "tenant") return apiError(422, "reliable-notification/Invalid Data", `Invalid context '${body.context}'`);
2308
+ if (body.context === "tenant" && body.source !== void 0) return apiError(422, "reliable-notification/Invalid Data", "A tenant subscription has no source");
2309
+ if (body.context === "mo" && body.source?.id === void 0) return apiError(422, "reliable-notification/Invalid Data", "A managed object subscription needs a source");
2310
+ if (body.context === "mo" && !data.managedObjects.has(String(body.source.id))) return apiError(404, "inventory/Not Found", `Finding device data from database failed : No managedObject for id '${body.source.id}'!`);
2311
+ const sourceId = body.context === "mo" ? String(body.source.id) : void 0;
2312
+ if (realtime.subscriptions.some((item) => item.tenant === tenant && item.subscription === body.subscription && item.source?.id === sourceId)) return apiError(409, "reliable-notification/Duplicate", `Subscription '${body.subscription}' already exists`);
2313
+ const created = realtime.createSubscription({
2314
+ tenant,
2315
+ context: body.context,
2316
+ subscription: body.subscription,
2317
+ ...sourceId ? { source: { id: sourceId } } : {},
2318
+ ...body.subscriptionFilter ? { subscriptionFilter: body.subscriptionFilter } : {},
2319
+ ...body.fragmentsToCopy ? { fragmentsToCopy: body.fragmentsToCopy } : {},
2320
+ ...body.nonPersistent !== void 0 ? { nonPersistent: body.nonPersistent } : {}
2321
+ });
2322
+ return Response.json(render(created), { status: 201 });
2323
+ }
2324
+ if (method === "GET") {
2325
+ checkQuery("GET /notification2/subscriptions", call, [
2326
+ "context",
2327
+ "currentPage",
2328
+ "pageSize",
2329
+ "source",
2330
+ "subscription",
2331
+ "withTotalElements",
2332
+ "withTotalPages"
2333
+ ]);
2334
+ const { query } = call;
2335
+ return paged(call, "subscriptions", realtime.subscriptions.filter((item) => item.tenant === tenant && (query.subscription === void 0 || item.subscription === query.subscription) && (query.context === void 0 || item.context === query.context) && (query.source === void 0 || item.source?.id === query.source)).map(render), "/notification2/subscriptions");
2336
+ }
2337
+ if (method === "DELETE") {
2338
+ checkQuery("DELETE /notification2/subscriptions", call, ["context", "source"]);
2339
+ realtime.subscriptions = realtime.subscriptions.filter((item) => !(item.tenant === tenant && (call.query.context === void 0 || item.context === call.query.context) && (call.query.source === void 0 || item.source?.id === call.query.source)));
2340
+ return new Response(null, { status: 204 });
2341
+ }
2342
+ return;
2343
+ }
2344
+ if (resource === "subscriptions" && id !== void 0 && segments.length === 3) {
2345
+ const subscription = realtime.subscriptions.find((item) => item.tenant === tenant && item.id === id);
2346
+ if (method === "GET") return subscription ? Response.json(render(subscription)) : notFound(id);
2347
+ if (method === "DELETE") {
2348
+ if (!subscription) return notFound(id);
2349
+ realtime.subscriptions = realtime.subscriptions.filter((item) => item !== subscription);
2350
+ return new Response(null, { status: 204 });
2351
+ }
2352
+ return;
2353
+ }
2354
+ if (resource === "token" && method === "POST" && segments.length === 2) {
2355
+ checkQuery("POST /notification2/token", call, []);
2356
+ if (!body.subscriber || !body.subscription) return apiError(422, "undefined/validationError", "Following mandatory fields should be included: subscriber,subscription");
2357
+ if (!NAME.test(String(body.subscriber))) return apiError(422, "reliable-notification/Invalid Data", "The subscriber name must be alphanumeric");
2358
+ return Response.json({ token: realtime.createToken({
2359
+ tenant,
2360
+ subscription: body.subscription,
2361
+ subscriber: body.subscriber
2362
+ }) });
2363
+ }
2364
+ if (resource === "unsubscribe" && method === "POST" && segments.length === 2) {
2365
+ const token = call.query.token ?? "";
2366
+ if (!token) return apiError(422, "reliable-notification/Invalid Data", "Missing token");
2367
+ const consumers = [...realtime.sockets].filter((socket) => new URL(socket.url).searchParams.get("token") === token);
2368
+ if (!realtime.tokens.has(token) || consumers.length === 0) return apiError(500, "reliable-notification/Internal Server Error", "Unsubscribing the consumer failed");
2369
+ realtime.tokens.delete(token);
2370
+ for (const socket of consumers) socket.close();
2371
+ return Response.json({ result: "DONE" });
2372
+ }
2373
+ }
2374
+ // src/testing/api/index.ts
2375
+ /**
2376
+ * Answers a request from the emulated platform APIs (inventory, identity,
2377
+ * measurements, events, alarms, operations).
2378
+ * @param call - The request
2379
+ * @param segments - Decoded path segments
2380
+ * @param data - Data of the request's tenant
2381
+ * @returns The response, or `undefined` if the endpoint is not emulated
2382
+ */
2383
+ async function platformApi(call, segments, data) {
2384
+ const [domain, resource] = segments;
2385
+ if (domain === "inventory" && resource === "binaries") return inventoryBinaries(call, segments, data);
2386
+ if (domain === "event" && resource === "events" && segments[3] === "binaries" && segments.length === 4) return eventBinaries(call, segments[2], data);
2387
+ if (domain === "inventory" && resource === "managedObjects") return inventory(call, segments, data);
2388
+ if (domain === "identity") return identity(call, segments, data);
2389
+ if (domain === "measurement" && resource === "measurements") return documents(call, segments, data, RESOURCES.measurement);
2390
+ if (domain === "event" && resource === "events") return documents(call, segments, data, RESOURCES.event);
2391
+ if (domain === "alarm" && resource === "alarms") return documents(call, segments, data, RESOURCES.alarm);
2392
+ if (domain === "notification2") return notifications(call, segments, data);
2393
+ if (domain === "devicecontrol" && resource === "operations") return documents(call, segments, data, RESOURCES.devicecontrol);
2394
+ }
2395
+ // src/testing/platform.ts
2396
+ /**
2397
+ * The in-memory Cumulocity tenant behind `c8y` in `c8y-nitro/testing`.
2398
+ * One instance per test file (Vitest isolates modules per file).
2399
+ */
2400
+ const platform = {
2401
+ baseline: {},
2402
+ tenant: DEFAULTS.tenant,
2403
+ user: null,
2404
+ subscriptions: [],
2405
+ /**
2406
+ * `${tenant}\u0000${category}\u0000${key}` → value
2407
+ */
2408
+ options: /* @__PURE__ */ new Map(),
2409
+ handlers: [],
2410
+ calls: [],
2411
+ unhandled: [],
2412
+ onUnhandledRequest: "error",
2413
+ /**
2414
+ * Requests being answered right now.
2415
+ */
2416
+ inflight: 0,
2417
+ /**
2418
+ * Sets the deployed tenant, subscriptions, and default user from the
2419
+ * baseline before the Nitro app starts, so plugins that run at startup (e.g.
2420
+ * realtime subscriptions) find credentials. Tenant options are applied by
2421
+ * `reset()` once the app's runtime config exists.
2422
+ * @param baseline - Plugin options
2423
+ */
2424
+ prepare(baseline) {
2425
+ this.baseline = baseline;
2426
+ this.tenant = baseline.tenant ?? DEFAULTS.tenant;
2427
+ this.subscriptions = (baseline.subscriptions ?? [defaultSubscription(this.tenant)]).map((subscription) => ({ ...subscription }));
2428
+ this.setUser(baseline.user === void 0 ? DEFAULTS.user : baseline.user);
2429
+ },
2430
+ /**
2431
+ * Restores the baseline from the plugin config.
2432
+ */
2433
+ reset() {
2434
+ this.tenant = this.baseline.tenant ?? DEFAULTS.tenant;
2435
+ this.user = null;
2436
+ this.subscriptions = [];
2437
+ this.options.clear();
2438
+ this.handlers = [];
2439
+ this.calls = [];
2440
+ this.unhandled = [];
2441
+ store.reset();
2442
+ this.onUnhandledRequest = this.baseline.onUnhandledRequest ?? "error";
2443
+ this.apply({
2444
+ user: this.baseline.user === void 0 ? DEFAULTS.user : this.baseline.user,
2445
+ subscriptions: this.baseline.subscriptions ?? [defaultSubscription(this.tenant)],
2446
+ tenantOptions: this.baseline.tenantOptions
2447
+ });
2448
+ },
2449
+ /**
2450
+ * Applies a partial state on top of the current one.
2451
+ * @param state - Partial tenant state
2452
+ */
2453
+ apply(state) {
2454
+ if (state.tenant !== void 0) this.tenant = state.tenant;
2455
+ if (state.user !== void 0) this.setUser(state.user);
2456
+ if (state.subscriptions !== void 0) this.subscriptions = state.subscriptions.map((subscription) => ({ ...subscription }));
2457
+ for (const [key, value] of Object.entries(state.tenantOptions ?? {})) this.setOption(key, value);
2458
+ if (state.onUnhandledRequest !== void 0) this.onUnhandledRequest = state.onUnhandledRequest;
2459
+ syncEnv();
2460
+ },
2461
+ setUser(user) {
2462
+ this.user = user ? {
2463
+ ...user,
2464
+ roles: [...user.roles ?? []]
2465
+ } : null;
2466
+ syncEnv();
2467
+ },
2468
+ setOption(key, value, opts = {}) {
2469
+ this.options.set(optionId(opts.tenant ?? this.tenant, opts.category ?? ownCategory(), key), value);
2470
+ },
2471
+ getOption(key, opts = {}) {
2472
+ return this.options.get(optionId(opts.tenant ?? this.tenant, opts.category ?? ownCategory(), key));
2473
+ },
2474
+ deleteOption(key, opts = {}) {
2475
+ return this.options.delete(optionId(opts.tenant ?? this.tenant, opts.category ?? ownCategory(), key));
2476
+ },
2477
+ override(pattern, handler, once = false) {
2478
+ const entry = {
2479
+ route: compileRoute(pattern),
2480
+ pattern,
2481
+ handler,
2482
+ once
2483
+ };
2484
+ this.handlers.push(entry);
2485
+ return () => {
2486
+ this.handlers = this.handlers.filter((item) => item !== entry);
2487
+ };
2488
+ },
2489
+ /**
2490
+ * Answers a request the real `@c8y/client` would send to the platform.
2491
+ * @param request - Request built by the patched FetchClient
2492
+ */
2493
+ async fetch(request) {
2494
+ this.inflight++;
2495
+ try {
2496
+ return await this.answer(request);
2497
+ } finally {
2498
+ this.inflight--;
2499
+ }
2500
+ },
2501
+ async answer(request) {
2502
+ const url = new URL(request.url);
2503
+ const method = request.method.toUpperCase();
2504
+ const segments = splitPath(url.pathname).map(decodeURIComponent);
2505
+ const path = `/${segments.join("/")}`;
2506
+ const caller = parseBasicAuth(request.headers.get("authorization"));
2507
+ const bytes = request.body ? new Uint8Array(await request.arrayBuffer()) : /* @__PURE__ */ new Uint8Array();
2508
+ const contentType = request.headers.get("content-type") ?? "";
2509
+ const isBinary = contentType.startsWith("multipart/") || contentType.includes("octet-stream");
2510
+ const text = bytes.length > 0 && !isBinary ? new TextDecoder().decode(bytes) : "";
2511
+ let body = text || void 0;
2512
+ try {
2513
+ body = text ? JSON.parse(text) : void 0;
2514
+ } catch {}
2515
+ const call = {
2516
+ method,
2517
+ path,
2518
+ query: Object.fromEntries(url.searchParams),
2519
+ body,
2520
+ headers: Object.fromEntries(request.headers),
2521
+ tenant: url.searchParams.get("targetTenant") ?? caller?.tenant ?? this.tenant,
2522
+ user: caller?.user,
2523
+ params: {}
2524
+ };
2525
+ this.calls.push(call);
2526
+ rawRequests.set(call, {
2527
+ bytes,
2528
+ contentType,
2529
+ params: url.searchParams
2530
+ });
2531
+ for (let i = this.handlers.length - 1; i >= 0; i--) {
2532
+ const entry = this.handlers[i];
2533
+ const params = matchRoute(entry.route, method, segments);
2534
+ if (!params) continue;
2535
+ if (entry.once) this.handlers.splice(i, 1);
2536
+ return toResponse(await entry.handler({
2537
+ ...call,
2538
+ params,
2539
+ request
2540
+ }));
2541
+ }
2542
+ const builtIn = builtInRoute(call, segments);
2543
+ if (builtIn) return builtIn;
2544
+ let reason = "endpoint not emulated";
2545
+ try {
2546
+ const response = await platformApi(call, segments, store.tenant(call.tenant));
2547
+ if (response) return response;
2548
+ } catch (error) {
2549
+ if (!(error instanceof UnsupportedRequestError)) throw error;
2550
+ reason = error.message;
2551
+ }
2552
+ const unhandled = {
2553
+ ...call,
2554
+ reason
2555
+ };
2556
+ this.unhandled.push(unhandled);
2557
+ if (this.onUnhandledRequest === "warn") console.warn(unhandledMessage([unhandled]));
2558
+ return apiError(501, "c8y-nitro/testing/unsupported", `${method} ${path}: ${reason} by the c8y-nitro/testing fake tenant. Answer it with c8y.override('${method} ${path}', ...).`);
2559
+ }
2560
+ };
2561
+ function unhandledMessage(calls) {
2562
+ return [
2563
+ `c8y-nitro/testing: ${calls.length} Cumulocity request(s) the fake tenant does not support:`,
2564
+ ...calls.map((call) => ` ${call.method} ${call.path} (${call.reason})`),
2565
+ "Answer them with c8y.override('METHOD /path', handler) (see \"What the fake emulates\" in the testing guide), or set onUnhandledRequest to 'warn' or 'ignore'."
2566
+ ].join("\n");
2567
+ }
2568
+ function defaultSubscription(tenant) {
2569
+ return {
2570
+ tenant,
2571
+ user: `service_${tenant}`,
2572
+ password: "service-password"
2573
+ };
2574
+ }
2575
+ function optionId(tenant, category, key) {
2576
+ return `${tenant}\u0000${category}\u0000${key}`;
2577
+ }
2578
+ function ownCategory() {
2579
+ return useRuntimeConfig().c8ySettingsCategory;
2580
+ }
2581
+ /**
2582
+ * Keeps the dev and bootstrap env in sync with the tenant and current user.
2583
+ */
2584
+ function syncEnv() {
2585
+ process.env.C8Y_BOOTSTRAP_TENANT = platform.tenant;
2586
+ process.env.C8Y_DEVELOPMENT_TENANT = platform.user?.tenant ?? platform.tenant;
2587
+ process.env.C8Y_DEVELOPMENT_USER = platform.user?.userName ?? DEFAULTS.user.userName;
2588
+ }
2589
+ function parseBasicAuth(header) {
2590
+ if (!header?.startsWith("Basic ")) return;
2591
+ const decoded = Buffer.from(header.slice(6), "base64").toString();
2592
+ const login = decoded.slice(0, decoded.indexOf(":"));
2593
+ const slash = login.indexOf("/");
2594
+ return slash === -1 ? { user: login } : {
2595
+ tenant: login.slice(0, slash),
2596
+ user: login.slice(slash + 1)
2597
+ };
2598
+ }
2599
+ function toResponse(result) {
2600
+ if (result instanceof Response) return result;
2601
+ if (result === void 0) return new Response(null, { status: 204 });
2602
+ return Response.json(result);
2603
+ }
2604
+ function optionNotFound(category, key) {
2605
+ return apiError(404, "options/Not Found", `Unable to find option by given key: ${category}/${key}`);
2606
+ }
2607
+ /**
2608
+ * `credentials.` options are encrypted: reads show `<<Encrypted>>`, except for
2609
+ * the microservice's own settings category, which the platform decrypts for
2610
+ * the service (verified against the platform for other categories).
2611
+ * @param category - Option category
2612
+ * @param key - Option key
2613
+ * @param value - Stored value
2614
+ */
2615
+ function readableOption(category, key, value) {
2616
+ return key.startsWith("credentials.") && category !== ownCategory() ? "<<Encrypted>>" : value;
2617
+ }
2618
+ function encrypted(key, value) {
2619
+ return key.startsWith("credentials.") ? Buffer.from(`fake-cipher:${value}`).toString("hex") : value;
2620
+ }
2621
+ /**
2622
+ * Platform endpoints that c8y-nitro itself relies on.
2623
+ * @param call - The recorded request
2624
+ * @param segments - Decoded path segments
2625
+ */
2626
+ function builtInRoute(call, segments) {
2627
+ const { method } = call;
2628
+ const [a, b, category, key, editable] = segments;
2629
+ if (method === "GET" && a === "user" && b === "currentUser" && segments.length === 2) {
2630
+ const user = platform.user;
2631
+ if (!user) return Response.json({
2632
+ error: "security/Unauthorized",
2633
+ message: "Invalid credentials!"
2634
+ }, { status: 401 });
2635
+ const { roles = [], tenant: _tenant, ...rest } = user;
2636
+ return Response.json({
2637
+ id: user.userName,
2638
+ ...rest,
2639
+ effectiveRoles: roles.map((name) => ({
2640
+ id: name,
2641
+ name
2642
+ }))
2643
+ });
2644
+ }
2645
+ if (method === "GET" && a === "tenant" && b === "currentTenant" && segments.length === 2) return Response.json({
2646
+ name: call.tenant,
2647
+ domainName: new URL(process.env.C8Y_BASEURL || DEFAULTS.baseUrl).host
2648
+ });
2649
+ if (method === "GET" && a === "application" && b === "currentApplication" && category === "subscriptions" && segments.length === 3) return Response.json({ users: platform.subscriptions.map(({ tenant, user, password }) => ({
2650
+ tenant,
2651
+ name: user,
2652
+ password
2653
+ })) });
2654
+ if (a === "tenant" && b === "options" && category !== void 0) {
2655
+ const scope = {
2656
+ tenant: call.tenant,
2657
+ category
2658
+ };
2659
+ if (key !== void 0 && editable === "editable" && segments.length === 5) return method === "PUT" ? Response.json({ editable: Boolean(call.body?.editable) }) : void 0;
2660
+ if (key !== void 0 && segments.length === 4) {
2661
+ const self = `${new URL(process.env.C8Y_BASEURL || DEFAULTS.baseUrl).origin}/tenant/options/${encodeURIComponent(category)}/${encodeURIComponent(key)}`;
2662
+ if (method === "GET") {
2663
+ const value = platform.getOption(key, scope);
2664
+ return value === void 0 ? optionNotFound(category, key) : Response.json({
2665
+ self,
2666
+ category,
2667
+ key,
2668
+ value: readableOption(category, key, value)
2669
+ });
2670
+ }
2671
+ if (method === "PUT") {
2672
+ const value = String(call.body?.value ?? "");
2673
+ platform.setOption(key, value, scope);
2674
+ return Response.json({
2675
+ self,
2676
+ category,
2677
+ key,
2678
+ value: encrypted(key, value)
2679
+ });
2680
+ }
2681
+ if (method === "DELETE") return platform.deleteOption(key, scope) ? new Response(null, { status: 204 }) : optionNotFound(category, key);
2682
+ return;
2683
+ }
2684
+ if (segments.length === 3) {
2685
+ if (method === "PUT") {
2686
+ const result = {};
2687
+ for (const [k, v] of Object.entries(call.body ?? {})) {
2688
+ platform.setOption(k, String(v), scope);
2689
+ result[k] = encrypted(k, String(v));
2690
+ }
2691
+ return Response.json(result);
2692
+ }
2693
+ if (method === "GET") {
2694
+ const prefix = optionId(call.tenant, category, "");
2695
+ const result = {};
2696
+ for (const [id, value] of platform.options) if (id.startsWith(prefix)) {
2697
+ const key = id.slice(prefix.length);
2698
+ result[key] = readableOption(category, key, value);
2699
+ }
2700
+ return Response.json(result);
2701
+ }
2702
+ }
2703
+ }
2704
+ }
2705
+ // src/testing/scope.ts
2706
+ /**
2707
+ * Scope-aware arrange calls.
2708
+ *
2709
+ * While Vitest collects a file, `c8y.*` calls do not change state. They
2710
+ * register a `beforeEach` in the scope they were called in (file top level or
2711
+ * a `describe`), so Vitest's own hook order replays them before every test:
2712
+ * outer scopes first, then inner ones, after the reset to the plugin baseline.
2713
+ * Inside a test (or a `beforeEach`), calls apply immediately and are undone by
2714
+ * the reset before the next test.
2715
+ */
2716
+ let phase = "collect";
2717
+ /**
2718
+ * Called from the setup file's `beforeAll`, which runs after collection.
2719
+ */
2720
+ function markRunning() {
2721
+ phase = "run";
2722
+ }
2723
+ /**
2724
+ * The error for test-time APIs in a concurrent test. Vitest 5 has no
2725
+ * `.sequential` modifier: a test opts out with `{ concurrent: false }`.
2726
+ * @param name - API name for error messages
2727
+ * @param test - Name of the concurrent test
2728
+ */
2729
+ function concurrentError(name, test) {
2730
+ return /* @__PURE__ */ new Error(`${name} cannot be used in concurrent tests ("${test}"): the fake Cumulocity tenant is shared by all tests of a file. Remove \`.concurrent\`, or pass \`{ concurrent: false }\` to the test or describe (for example when \`sequence.concurrent\` is on).`);
2731
+ }
2732
+ /**
2733
+ * Runs an arrange operation in the scope it was called from.
2734
+ * @param name - API name for error messages, e.g. `c8y.user()`
2735
+ * @param operation - Applies the change to the fake tenant
2736
+ */
2737
+ function arrange(name, operation) {
2738
+ if (phase === "collect") {
2739
+ beforeEach((context) => {
2740
+ if (context.task.concurrent) throw concurrentError(name, context.task.name);
2741
+ store.silent = true;
2742
+ try {
2743
+ operation();
2744
+ } finally {
2745
+ store.silent = false;
2746
+ }
2747
+ });
2748
+ return;
2749
+ }
2750
+ assertActiveTest(name);
2751
+ operation();
2752
+ }
2753
+ /**
2754
+ * Throws when a test-time API is used outside a running test, or in a
2755
+ * concurrent test (the fake tenant is shared by the tests of a file).
2756
+ * @param name - API name for error messages
2757
+ */
2758
+ function assertActiveTest(name) {
2759
+ const test = TestRunner.getCurrentTest();
2760
+ if (!test) throw new Error(`${name} cannot be used in beforeAll() or afterAll(). Call it at file or describe level, in beforeEach(), or inside a test.`);
2761
+ if (test.concurrent) throw concurrentError(name, test.name);
2762
+ }
2763
+ export { store as S, FakeWebSocket as _, unhandledMessage as a, waitForRealtimeStartup as b, splitPath as c, RESOURCES as d, createDocument as f, renderManagedObject as g, createManagedObject as h, platform as i, createBinary as l, updateDocument as m, assertActiveTest as n, compileRoute as o, renderDocument as p, markRunning as r, matchRoute as s, arrange as t, addExternalId as u, realtime as v, addChild as x, trackInflight as y };