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.
- package/README.md +2 -0
- package/dist/{apiClient-kxv5KHYJ.mjs → apiClient-GMQqcgQe.mjs} +2 -3
- package/dist/{autoBootstrap-DUjHuHe_.mjs → autoBootstrap-D4vEytWW.mjs} +6 -8
- package/dist/{bootstrap-DrMjty-k.mjs → bootstrap-DpYAhM7C.mjs} +6 -7
- package/dist/cli/index.mjs +6 -7
- package/dist/{config-BVQHqlgV.mjs → config-Dozrdw3A.mjs} +11 -8
- package/dist/{credentials-eKnJ7m9Y.d.mts → credentials-C_3m4LVX.d.mts} +0 -2
- package/dist/defaults-CrrCinRJ.mjs +20 -0
- package/dist/{c8y-api-DSs1xIIh.mjs → env-fUAhIWsI.mjs} +76 -3
- package/dist/{env-file-BrOTmVjA.mjs → env-file-DWbaFlIp.mjs} +1 -2
- package/dist/{index-BEFoDy-y.d.mts → index-CmIQfBpj.d.mts} +56 -22
- package/dist/index.d.mts +4 -6
- package/dist/index.mjs +20 -21
- package/dist/{logging-BUYT8Z2V.mjs → logging-B1PuWdiF.mjs} +1 -2
- package/dist/{manifest-DV3UW9Dl.mjs → manifest-BhrHDm8Y.mjs} +4 -6
- package/dist/{options-54uJmuUV.mjs → options-CgTptIpI.mjs} +5 -6
- package/dist/{package-Dqlz6AoZ.mjs → package-y3p_br3i.mjs} +2 -3
- package/dist/{roles-CNCObfa3.mjs → roles-DEIHe2Xt.mjs} +4 -5
- package/dist/runtime/handlers/invalidateTenantOptions.mjs +2 -408
- package/dist/runtime/handlers/liveness-readiness.mjs +1 -2
- package/dist/runtime/handlers/openapi.mjs +4 -5
- package/dist/runtime/handlers/rewriteRequestUrl.mjs +3 -4
- package/dist/runtime/middlewares/c8y-client.mjs +3 -4
- package/dist/runtime/middlewares/dev-user.dev.mjs +2 -3
- package/dist/runtime/plugins/c8y-variables.dev.mjs +2 -3
- package/dist/runtime/plugins/enrich-logs.mjs +2 -3
- package/dist/scope-CxEPZ74p.mjs +2763 -0
- package/dist/{client-BmSKIcuq.mjs → tenant-0MBgBY_o.mjs} +115 -139
- package/dist/testing/setup.d.mts +1 -0
- package/dist/testing/setup.mjs +62 -0
- package/dist/testing/vite.d.mts +26 -0
- package/dist/testing/vite.mjs +58 -0
- package/dist/testing-D5VjeD-F.mjs +14 -0
- package/dist/testing.d.mts +521 -0
- package/dist/testing.mjs +494 -0
- package/dist/{typegen-BO_AkyNc.mjs → typegen-Bw4VxFmA.mjs} +4 -5
- package/dist/types-D663gk80.d.mts +140 -0
- package/dist/types.d.mts +3 -3
- package/dist/utils.d.mts +48 -66
- package/dist/utils.mjs +16 -27
- 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 };
|