c8y-nitro 0.10.4 → 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-CElU3xv-.mjs → autoBootstrap-D4vEytWW.mjs} +6 -8
- package/dist/{bootstrap-hV_pJO5R.mjs → bootstrap-DpYAhM7C.mjs} +6 -7
- package/dist/cli/index.mjs +6 -7
- package/dist/{config-4Ju9safS.mjs → config-Dozrdw3A.mjs} +2 -3
- package/dist/{credentials-eKnJ7m9Y.d.mts → credentials-C_3m4LVX.d.mts} +0 -2
- package/dist/defaults-CrrCinRJ.mjs +20 -0
- package/dist/{env-CM-t7BwA.mjs → env-fUAhIWsI.mjs} +3 -5
- package/dist/{env-file-BrOTmVjA.mjs → env-file-DWbaFlIp.mjs} +1 -2
- package/dist/{index-BOaWGoaf.d.mts → index-CmIQfBpj.d.mts} +38 -22
- package/dist/index.d.mts +4 -6
- package/dist/index.mjs +16 -22
- package/dist/{logging-BUYT8Z2V.mjs → logging-B1PuWdiF.mjs} +1 -2
- package/dist/{manifest-DV3UW9Dl.mjs → manifest-BhrHDm8Y.mjs} +4 -6
- package/dist/{options-Bif4gAIp.mjs → options-CgTptIpI.mjs} +5 -6
- package/dist/{package-WtwC9ClY.mjs → package-y3p_br3i.mjs} +2 -3
- package/dist/{roles-D08LKKLI.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-D8DbCxT6.mjs → tenant-0MBgBY_o.mjs} +114 -138
- 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 +15 -26
- package/package.json +36 -19
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { S as store, c as splitPath, d as RESOURCES, f as createDocument, g as renderManagedObject, h as createManagedObject, i as platform, l as createBinary, m as updateDocument, n as assertActiveTest, o as compileRoute, p as renderDocument, s as matchRoute, t as arrange, u as addExternalId, v as realtime, x as addChild } from "./scope-CxEPZ74p.mjs";
|
|
2
|
+
import { t as DEFAULTS } from "./defaults-CrrCinRJ.mjs";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { serverFetch as serverFetch$1 } from "nitro";
|
|
5
|
+
// src/testing/data.ts
|
|
6
|
+
var Ref = class {
|
|
7
|
+
label;
|
|
8
|
+
constructor(label) {
|
|
9
|
+
this.label = label;
|
|
10
|
+
}
|
|
11
|
+
get id() {
|
|
12
|
+
const id = store.refs.get(this);
|
|
13
|
+
if (id === void 0) throw new Error(`${this.label} does not exist yet: managed objects created with c8y.device()/c8y.managedObject() exist while a test runs, in the scope they were created in.`);
|
|
14
|
+
return id;
|
|
15
|
+
}
|
|
16
|
+
toJSON() {
|
|
17
|
+
return { id: this.id };
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function resolveId(input) {
|
|
21
|
+
return typeof input === "string" ? input : input.id;
|
|
22
|
+
}
|
|
23
|
+
const RESERVED = [
|
|
24
|
+
"children",
|
|
25
|
+
"assets",
|
|
26
|
+
"additions",
|
|
27
|
+
"externalIds",
|
|
28
|
+
"tenant"
|
|
29
|
+
];
|
|
30
|
+
function newRef(label) {
|
|
31
|
+
return new Ref(label);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Creates a managed object from factory input and binds it to its ref.
|
|
35
|
+
* @param ref - The ref returned to the test
|
|
36
|
+
* @param input - Factory input
|
|
37
|
+
*/
|
|
38
|
+
function createFromInput(ref, input) {
|
|
39
|
+
const data = store.tenant(input.tenant ?? platform.tenant);
|
|
40
|
+
const fragments = Object.fromEntries(Object.entries(input).filter(([key]) => !RESERVED.includes(key)));
|
|
41
|
+
const document = createManagedObject(data, fragments, input.owner ?? platform.user?.userName ?? "test-user");
|
|
42
|
+
store.refs.set(ref, document.id);
|
|
43
|
+
for (const [kind, children] of [
|
|
44
|
+
["childDevices", input.children],
|
|
45
|
+
["childAssets", input.assets],
|
|
46
|
+
["childAdditions", input.additions]
|
|
47
|
+
]) for (const child of children ?? []) addChild(data, document.id, kind, child.id);
|
|
48
|
+
for (const [type, externalId] of Object.entries(input.externalIds ?? {})) if (addExternalId(data, document.id, type, externalId) instanceof Response) throw new TypeError(`c8y: external id ${type}/${externalId} already exists`);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Creates an inventory binary from seed input and binds it to its ref.
|
|
52
|
+
* @param ref - The ref returned to the test
|
|
53
|
+
* @param input - Binary input
|
|
54
|
+
*/
|
|
55
|
+
function createBinaryFromInput(ref, input) {
|
|
56
|
+
const { name, content, contentType, tenant, ...fragments } = input;
|
|
57
|
+
const data = store.tenant(tenant ?? platform.tenant);
|
|
58
|
+
const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
59
|
+
const document = createBinary(data, {
|
|
60
|
+
bytes,
|
|
61
|
+
contentType: contentType ?? (typeof content === "string" ? "text/plain" : "application/octet-stream"),
|
|
62
|
+
name
|
|
63
|
+
}, {
|
|
64
|
+
name,
|
|
65
|
+
...fragments
|
|
66
|
+
}, platform.user?.userName ?? "test-user");
|
|
67
|
+
store.refs.set(ref, document.id);
|
|
68
|
+
}
|
|
69
|
+
function toContent(content) {
|
|
70
|
+
return content && {
|
|
71
|
+
...content,
|
|
72
|
+
text: () => new TextDecoder().decode(content.bytes)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function seedInto(data, collection, input) {
|
|
76
|
+
const resource = Object.values(RESOURCES).find((item) => item.collection === collection);
|
|
77
|
+
const body = {
|
|
78
|
+
time: store.now(),
|
|
79
|
+
...input
|
|
80
|
+
};
|
|
81
|
+
if ("source" in input) body.source = { id: resolveId(input.source) };
|
|
82
|
+
if (collection === "events" || collection === "alarms") body.text ??= body.type;
|
|
83
|
+
if (collection === "alarms") body.severity ??= "MAJOR";
|
|
84
|
+
if (collection === "operations") {
|
|
85
|
+
delete body.time;
|
|
86
|
+
delete body.status;
|
|
87
|
+
delete body.failureReason;
|
|
88
|
+
body.deviceId = resolveId(input.deviceId);
|
|
89
|
+
}
|
|
90
|
+
const result = createDocument(data, resource, body);
|
|
91
|
+
if (result instanceof Response) throw new TypeError(`c8y.seed(): invalid ${collection.slice(0, -1)}: ${result.message ?? result.status}`);
|
|
92
|
+
if (collection === "operations" && (input.status !== void 0 || input.failureReason !== void 0)) updateDocument(data, resource, result, {
|
|
93
|
+
status: input.status ?? "PENDING",
|
|
94
|
+
failureReason: input.failureReason
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Seeds measurements, events, alarms, and operations (validated like API writes).
|
|
99
|
+
* @param seed - Data to seed
|
|
100
|
+
*/
|
|
101
|
+
function applySeed(seed) {
|
|
102
|
+
const data = store.tenant(seed.tenant ?? platform.tenant);
|
|
103
|
+
for (const measurement of seed.measurements ?? []) seedInto(data, "measurements", measurement);
|
|
104
|
+
for (const event of seed.events ?? []) seedInto(data, "events", event);
|
|
105
|
+
for (const alarm of seed.alarms ?? []) seedInto(data, "alarms", alarm);
|
|
106
|
+
for (const operation of seed.operations ?? []) seedInto(data, "operations", operation);
|
|
107
|
+
}
|
|
108
|
+
function matches(value, expected) {
|
|
109
|
+
if (expected instanceof Ref) return value?.id === expected.id || value === expected.id;
|
|
110
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected)) return Object.entries(expected).every(([key, item]) => matches(value?.[key], item));
|
|
111
|
+
return value === expected;
|
|
112
|
+
}
|
|
113
|
+
function filterDocuments(documents, filter) {
|
|
114
|
+
if (!filter) return documents;
|
|
115
|
+
if (typeof filter === "function") return documents.filter(filter);
|
|
116
|
+
return documents.filter((document) => Object.entries(filter).every(([key, expected]) => {
|
|
117
|
+
if ((key === "source" || key === "deviceId") && (typeof expected === "string" || expected instanceof Ref)) {
|
|
118
|
+
const id = typeof expected === "string" ? expected : expected.id;
|
|
119
|
+
return (key === "source" ? document.source?.id : document.deviceId) === id;
|
|
120
|
+
}
|
|
121
|
+
return matches(document[key], expected);
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
function collectionView(tenant, collection) {
|
|
125
|
+
const rendered = () => {
|
|
126
|
+
const data = store.tenant(tenant());
|
|
127
|
+
return [...data[collection].values()].map((document) => collection === "managedObjects" ? renderManagedObject(data, document, { withParents: true }) : renderDocument(data, Object.values(RESOURCES).find((item) => item.collection === collection), document));
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
get: (id) => rendered().find((document) => document.id === resolveId(id)),
|
|
131
|
+
find: (filter) => filterDocuments(rendered(), filter)
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function createDb(tenant) {
|
|
135
|
+
return {
|
|
136
|
+
managedObjects: collectionView(tenant, "managedObjects"),
|
|
137
|
+
measurements: collectionView(tenant, "measurements"),
|
|
138
|
+
events: {
|
|
139
|
+
...collectionView(tenant, "events"),
|
|
140
|
+
attachment: (id) => toContent(store.tenant(tenant()).eventBinaries.get(resolveId(id)))
|
|
141
|
+
},
|
|
142
|
+
binaries: {
|
|
143
|
+
get: (id) => {
|
|
144
|
+
const document = collectionView(tenant, "managedObjects").get(id);
|
|
145
|
+
return document?.c8y_IsBinary !== void 0 ? document : void 0;
|
|
146
|
+
},
|
|
147
|
+
find: (filter) => collectionView(tenant, "managedObjects").find(filter).filter((document) => document.c8y_IsBinary !== void 0),
|
|
148
|
+
content: (id) => toContent(store.tenant(tenant()).binaries.get(resolveId(id)))
|
|
149
|
+
},
|
|
150
|
+
alarms: collectionView(tenant, "alarms"),
|
|
151
|
+
operations: {
|
|
152
|
+
...collectionView(tenant, "operations"),
|
|
153
|
+
update(id, update) {
|
|
154
|
+
const operation = store.tenant(tenant()).operations.get(resolveId(id));
|
|
155
|
+
if (!operation) throw new TypeError(`c8y.db.operations.update(): no operation ${resolveId(id)}`);
|
|
156
|
+
updateDocument(store.tenant(tenant()), RESOURCES.devicecontrol, operation, update);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
externalIds: { find: (managedObject) => store.tenant(tenant()).externalIds.filter((entry) => managedObject === void 0 || entry.managedObjectId === resolveId(managedObject)).map((entry) => ({ ...entry })) }
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// src/testing/index.ts
|
|
163
|
+
/**
|
|
164
|
+
* The fake Cumulocity tenant your microservice talks to in tests. Requires the
|
|
165
|
+
* `c8yTest()` plugin from `c8y-nitro/testing/vite`.
|
|
166
|
+
*
|
|
167
|
+
* Arrange calls are **scope-aware**: at the top of a file they apply to every
|
|
168
|
+
* test in the file, inside `describe` to the tests of that `describe` (inner
|
|
169
|
+
* scopes override outer ones), and inside a test only to that test. Before
|
|
170
|
+
* every test the tenant is rebuilt from the plugin baseline, the call log is
|
|
171
|
+
* cleared, and c8y-nitro's caches are invalidated, so tests are independent.
|
|
172
|
+
*
|
|
173
|
+
* Arrange the platform as data: users, subscriptions, tenant options,
|
|
174
|
+
* managed objects (`device()`), and measurements, events, alarms, and
|
|
175
|
+
* operations (`seed()`). Your service reads and writes them through the real
|
|
176
|
+
* `@c8y/client`; assert on its responses and on `c8y.db`.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* import { c8y, definePersona, serverFetch } from 'c8y-nitro/testing'
|
|
181
|
+
*
|
|
182
|
+
* const pump = c8y.device({ name: 'Pump', externalIds: { c8y_Serial: 'SN-1' } }) // seed the platform
|
|
183
|
+
* const visitor = definePersona({ userName: 'visitor', roles: [] })
|
|
184
|
+
*
|
|
185
|
+
* it('returns the pump', async () => {
|
|
186
|
+
* expect((await serverFetch('/pumps/SN-1')).status).toBe(200) // as the default user
|
|
187
|
+
* expect((await serverFetch('/pumps/SN-1', { as: visitor })).status).toBe(403) // as another user
|
|
188
|
+
* })
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
const c8y = {
|
|
192
|
+
/**
|
|
193
|
+
* Tenant the microservice is deployed in (the bootstrap tenant).
|
|
194
|
+
*/
|
|
195
|
+
get tenant() {
|
|
196
|
+
return platform.tenant;
|
|
197
|
+
},
|
|
198
|
+
/**
|
|
199
|
+
* Every request the microservice sent to the platform in this test, in order.
|
|
200
|
+
*/
|
|
201
|
+
get calls() {
|
|
202
|
+
return platform.calls;
|
|
203
|
+
},
|
|
204
|
+
/**
|
|
205
|
+
* Requests in this test the fake tenant could not answer (not emulated, and
|
|
206
|
+
* no `override()`). With the default `onUnhandledRequest: 'error'`, the test
|
|
207
|
+
* fails after it ran if this is not empty.
|
|
208
|
+
*/
|
|
209
|
+
get unhandled() {
|
|
210
|
+
return platform.unhandled;
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* The fake tenant's data, to assert on what your service wrote (and to
|
|
214
|
+
* drive operation status like a device would).
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```ts
|
|
218
|
+
* expect(c8y.db.alarms.find({ source: pump, type: 'overdue' })).toMatchObject([{ severity: 'MAJOR' }])
|
|
219
|
+
* c8y.db.operations.update(operationId, { status: 'SUCCESSFUL' })
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
db: {
|
|
223
|
+
...createDb(() => platform.tenant),
|
|
224
|
+
tenant: (tenant) => createDb(() => tenant)
|
|
225
|
+
},
|
|
226
|
+
/**
|
|
227
|
+
* Creates a managed object in the fake tenant (in the current scope).
|
|
228
|
+
* Returns a ref whose `id` is available while a test runs.
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```ts
|
|
232
|
+
* const gateway = c8y.managedObject({ name: 'Gateway', type: 'c8y_Gateway', children: [c8y.device({ name: 'Pump' })] })
|
|
233
|
+
* ```
|
|
234
|
+
* @param input - Fragments, plus `children`/`assets`/`additions`, `externalIds`, and `tenant`
|
|
235
|
+
*/
|
|
236
|
+
managedObject(input = {}) {
|
|
237
|
+
const ref = newRef(`c8y.managedObject(${JSON.stringify(input.name ?? input.type ?? "")})`);
|
|
238
|
+
arrange("c8y.managedObject()", () => createFromInput(ref, input));
|
|
239
|
+
return ref;
|
|
240
|
+
},
|
|
241
|
+
/**
|
|
242
|
+
* Creates a device (a managed object with `c8y_IsDevice`) in the fake tenant.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```ts
|
|
246
|
+
* const pump = c8y.device({ name: 'Pump', externalIds: { c8y_Serial: 'SN-1' } })
|
|
247
|
+
* it('…', async () => {
|
|
248
|
+
* await serverFetch(`/devices/${pump.id}`)
|
|
249
|
+
* })
|
|
250
|
+
* ```
|
|
251
|
+
* @param input - Fragments, plus `children`/`assets`/`additions`, `externalIds`, and `tenant`
|
|
252
|
+
*/
|
|
253
|
+
device(input = {}) {
|
|
254
|
+
const ref = newRef(`c8y.device(${JSON.stringify(input.name ?? "")})`);
|
|
255
|
+
arrange("c8y.device()", () => createFromInput(ref, {
|
|
256
|
+
c8y_IsDevice: {},
|
|
257
|
+
...input
|
|
258
|
+
}));
|
|
259
|
+
return ref;
|
|
260
|
+
},
|
|
261
|
+
/**
|
|
262
|
+
* Creates an inventory binary (a file) in the fake tenant (in the current
|
|
263
|
+
* scope). Returns a ref whose `id` is available while a test runs.
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* ```ts
|
|
267
|
+
* const manual = c8y.binary({ name: 'manual.txt', content: 'Turn it off and on again.' })
|
|
268
|
+
* ```
|
|
269
|
+
* @param input - Name, content, optional content type, tenant, and fragments
|
|
270
|
+
*/
|
|
271
|
+
binary(input) {
|
|
272
|
+
const ref = newRef(`c8y.binary(${JSON.stringify(input.name)})`);
|
|
273
|
+
arrange("c8y.binary()", () => createBinaryFromInput(ref, input));
|
|
274
|
+
return ref;
|
|
275
|
+
},
|
|
276
|
+
/**
|
|
277
|
+
* Seeds measurements, events, alarms, and operations (in the current scope).
|
|
278
|
+
* They are validated like API writes (required fields, enums, existing
|
|
279
|
+
* source), and alarms are deduplicated like on the platform.
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```ts
|
|
283
|
+
* c8y.seed({
|
|
284
|
+
* measurements: [{ source: pump, type: 'c8y_Temperature', c8y_Temperature: { T: { value: 21.5, unit: 'C' } } }],
|
|
285
|
+
* alarms: [{ source: pump, type: 'c8y_Overheating', severity: 'CRITICAL' }],
|
|
286
|
+
* })
|
|
287
|
+
* ```
|
|
288
|
+
* @param seed - Data to seed
|
|
289
|
+
*/
|
|
290
|
+
seed(seed) {
|
|
291
|
+
arrange("c8y.seed()", () => applySeed(seed));
|
|
292
|
+
},
|
|
293
|
+
/**
|
|
294
|
+
* Tenants subscribed to the microservice and their service users.
|
|
295
|
+
*/
|
|
296
|
+
subscriptions: {
|
|
297
|
+
/**
|
|
298
|
+
* Current subscriptions.
|
|
299
|
+
*/
|
|
300
|
+
list() {
|
|
301
|
+
return platform.subscriptions;
|
|
302
|
+
},
|
|
303
|
+
/**
|
|
304
|
+
* Adds a subscription, or replaces the one of the same tenant.
|
|
305
|
+
* @param subscription - Tenant and service user credentials
|
|
306
|
+
*/
|
|
307
|
+
add(subscription) {
|
|
308
|
+
arrange("c8y.subscriptions.add()", () => {
|
|
309
|
+
platform.subscriptions = [...platform.subscriptions.filter((item) => item.tenant !== subscription.tenant), { ...subscription }];
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
/**
|
|
313
|
+
* Removes the subscription of a tenant.
|
|
314
|
+
* @param tenant - Tenant id
|
|
315
|
+
*/
|
|
316
|
+
remove(tenant) {
|
|
317
|
+
arrange("c8y.subscriptions.remove()", () => {
|
|
318
|
+
platform.subscriptions = platform.subscriptions.filter((item) => item.tenant !== tenant);
|
|
319
|
+
});
|
|
320
|
+
},
|
|
321
|
+
/**
|
|
322
|
+
* Replaces all subscriptions.
|
|
323
|
+
* @param subscriptions - The new subscriptions
|
|
324
|
+
*/
|
|
325
|
+
set(subscriptions) {
|
|
326
|
+
arrange("c8y.subscriptions.set()", () => {
|
|
327
|
+
platform.subscriptions = subscriptions.map((subscription) => ({ ...subscription }));
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
/**
|
|
332
|
+
* Tenant options stored in the fake platform. These are platform values, not
|
|
333
|
+
* caches: a cached read keeps its value until it expires or is invalidated.
|
|
334
|
+
*/
|
|
335
|
+
tenantOptions: {
|
|
336
|
+
/**
|
|
337
|
+
* Sets an option value (merged with the outer scope's options).
|
|
338
|
+
* @param key - Option key
|
|
339
|
+
* @param value - Option value
|
|
340
|
+
* @param scope - Category and tenant, default own category in the deployed tenant
|
|
341
|
+
*/
|
|
342
|
+
set(key, value, scope) {
|
|
343
|
+
arrange("c8y.tenantOptions.set()", () => platform.setOption(key, value, scope));
|
|
344
|
+
},
|
|
345
|
+
/**
|
|
346
|
+
* Deletes an option in this scope.
|
|
347
|
+
* @param key - Option key
|
|
348
|
+
* @param scope - Category and tenant
|
|
349
|
+
*/
|
|
350
|
+
delete(key, scope) {
|
|
351
|
+
arrange("c8y.tenantOptions.delete()", () => {
|
|
352
|
+
platform.deleteOption(key, scope);
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
/**
|
|
356
|
+
* Reads an option value from the platform.
|
|
357
|
+
* @param key - Option key
|
|
358
|
+
* @param scope - Category and tenant
|
|
359
|
+
*/
|
|
360
|
+
get(key, scope) {
|
|
361
|
+
return platform.getOption(key, scope);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
/**
|
|
365
|
+
* Overrides the platform's answer for requests matching `pattern`. Use it
|
|
366
|
+
* for failures (errors, timeouts) and for APIs the fake tenant does not
|
|
367
|
+
* emulate; arrange normal data with `device()`/`seed()` instead, so tests do
|
|
368
|
+
* not depend on which endpoints your service calls. Overrides stack: inner
|
|
369
|
+
* scopes and newer overrides win over everything else.
|
|
370
|
+
*
|
|
371
|
+
* Patterns are `"[METHOD ]/path"` with `:param`, `*` (one segment), and `**`
|
|
372
|
+
* (rest of the path).
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* ```ts
|
|
376
|
+
* c8y.override('GET /inventory/**', () => Response.json({ message: 'Unavailable' }, { status: 503 }))
|
|
377
|
+
* c8y.override('GET /tenant/statistics/summary', () => ({ deviceCount: 3 }))
|
|
378
|
+
* ```
|
|
379
|
+
* @param pattern - Route pattern
|
|
380
|
+
* @param handler - Returns a `Response`, a JSON value (`200`), or nothing (`204`)
|
|
381
|
+
* @param options - Handler options
|
|
382
|
+
* @param options.once - Answer only the first matching request
|
|
383
|
+
*/
|
|
384
|
+
override(pattern, handler, options = {}) {
|
|
385
|
+
arrange("c8y.override()", () => {
|
|
386
|
+
platform.override(pattern, handler, options.once ?? false);
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
/**
|
|
390
|
+
* Applies several arrange settings at once, in the current scope. Callers
|
|
391
|
+
* are not part of the state: use `serverFetch(path, { as })`.
|
|
392
|
+
* @param state - Partial tenant state
|
|
393
|
+
*/
|
|
394
|
+
apply(state) {
|
|
395
|
+
arrange("c8y.apply()", () => platform.apply(state));
|
|
396
|
+
},
|
|
397
|
+
/**
|
|
398
|
+
* Notification 2.0 (realtime) in the fake tenant. Writes from your service,
|
|
399
|
+
* `c8y.seed()`/`c8y.device()` inside a test, and `c8y.db.operations.update()`
|
|
400
|
+
* are published to the subscriptions your service created.
|
|
401
|
+
*/
|
|
402
|
+
notifications: {
|
|
403
|
+
/**
|
|
404
|
+
* Waits until every notification delivered to your service's realtime
|
|
405
|
+
* handlers has been processed (acknowledged), including notifications
|
|
406
|
+
* caused by the handlers' own writes.
|
|
407
|
+
*
|
|
408
|
+
* @example
|
|
409
|
+
* ```ts
|
|
410
|
+
* c8y.seed({ measurements: [{ source: pump, type: 'c8y_Temperature', c8y_Temperature: { T: { value: 95, unit: 'C' } } }] })
|
|
411
|
+
* await c8y.notifications.settled()
|
|
412
|
+
* expect(c8y.db.alarms.find({ source: pump })).toHaveLength(1)
|
|
413
|
+
* ```
|
|
414
|
+
* @param options - Options
|
|
415
|
+
* @param options.timeout - Milliseconds to wait before failing (default 5000)
|
|
416
|
+
*/
|
|
417
|
+
async settled(options = {}) {
|
|
418
|
+
const timeout = options.timeout ?? 5e3;
|
|
419
|
+
const start = Date.now();
|
|
420
|
+
const tick = () => new Promise((resolve) => {
|
|
421
|
+
setTimeout(resolve, 1);
|
|
422
|
+
});
|
|
423
|
+
while (Date.now() - start < 500 && realtime.subscriptions.some((item) => ![...realtime.tokens.values()].some((token) => token.tenant === item.tenant && token.subscription === item.subscription))) await tick();
|
|
424
|
+
let quietTicks = 0;
|
|
425
|
+
while (quietTicks < 3) {
|
|
426
|
+
await tick();
|
|
427
|
+
quietTicks = realtime.pending() === 0 ? quietTicks + 1 : 0;
|
|
428
|
+
if (Date.now() - start > timeout) throw new Error(`c8y.notifications.settled(): ${realtime.pending()} notification(s) still not processed after ${timeout} ms`);
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
/**
|
|
432
|
+
* Notification 2.0 subscriptions your service created (all tenants).
|
|
433
|
+
*/
|
|
434
|
+
subscriptions() {
|
|
435
|
+
return realtime.subscriptions;
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
/**
|
|
439
|
+
* Recorded requests matching a route pattern (same syntax as `override()`).
|
|
440
|
+
* @param pattern - Route pattern, e.g. `'GET /tenant/options/**'`
|
|
441
|
+
*/
|
|
442
|
+
callsTo(pattern) {
|
|
443
|
+
const route = compileRoute(pattern);
|
|
444
|
+
return platform.calls.filter((call) => matchRoute(route, call.method, splitPath(call.path)));
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
/**
|
|
448
|
+
* Types a user that calls your service in tests, so it can be defined once
|
|
449
|
+
* and reused (role names autocomplete). Pass it to `serverFetch(path, { as })`.
|
|
450
|
+
*
|
|
451
|
+
* @example
|
|
452
|
+
* ```ts
|
|
453
|
+
* const operator = definePersona({ userName: 'operator', roles: ['ROLE_ALARM_ADMIN'] })
|
|
454
|
+
* const partner = definePersona({ userName: 'eve', roles: [], tenant: 't99999' })
|
|
455
|
+
* ```
|
|
456
|
+
* @param user - The user
|
|
457
|
+
*/
|
|
458
|
+
function definePersona(user) {
|
|
459
|
+
return user;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Calls a route of your service, like `serverFetch()` from `nitro`. With
|
|
463
|
+
* `as`, the request is sent as that user: the platform answers
|
|
464
|
+
* `GET /user/currentUser` with it during the request.
|
|
465
|
+
*
|
|
466
|
+
* @example
|
|
467
|
+
* ```ts
|
|
468
|
+
* await serverFetch('/pumps/SN-1') // default user
|
|
469
|
+
* await serverFetch('/pumps/SN-1/check', { method: 'POST', as: operator }) // a persona
|
|
470
|
+
* await serverFetch('/pumps/SN-1', { as: { userName: 'visitor', roles: [] } }) // inline
|
|
471
|
+
* await serverFetch('/user', { as: null }) // not logged in
|
|
472
|
+
* ```
|
|
473
|
+
* @param path - Route path, including the query
|
|
474
|
+
* @param init - Request options, plus `as`
|
|
475
|
+
*/
|
|
476
|
+
async function serverFetch(path, init = {}) {
|
|
477
|
+
if (!("as" in init)) return serverFetch$1(path, init);
|
|
478
|
+
const { as: user, ...requestInit } = init;
|
|
479
|
+
assertActiveTest("serverFetch({ as })");
|
|
480
|
+
const previous = platform.user;
|
|
481
|
+
platform.setUser(user ?? null);
|
|
482
|
+
try {
|
|
483
|
+
const headers = new Headers(requestInit.headers);
|
|
484
|
+
const login = user ? `${user.tenant ?? platform.tenant}/${user.userName}:${DEFAULTS.developmentPassword}` : `${platform.tenant}/anonymous:invalid`;
|
|
485
|
+
headers.set("authorization", `Basic ${Buffer.from(login).toString("base64")}`);
|
|
486
|
+
return await serverFetch$1(path, {
|
|
487
|
+
...requestInit,
|
|
488
|
+
headers
|
|
489
|
+
});
|
|
490
|
+
} finally {
|
|
491
|
+
platform.setUser(previous);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
export { c8y, definePersona, serverFetch };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { resolve } from "pathe";
|
|
1
|
+
import { t as writeAPIClient } from "./apiClient-GMQqcgQe.mjs";
|
|
2
|
+
import { i as C8Y_NITRO_TYPES_FILE, r as C8Y_NITRO_TYPES_DIR } from "./manifest-BhrHDm8Y.mjs";
|
|
4
3
|
import process from "node:process";
|
|
4
|
+
import { resolve } from "pathe";
|
|
5
5
|
import { defineCommand } from "citty";
|
|
6
6
|
import { consola } from "consola";
|
|
7
7
|
import { createNitro } from "nitro/builder";
|
|
8
|
-
|
|
8
|
+
// src/cli/commands/typegen.ts
|
|
9
9
|
var typegen_default = defineCommand({
|
|
10
10
|
meta: {
|
|
11
11
|
name: "typegen",
|
|
@@ -26,5 +26,4 @@ var typegen_default = defineCommand({
|
|
|
26
26
|
consola.info(`Add "${C8Y_NITRO_TYPES_DIR}/*.d.ts" to your tsconfig \`include\` if you have not already.`);
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
|
-
//#endregion
|
|
30
29
|
export { typegen_default as default };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { C8YRole, C8YRoles, C8YTenantOptionKey } from "c8y-nitro/types";
|
|
2
|
+
/**
|
|
3
|
+
* A role: the manifest's custom roles, known platform roles, or any string.
|
|
4
|
+
*/
|
|
5
|
+
type C8yTestRole = keyof C8YRoles | C8YRole;
|
|
6
|
+
/**
|
|
7
|
+
* The user calling the microservice (returned by `GET /user/currentUser`).
|
|
8
|
+
*/
|
|
9
|
+
interface C8yTestUser {
|
|
10
|
+
userName: string;
|
|
11
|
+
/**
|
|
12
|
+
* Effective roles of the user.
|
|
13
|
+
*/
|
|
14
|
+
roles?: C8yTestRole[];
|
|
15
|
+
/**
|
|
16
|
+
* Tenant the user belongs to. Defaults to the tenant the microservice is
|
|
17
|
+
* deployed in. Drives the injected dev user's auth header.
|
|
18
|
+
*/
|
|
19
|
+
tenant?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Any other `ICurrentUser` fields (email, firstName, …).
|
|
22
|
+
*/
|
|
23
|
+
[field: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Service user credentials of a tenant subscribed to the microservice.
|
|
27
|
+
*/
|
|
28
|
+
interface C8yTestSubscription {
|
|
29
|
+
tenant: string;
|
|
30
|
+
user: string;
|
|
31
|
+
password: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* What to do with platform requests no handler answers.
|
|
35
|
+
*/
|
|
36
|
+
type UnhandledRequestStrategy = 'error' | 'warn' | 'ignore';
|
|
37
|
+
/**
|
|
38
|
+
* State of the fake Cumulocity tenant. Used as the baseline in the
|
|
39
|
+
* `c8yTest()` plugin config, and as partial overrides in
|
|
40
|
+
* `c8y.apply()`.
|
|
41
|
+
*/
|
|
42
|
+
interface C8yTestState {
|
|
43
|
+
/**
|
|
44
|
+
* Tenant the microservice is deployed in (the bootstrap tenant).
|
|
45
|
+
* @default 't12345'
|
|
46
|
+
*/
|
|
47
|
+
tenant?: string;
|
|
48
|
+
/**
|
|
49
|
+
* User calling the microservice. `null` makes `GET /user/currentUser`
|
|
50
|
+
* answer 401.
|
|
51
|
+
* @default { userName: 'test-user', roles: [] }
|
|
52
|
+
*/
|
|
53
|
+
user?: C8yTestUser | null;
|
|
54
|
+
/**
|
|
55
|
+
* Tenants subscribed to the microservice, with their service users.
|
|
56
|
+
* @default one subscription for the deployed tenant
|
|
57
|
+
*/
|
|
58
|
+
subscriptions?: C8yTestSubscription[];
|
|
59
|
+
/**
|
|
60
|
+
* Tenant options of the microservice's own settings category, in the deployed tenant.
|
|
61
|
+
*/
|
|
62
|
+
tenantOptions?: Partial<Record<C8YTenantOptionKey, string>> & Record<string, string>;
|
|
63
|
+
/**
|
|
64
|
+
* What to do with platform requests that no handler answers. `'error'`
|
|
65
|
+
* fails the test after it ran.
|
|
66
|
+
* @default 'error'
|
|
67
|
+
*/
|
|
68
|
+
onUnhandledRequest?: UnhandledRequestStrategy;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A request the microservice sent to the (fake) platform.
|
|
72
|
+
*/
|
|
73
|
+
interface C8yRequest {
|
|
74
|
+
method: string;
|
|
75
|
+
/**
|
|
76
|
+
* Path without query, e.g. `/inventory/managedObjects/42`.
|
|
77
|
+
*/
|
|
78
|
+
path: string;
|
|
79
|
+
query: Record<string, string>;
|
|
80
|
+
/**
|
|
81
|
+
* Parsed JSON body, raw text, or `undefined`.
|
|
82
|
+
*/
|
|
83
|
+
body: unknown;
|
|
84
|
+
headers: Record<string, string>;
|
|
85
|
+
/**
|
|
86
|
+
* Tenant the request targets: `targetTenant` query, else the caller's tenant.
|
|
87
|
+
*/
|
|
88
|
+
tenant: string;
|
|
89
|
+
/**
|
|
90
|
+
* User name from basic auth, if any.
|
|
91
|
+
*/
|
|
92
|
+
user: string | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Route params captured by the matching `c8y.override()` pattern.
|
|
95
|
+
*/
|
|
96
|
+
params: Record<string, string>;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A platform request the fake tenant could not answer.
|
|
100
|
+
*/
|
|
101
|
+
interface C8yUnhandledRequest extends C8yRequest {
|
|
102
|
+
/**
|
|
103
|
+
* Why the fake tenant could not answer it.
|
|
104
|
+
*/
|
|
105
|
+
reason: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Context passed to a `c8y.override()` handler.
|
|
109
|
+
*/
|
|
110
|
+
interface C8yRequestContext extends C8yRequest {
|
|
111
|
+
/**
|
|
112
|
+
* The raw request, body already consumed.
|
|
113
|
+
*/
|
|
114
|
+
request: Request;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Answers a platform request. Return a `Response` for full control (status,
|
|
118
|
+
* headers), any other value to answer `200` with it as JSON, or nothing for
|
|
119
|
+
* `204`.
|
|
120
|
+
*/
|
|
121
|
+
type C8yRequestHandler = (context: C8yRequestContext) => unknown;
|
|
122
|
+
/**
|
|
123
|
+
* Options for the `c8yTest()` Vite plugin.
|
|
124
|
+
*/
|
|
125
|
+
interface C8yTestOptions extends C8yTestState {
|
|
126
|
+
/**
|
|
127
|
+
* Base URL the fake platform pretends to live at (`C8Y_BASEURL`).
|
|
128
|
+
* @default 'https://t12345.cumulocity.test'
|
|
129
|
+
*/
|
|
130
|
+
baseUrl?: string;
|
|
131
|
+
}
|
|
132
|
+
declare module 'vitest' {
|
|
133
|
+
interface ProvidedContext {
|
|
134
|
+
/**
|
|
135
|
+
* Baseline options of the `c8yTest()` plugin.
|
|
136
|
+
*/
|
|
137
|
+
c8yNitroTest: C8yTestOptions;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export { C8yTestRole as a, C8yTestUser as c, C8yTestOptions as i, C8yUnhandledRequest as l, C8yRequestContext as n, C8yTestState as o, C8yRequestHandler as r, C8yTestSubscription as s, C8yRequest as t, UnhandledRequestStrategy as u };
|
package/dist/types.d.mts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { a as C8YTenantOptionKey, c as C8yDockerOptions, d as C8YManifest, f as C8YManifestOptions, g as
|
|
2
|
-
import { t as TenantCredentials } from "./credentials-
|
|
3
|
-
export { C8YAPIClientOptions, type C8YKnownRole, type C8YManifest, type C8YManifestOptions, C8YRole, C8YRoles, C8YSettingsCategory, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, type C8YZipOptions, type C8yCacheOptions, C8yDevOptions, type C8yDockerOptions, C8yNitroModuleOptions, C8yOpenAPIOptions, type C8yRealtimeOptions, type TenantCredentials };
|
|
1
|
+
import { _ as C8YAPIClientOptions, a as C8YTenantOptionKey, c as C8yDockerOptions, d as C8YManifest, f as C8YManifestOptions, g as C8YKnownRole, h as C8YRoles, i as C8YSettingsCategory, l as C8yCacheOptions, m as C8YRole, n as C8yNitroModuleOptions, o as C8YTenantOptionKeysCacheConfig, p as ExposedMcpServer, r as C8yOpenAPIOptions, s as C8yRealtimeOptions, t as C8yDevOptions, u as C8YZipOptions } from "./index-CmIQfBpj.mjs";
|
|
2
|
+
import { t as TenantCredentials } from "./credentials-C_3m4LVX.mjs";
|
|
3
|
+
export { C8YAPIClientOptions, type C8YKnownRole, type C8YManifest, type C8YManifestOptions, C8YRole, C8YRoles, C8YSettingsCategory, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, type C8YZipOptions, type C8yCacheOptions, C8yDevOptions, type C8yDockerOptions, C8yNitroModuleOptions, C8yOpenAPIOptions, type C8yRealtimeOptions, type ExposedMcpServer, type TenantCredentials };
|