@phystack/device-simulator 6.3.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/CHANGELOG.md +14 -0
- package/build-binary.sh +37 -0
- package/dist/index.js +37 -0
- package/package.json +38 -0
- package/src/__tests__/e2e/binary.e2e.test.ts +394 -0
- package/src/__tests__/preload.ts +44 -0
- package/src/command.ts +66 -0
- package/src/commands/__tests__/run-helpers.test.ts +181 -0
- package/src/commands/list.ts +25 -0
- package/src/commands/remove.ts +16 -0
- package/src/commands/run.ts +518 -0
- package/src/commands/start.ts +309 -0
- package/src/index.ts +45 -0
- package/src/services/__tests__/dev-token.test.ts +156 -0
- package/src/services/dev-token.ts +52 -0
- package/src/services/env.ts +10 -0
- package/src/simulator/__tests__/message-router.test.ts +782 -0
- package/src/simulator/__tests__/twin-cache.test.ts +129 -0
- package/src/simulator/index.ts +200 -0
- package/src/simulator/local-server.ts +184 -0
- package/src/simulator/logger.ts +44 -0
- package/src/simulator/message-router.ts +525 -0
- package/src/simulator/twin-cache.ts +61 -0
- package/src/simulator/types.ts +53 -0
- package/src/utils/__tests__/simulator-config.test.ts +230 -0
- package/src/utils/config-paths.ts +38 -0
- package/src/utils/index.ts +41 -0
- package/src/utils/simulator-config.ts +185 -0
- package/src/utils/tenant-storage.ts +106 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MessageRouter } from "../message-router";
|
|
3
|
+
import { TwinCache } from "../twin-cache";
|
|
4
|
+
import { EventPayload, TwinResponse, TwinTypeEnum } from "../types";
|
|
5
|
+
|
|
6
|
+
// --- Fakes -----------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
interface EmitRecord {
|
|
9
|
+
room: string | null;
|
|
10
|
+
event: string;
|
|
11
|
+
args: any[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal socket.io server fake. Records every emit (both broadcast
|
|
16
|
+
* `io.emit(...)` and room-scoped `io.to(room).emit(...)`) so tests can assert
|
|
17
|
+
* what the router published, without standing up a real Socket.IO server.
|
|
18
|
+
*/
|
|
19
|
+
function makeFakeIo() {
|
|
20
|
+
const emits: EmitRecord[] = [];
|
|
21
|
+
const rooms = new Map<string, Set<string>>();
|
|
22
|
+
const socketsById = new Map<string, any>();
|
|
23
|
+
const fakeIo: any = {
|
|
24
|
+
emits,
|
|
25
|
+
emit(event: string, ...args: any[]) {
|
|
26
|
+
emits.push({ room: null, event, args });
|
|
27
|
+
},
|
|
28
|
+
to(room: string) {
|
|
29
|
+
return {
|
|
30
|
+
emit(event: string, ...args: any[]) {
|
|
31
|
+
emits.push({ room, event, args });
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
sockets: {
|
|
36
|
+
adapter: { rooms },
|
|
37
|
+
sockets: socketsById,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
return { fakeIo, emits, rooms, socketsById };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Minimal socket.io Socket fake: records `.join()` calls so we can assert
|
|
45
|
+
* subscription wiring.
|
|
46
|
+
*/
|
|
47
|
+
function makeFakeSocket() {
|
|
48
|
+
const joined: string[] = [];
|
|
49
|
+
const fakeSocket: any = {
|
|
50
|
+
joined,
|
|
51
|
+
join(room: string) {
|
|
52
|
+
joined.push(room);
|
|
53
|
+
},
|
|
54
|
+
emit() {},
|
|
55
|
+
to() {
|
|
56
|
+
return { emit() {} };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
return { fakeSocket, joined };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeTwin(
|
|
63
|
+
id: string,
|
|
64
|
+
type: TwinTypeEnum,
|
|
65
|
+
overrides: Partial<TwinResponse> = {},
|
|
66
|
+
): TwinResponse {
|
|
67
|
+
return {
|
|
68
|
+
id,
|
|
69
|
+
deviceId: overrides.deviceId ?? `device-${id}`,
|
|
70
|
+
tenantId: overrides.tenantId ?? `tenant-${id}`,
|
|
71
|
+
type,
|
|
72
|
+
properties: overrides.properties ?? { desired: {}, reported: {} },
|
|
73
|
+
descriptors: overrides.descriptors,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Capture the value passed to the ack callback. */
|
|
78
|
+
function captureCallback() {
|
|
79
|
+
const calls: any[] = [];
|
|
80
|
+
const callback = (result: any) => {
|
|
81
|
+
calls.push(result);
|
|
82
|
+
};
|
|
83
|
+
return { callback, calls };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("MessageRouter — guard clauses", () => {
|
|
87
|
+
test("missing method → error ack and undefined return", async () => {
|
|
88
|
+
const cache = new TwinCache();
|
|
89
|
+
const { fakeIo } = makeFakeIo();
|
|
90
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
91
|
+
const { fakeSocket } = makeFakeSocket();
|
|
92
|
+
const { callback, calls } = captureCallback();
|
|
93
|
+
|
|
94
|
+
const result = await router.handleMessage(
|
|
95
|
+
fakeSocket,
|
|
96
|
+
"caller",
|
|
97
|
+
{} as EventPayload,
|
|
98
|
+
callback,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(result).toBeUndefined();
|
|
102
|
+
expect(calls).toEqual([
|
|
103
|
+
{ status: "error", message: "No method specified" },
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("unsupported method → error result and ack", async () => {
|
|
108
|
+
const cache = new TwinCache();
|
|
109
|
+
const { fakeIo } = makeFakeIo();
|
|
110
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
111
|
+
const { fakeSocket } = makeFakeSocket();
|
|
112
|
+
const { callback, calls } = captureCallback();
|
|
113
|
+
|
|
114
|
+
const result = await router.handleMessage(
|
|
115
|
+
fakeSocket,
|
|
116
|
+
"caller",
|
|
117
|
+
{ method: "reboot" },
|
|
118
|
+
callback,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const expected = {
|
|
122
|
+
status: "error",
|
|
123
|
+
message: "reboot is not supported in simulator mode",
|
|
124
|
+
};
|
|
125
|
+
expect(result).toEqual(expected);
|
|
126
|
+
expect(calls).toEqual([expected]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("signal method → success no-op", async () => {
|
|
130
|
+
const cache = new TwinCache();
|
|
131
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
132
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
133
|
+
const { fakeSocket } = makeFakeSocket();
|
|
134
|
+
const { callback, calls } = captureCallback();
|
|
135
|
+
|
|
136
|
+
const result = await router.handleMessage(
|
|
137
|
+
fakeSocket,
|
|
138
|
+
"caller",
|
|
139
|
+
{ method: "sendEventSignal", data: { kind: "demo" } },
|
|
140
|
+
callback,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(result).toEqual({ status: "success" });
|
|
144
|
+
expect(calls).toEqual([{ status: "success" }]);
|
|
145
|
+
expect(emits).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("unknown method → error result naming the method", async () => {
|
|
149
|
+
const cache = new TwinCache();
|
|
150
|
+
const { fakeIo } = makeFakeIo();
|
|
151
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
152
|
+
const { fakeSocket } = makeFakeSocket();
|
|
153
|
+
const { callback, calls } = captureCallback();
|
|
154
|
+
|
|
155
|
+
const result = await router.handleMessage(
|
|
156
|
+
fakeSocket,
|
|
157
|
+
"caller",
|
|
158
|
+
{ method: "doesNotExist" },
|
|
159
|
+
callback,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(result).toEqual({
|
|
163
|
+
status: "error",
|
|
164
|
+
message: "Unknown method: doesNotExist",
|
|
165
|
+
});
|
|
166
|
+
expect(calls).toEqual([
|
|
167
|
+
{ status: "error", message: "Unknown method: doesNotExist" },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("ping → pong", async () => {
|
|
172
|
+
const cache = new TwinCache();
|
|
173
|
+
const { fakeIo } = makeFakeIo();
|
|
174
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
175
|
+
const { fakeSocket } = makeFakeSocket();
|
|
176
|
+
const { callback, calls } = captureCallback();
|
|
177
|
+
|
|
178
|
+
await router.handleMessage(
|
|
179
|
+
fakeSocket,
|
|
180
|
+
"caller",
|
|
181
|
+
{ method: "ping" },
|
|
182
|
+
callback,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(calls).toEqual([{ status: "success", message: "pong" }]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("MessageRouter — device status & instance", () => {
|
|
190
|
+
test("getDeviceStatus reflects the device twin desired/reported props", async () => {
|
|
191
|
+
const cache = new TwinCache();
|
|
192
|
+
cache.addTwin(
|
|
193
|
+
makeTwin("dev-twin", TwinTypeEnum.Device, {
|
|
194
|
+
deviceId: "device-abc",
|
|
195
|
+
tenantId: "tenant-xyz",
|
|
196
|
+
properties: {
|
|
197
|
+
desired: {
|
|
198
|
+
displayName: "My Sim",
|
|
199
|
+
spaceId: "space-1",
|
|
200
|
+
env: "staging",
|
|
201
|
+
accessKey: "key-123",
|
|
202
|
+
deviceSerial: "SIM-XYZ",
|
|
203
|
+
},
|
|
204
|
+
reported: { os: { osVersion: "9.9" }, ip: [{ interface: "eth0" }] },
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
cache.addTwin(makeTwin("screen-1", TwinTypeEnum.Screen));
|
|
209
|
+
const { fakeIo } = makeFakeIo();
|
|
210
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
211
|
+
const { fakeSocket } = makeFakeSocket();
|
|
212
|
+
const { callback, calls } = captureCallback();
|
|
213
|
+
|
|
214
|
+
const result = await router.handleMessage(
|
|
215
|
+
fakeSocket,
|
|
216
|
+
"caller",
|
|
217
|
+
{ method: "getDeviceStatus" },
|
|
218
|
+
callback,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(result.status).toBe("success");
|
|
222
|
+
expect(result.socketConnected).toBe(true);
|
|
223
|
+
expect(result.socketAuthenticated).toBe(true);
|
|
224
|
+
expect(result.deviceId).toBe("device-abc");
|
|
225
|
+
expect(result.tenantId).toBe("tenant-xyz");
|
|
226
|
+
expect(result.displayName).toBe("My Sim");
|
|
227
|
+
expect(result.spaceId).toBe("space-1");
|
|
228
|
+
expect(result.gridEnv).toBe("staging");
|
|
229
|
+
expect(result.accessKey).toBe("key-123");
|
|
230
|
+
expect(result.deviceSerial).toBe("SIM-XYZ");
|
|
231
|
+
expect(result.osVersion).toBe("9.9");
|
|
232
|
+
expect(result.dataResidency).toBe("DEV");
|
|
233
|
+
expect(result.isConnected).toBe("true");
|
|
234
|
+
expect(result.twins).toEqual({
|
|
235
|
+
Device: ["dev-twin"],
|
|
236
|
+
Screen: ["screen-1"],
|
|
237
|
+
});
|
|
238
|
+
expect(calls).toEqual([result]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("getDeviceStatus falls back to defaults when no device twin exists", async () => {
|
|
242
|
+
const cache = new TwinCache();
|
|
243
|
+
const { fakeIo } = makeFakeIo();
|
|
244
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
245
|
+
const { fakeSocket } = makeFakeSocket();
|
|
246
|
+
|
|
247
|
+
const result = await router.handleMessage(fakeSocket, "caller", {
|
|
248
|
+
method: "getDeviceStatus",
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(result.displayName).toBe("Simulator");
|
|
252
|
+
expect(result.spaceId).toBe("");
|
|
253
|
+
expect(result.gridEnv).toBe("development");
|
|
254
|
+
expect(result.accessKey).toBe("simulator-local-key");
|
|
255
|
+
expect(result.deviceSerial).toBe("SIM-LOCAL");
|
|
256
|
+
expect(result.osVersion).toBe("Simulator");
|
|
257
|
+
expect(result.deviceId).toBeUndefined();
|
|
258
|
+
expect(result.ip).toEqual([
|
|
259
|
+
{ interface: "lo", ipv4: "127.0.0.1", ipv6: "::1" },
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("getInstance / getDeviceInstance return the device twin or an error", async () => {
|
|
264
|
+
const cache = new TwinCache();
|
|
265
|
+
const { fakeIo } = makeFakeIo();
|
|
266
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
267
|
+
const { fakeSocket } = makeFakeSocket();
|
|
268
|
+
|
|
269
|
+
const missing = await router.handleMessage(fakeSocket, "caller", {
|
|
270
|
+
method: "getDeviceInstance",
|
|
271
|
+
});
|
|
272
|
+
expect(missing).toEqual({
|
|
273
|
+
status: "error",
|
|
274
|
+
message: "Device twin not found",
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const device = makeTwin("dev-twin", TwinTypeEnum.Device);
|
|
278
|
+
cache.addTwin(device);
|
|
279
|
+
|
|
280
|
+
const viaGetInstance = await router.handleMessage(fakeSocket, "caller", {
|
|
281
|
+
method: "getInstance",
|
|
282
|
+
});
|
|
283
|
+
expect(viaGetInstance).toEqual({ status: "success", twin: device });
|
|
284
|
+
|
|
285
|
+
const viaGetDeviceInstance = await router.handleMessage(
|
|
286
|
+
fakeSocket,
|
|
287
|
+
"caller",
|
|
288
|
+
{
|
|
289
|
+
method: "getDeviceInstance",
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
expect(viaGetDeviceInstance).toEqual({ status: "success", twin: device });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("getTwinById returns the twin, or errors on missing/absent id", async () => {
|
|
296
|
+
const cache = new TwinCache();
|
|
297
|
+
const target = makeTwin("twin-x", TwinTypeEnum.Screen);
|
|
298
|
+
cache.addTwin(target);
|
|
299
|
+
const { fakeIo } = makeFakeIo();
|
|
300
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
301
|
+
const { fakeSocket } = makeFakeSocket();
|
|
302
|
+
|
|
303
|
+
expect(
|
|
304
|
+
await router.handleMessage(fakeSocket, "caller", {
|
|
305
|
+
method: "getTwinById",
|
|
306
|
+
data: { twinId: "twin-x" },
|
|
307
|
+
}),
|
|
308
|
+
).toEqual({ status: "success", twin: target });
|
|
309
|
+
|
|
310
|
+
expect(
|
|
311
|
+
await router.handleMessage(fakeSocket, "caller", {
|
|
312
|
+
method: "getTwinById",
|
|
313
|
+
data: { twinId: "nope" },
|
|
314
|
+
}),
|
|
315
|
+
).toEqual({ status: "error", message: "Twin not found" });
|
|
316
|
+
|
|
317
|
+
expect(
|
|
318
|
+
await router.handleMessage(fakeSocket, "caller", {
|
|
319
|
+
method: "getTwinById",
|
|
320
|
+
data: {},
|
|
321
|
+
}),
|
|
322
|
+
).toEqual({ status: "error", message: "Twin ID is required" });
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("MessageRouter — twin creation", () => {
|
|
327
|
+
test("createInstanceTwin adds a twin, broadcasts twinCreated, reuses given id", async () => {
|
|
328
|
+
const cache = new TwinCache();
|
|
329
|
+
cache.addTwin(
|
|
330
|
+
makeTwin("dev-twin", TwinTypeEnum.Device, {
|
|
331
|
+
deviceId: "dev-id",
|
|
332
|
+
tenantId: "ten-id",
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
336
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
337
|
+
const { fakeSocket } = makeFakeSocket();
|
|
338
|
+
|
|
339
|
+
const result = await router.handleMessage(fakeSocket, "caller", {
|
|
340
|
+
method: "createInstanceTwin",
|
|
341
|
+
data: {
|
|
342
|
+
type: TwinTypeEnum.Edge,
|
|
343
|
+
desiredProperties: { appName: "demo" },
|
|
344
|
+
id: "fixed-id",
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(result.status).toBe("success");
|
|
349
|
+
expect(result.twin.id).toBe("fixed-id");
|
|
350
|
+
expect(result.twin.type).toBe(TwinTypeEnum.Edge);
|
|
351
|
+
expect(result.twin.deviceId).toBe("dev-id");
|
|
352
|
+
expect(result.twin.tenantId).toBe("ten-id");
|
|
353
|
+
expect(result.twin.properties).toEqual({
|
|
354
|
+
desired: { appName: "demo" },
|
|
355
|
+
reported: {},
|
|
356
|
+
});
|
|
357
|
+
expect(cache.getTwin("fixed-id")).toEqual(result.twin);
|
|
358
|
+
expect(emits).toEqual([
|
|
359
|
+
{ room: null, event: "twinCreated", args: [{ data: result.twin }] },
|
|
360
|
+
]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("createInstanceTwin defaults to Screen type when none supplied", async () => {
|
|
364
|
+
const cache = new TwinCache();
|
|
365
|
+
const { fakeIo } = makeFakeIo();
|
|
366
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
367
|
+
const { fakeSocket } = makeFakeSocket();
|
|
368
|
+
|
|
369
|
+
const result = await router.handleMessage(fakeSocket, "caller", {
|
|
370
|
+
method: "createInstanceTwin",
|
|
371
|
+
data: {},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(result.twin.type).toBe(TwinTypeEnum.Screen);
|
|
375
|
+
// No device twin → deviceId/tenantId are freshly generated uuids
|
|
376
|
+
expect(typeof result.twin.deviceId).toBe("string");
|
|
377
|
+
expect(result.twin.deviceId).toHaveLength(36);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("createPeripheralTwin stores descriptors and broadcasts twinCreated", async () => {
|
|
381
|
+
const cache = new TwinCache();
|
|
382
|
+
cache.addTwin(
|
|
383
|
+
makeTwin("dev-twin", TwinTypeEnum.Device, {
|
|
384
|
+
deviceId: "dev-id",
|
|
385
|
+
tenantId: "ten-id",
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
389
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
390
|
+
const { fakeSocket } = makeFakeSocket();
|
|
391
|
+
|
|
392
|
+
const result = await router.handleMessage(fakeSocket, "caller", {
|
|
393
|
+
method: "createPeripheralTwin",
|
|
394
|
+
data: {
|
|
395
|
+
instanceId: "inst-1",
|
|
396
|
+
name: "camera",
|
|
397
|
+
hardwareId: "hw-9",
|
|
398
|
+
desiredProperties: { fps: 30 },
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
expect(result.status).toBe("success");
|
|
403
|
+
expect(result.twin.type).toBe(TwinTypeEnum.Peripheral);
|
|
404
|
+
expect(result.twin.deviceId).toBe("dev-id");
|
|
405
|
+
expect(result.twin.descriptors).toEqual({
|
|
406
|
+
instanceId: "inst-1",
|
|
407
|
+
name: "camera",
|
|
408
|
+
hardwareId: "hw-9",
|
|
409
|
+
});
|
|
410
|
+
expect(result.twin.properties.desired).toEqual({
|
|
411
|
+
fps: 30,
|
|
412
|
+
instanceId: "inst-1",
|
|
413
|
+
name: "camera",
|
|
414
|
+
hardwareId: "hw-9",
|
|
415
|
+
});
|
|
416
|
+
expect(cache.getTwin(result.twin.id)).toEqual(result.twin);
|
|
417
|
+
expect(emits).toEqual([
|
|
418
|
+
{ room: null, event: "twinCreated", args: [{ data: result.twin }] },
|
|
419
|
+
]);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe("MessageRouter — peripheral twin lifecycle", () => {
|
|
424
|
+
test("deletePeripheralTwin removes the twin and broadcasts twinDeleted", async () => {
|
|
425
|
+
const cache = new TwinCache();
|
|
426
|
+
const peripheral = makeTwin("per-1", TwinTypeEnum.Peripheral);
|
|
427
|
+
cache.addTwin(peripheral);
|
|
428
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
429
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
430
|
+
const { fakeSocket } = makeFakeSocket();
|
|
431
|
+
|
|
432
|
+
const result = await router.handleMessage(fakeSocket, "caller", {
|
|
433
|
+
method: "deletePeripheralTwin",
|
|
434
|
+
data: { twinId: "per-1" },
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
expect(result).toEqual({ status: "success" });
|
|
438
|
+
expect(cache.hasTwin("per-1")).toBe(false);
|
|
439
|
+
expect(emits).toEqual([
|
|
440
|
+
{ room: null, event: "twinDeleted", args: [{ data: peripheral }] },
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("deletePeripheralTwin errors on missing id and on unknown twin", async () => {
|
|
445
|
+
const cache = new TwinCache();
|
|
446
|
+
const { fakeIo } = makeFakeIo();
|
|
447
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
448
|
+
const { fakeSocket } = makeFakeSocket();
|
|
449
|
+
|
|
450
|
+
expect(
|
|
451
|
+
await router.handleMessage(fakeSocket, "caller", {
|
|
452
|
+
method: "deletePeripheralTwin",
|
|
453
|
+
data: {},
|
|
454
|
+
}),
|
|
455
|
+
).toEqual({ status: "error", message: "Twin ID is required" });
|
|
456
|
+
|
|
457
|
+
expect(
|
|
458
|
+
await router.handleMessage(fakeSocket, "caller", {
|
|
459
|
+
method: "deletePeripheralTwin",
|
|
460
|
+
data: { twinId: "ghost" },
|
|
461
|
+
}),
|
|
462
|
+
).toEqual({ status: "error", message: "Twin not found" });
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("getPeripheralTwins lists all, then filters by instanceId", async () => {
|
|
466
|
+
const cache = new TwinCache();
|
|
467
|
+
cache.addTwin(
|
|
468
|
+
makeTwin("per-1", TwinTypeEnum.Peripheral, {
|
|
469
|
+
descriptors: { instanceId: "inst-A" },
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
cache.addTwin(
|
|
473
|
+
makeTwin("per-2", TwinTypeEnum.Peripheral, {
|
|
474
|
+
descriptors: { instanceId: "inst-B" },
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
477
|
+
cache.addTwin(makeTwin("screen-1", TwinTypeEnum.Screen));
|
|
478
|
+
const { fakeIo } = makeFakeIo();
|
|
479
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
480
|
+
const { fakeSocket } = makeFakeSocket();
|
|
481
|
+
|
|
482
|
+
const all = await router.handleMessage(fakeSocket, "caller", {
|
|
483
|
+
method: "getPeripheralTwins",
|
|
484
|
+
});
|
|
485
|
+
expect(all.status).toBe("success");
|
|
486
|
+
expect(all.twins.map((twin: TwinResponse) => twin.id).sort()).toEqual([
|
|
487
|
+
"per-1",
|
|
488
|
+
"per-2",
|
|
489
|
+
]);
|
|
490
|
+
|
|
491
|
+
const filtered = await router.handleMessage(fakeSocket, "caller", {
|
|
492
|
+
method: "getPeripheralTwins",
|
|
493
|
+
data: { instanceId: "inst-A" },
|
|
494
|
+
});
|
|
495
|
+
expect(filtered.twins.map((twin: TwinResponse) => twin.id)).toEqual([
|
|
496
|
+
"per-1",
|
|
497
|
+
]);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("MessageRouter — reported property patches", () => {
|
|
502
|
+
test.each([
|
|
503
|
+
"reportScreenTwinProperties",
|
|
504
|
+
"reportEdgeTwinProperties",
|
|
505
|
+
"reportPeripheralTwinProperties",
|
|
506
|
+
])(
|
|
507
|
+
"%s merges reported props and emits twinUpdated to the twin room",
|
|
508
|
+
async (method) => {
|
|
509
|
+
const cache = new TwinCache();
|
|
510
|
+
cache.addTwin(
|
|
511
|
+
makeTwin("twin-1", TwinTypeEnum.Screen, {
|
|
512
|
+
properties: { desired: {}, reported: { keep: 1 } },
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
516
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
517
|
+
const { fakeSocket } = makeFakeSocket();
|
|
518
|
+
|
|
519
|
+
const result = await router.handleMessage(fakeSocket, "caller", {
|
|
520
|
+
method,
|
|
521
|
+
twinId: "twin-1",
|
|
522
|
+
data: { added: 2 },
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(result.status).toBe("success");
|
|
526
|
+
expect(result.twin.properties.reported).toEqual({ keep: 1, added: 2 });
|
|
527
|
+
expect(cache.getTwin("twin-1")!.properties.reported).toEqual({
|
|
528
|
+
keep: 1,
|
|
529
|
+
added: 2,
|
|
530
|
+
});
|
|
531
|
+
// handleIncomingTwinUpdated emits twinMessage/twinUpdated to room = twin id
|
|
532
|
+
expect(emits).toEqual([
|
|
533
|
+
{
|
|
534
|
+
room: "twin-1",
|
|
535
|
+
event: "twinMessage",
|
|
536
|
+
args: [
|
|
537
|
+
{
|
|
538
|
+
method: "twinUpdated",
|
|
539
|
+
twinId: "twin-1",
|
|
540
|
+
data: cache.getTwin("twin-1"),
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
},
|
|
544
|
+
]);
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
test("reportScreenTwinProperties errors without a twinId or on unknown twin", async () => {
|
|
549
|
+
const cache = new TwinCache();
|
|
550
|
+
const { fakeIo } = makeFakeIo();
|
|
551
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
552
|
+
const { fakeSocket } = makeFakeSocket();
|
|
553
|
+
|
|
554
|
+
expect(
|
|
555
|
+
await router.handleMessage(fakeSocket, "caller", {
|
|
556
|
+
method: "reportScreenTwinProperties",
|
|
557
|
+
data: { x: 1 },
|
|
558
|
+
}),
|
|
559
|
+
).toEqual({ status: "error", message: "Twin ID is required" });
|
|
560
|
+
|
|
561
|
+
expect(
|
|
562
|
+
await router.handleMessage(fakeSocket, "caller", {
|
|
563
|
+
method: "reportScreenTwinProperties",
|
|
564
|
+
twinId: "ghost",
|
|
565
|
+
data: { x: 1 },
|
|
566
|
+
}),
|
|
567
|
+
).toEqual({ status: "error", message: "Twin not found" });
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("reportDeviceTwinProperties merges into the device twin (properties key)", async () => {
|
|
571
|
+
const cache = new TwinCache();
|
|
572
|
+
cache.addTwin(
|
|
573
|
+
makeTwin("dev-twin", TwinTypeEnum.Device, {
|
|
574
|
+
properties: { desired: {}, reported: { boot: 1 } },
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
578
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
579
|
+
const { fakeSocket } = makeFakeSocket();
|
|
580
|
+
|
|
581
|
+
const result = await router.handleMessage(fakeSocket, "caller", {
|
|
582
|
+
method: "reportDeviceTwinProperties",
|
|
583
|
+
data: { properties: { uptime: 42 } },
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
expect(result).toEqual({ status: "success" });
|
|
587
|
+
expect(cache.getDeviceTwin()!.properties.reported).toEqual({
|
|
588
|
+
boot: 1,
|
|
589
|
+
uptime: 42,
|
|
590
|
+
});
|
|
591
|
+
expect(emits[0].event).toBe("twinMessage");
|
|
592
|
+
expect(emits[0].room).toBe("dev-twin");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("reportDeviceTwinProperties also accepts a `reported` key", async () => {
|
|
596
|
+
const cache = new TwinCache();
|
|
597
|
+
cache.addTwin(
|
|
598
|
+
makeTwin("dev-twin", TwinTypeEnum.Device, {
|
|
599
|
+
properties: { desired: {}, reported: {} },
|
|
600
|
+
}),
|
|
601
|
+
);
|
|
602
|
+
const { fakeIo } = makeFakeIo();
|
|
603
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
604
|
+
const { fakeSocket } = makeFakeSocket();
|
|
605
|
+
|
|
606
|
+
await router.handleMessage(fakeSocket, "caller", {
|
|
607
|
+
method: "reportDeviceTwinProperties",
|
|
608
|
+
data: { reported: { ram: 8 } },
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
expect(cache.getDeviceTwin()!.properties.reported).toEqual({ ram: 8 });
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("reportDeviceJob is a success no-op (no twin mutation, no emit)", async () => {
|
|
615
|
+
const cache = new TwinCache();
|
|
616
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
617
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
618
|
+
const { fakeSocket } = makeFakeSocket();
|
|
619
|
+
|
|
620
|
+
const result = await router.handleMessage(fakeSocket, "caller", {
|
|
621
|
+
method: "reportDeviceJob",
|
|
622
|
+
});
|
|
623
|
+
expect(result).toEqual({ status: "success" });
|
|
624
|
+
expect(emits).toHaveLength(0);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
describe("MessageRouter — subscriptions & twinMessage routing", () => {
|
|
629
|
+
test("twinSubscribe joins the socket to the target room and acks success", async () => {
|
|
630
|
+
const cache = new TwinCache();
|
|
631
|
+
const { fakeIo } = makeFakeIo();
|
|
632
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
633
|
+
const { fakeSocket, joined } = makeFakeSocket();
|
|
634
|
+
const { callback, calls } = captureCallback();
|
|
635
|
+
|
|
636
|
+
await router.handleMessage(
|
|
637
|
+
fakeSocket,
|
|
638
|
+
"subscriber-twin",
|
|
639
|
+
{ method: "twinSubscribe", twinId: "target-twin" },
|
|
640
|
+
callback,
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
expect(joined).toEqual(["target-twin"]);
|
|
644
|
+
expect(calls).toEqual([{ status: "success" }]);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("twinUnsubscribe acks success without throwing when no prior subscription", async () => {
|
|
648
|
+
const cache = new TwinCache();
|
|
649
|
+
const { fakeIo } = makeFakeIo();
|
|
650
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
651
|
+
const { fakeSocket } = makeFakeSocket();
|
|
652
|
+
const { callback, calls } = captureCallback();
|
|
653
|
+
|
|
654
|
+
await router.handleMessage(
|
|
655
|
+
fakeSocket,
|
|
656
|
+
"subscriber-twin",
|
|
657
|
+
{ method: "twinUnsubscribe", twinId: "target-twin" },
|
|
658
|
+
callback,
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
expect(calls).toEqual([{ status: "success" }]);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("twinMessage with no target twinId is dropped (no emit)", async () => {
|
|
665
|
+
const cache = new TwinCache();
|
|
666
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
667
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
668
|
+
const { fakeSocket } = makeFakeSocket();
|
|
669
|
+
|
|
670
|
+
const result = await router.handleMessage(fakeSocket, "caller", {
|
|
671
|
+
method: "twinMessage",
|
|
672
|
+
});
|
|
673
|
+
expect(result).toBeUndefined();
|
|
674
|
+
expect(emits).toHaveLength(0);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("twinMessage to an Edge twin routes via direct type-based delivery", async () => {
|
|
678
|
+
const cache = new TwinCache();
|
|
679
|
+
cache.addTwin(makeTwin("edge-1", TwinTypeEnum.Edge));
|
|
680
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
681
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
682
|
+
const { fakeSocket } = makeFakeSocket();
|
|
683
|
+
|
|
684
|
+
const payload: EventPayload = {
|
|
685
|
+
method: "twinMessage",
|
|
686
|
+
twinId: "edge-1",
|
|
687
|
+
sourceTwinId: "screen-1",
|
|
688
|
+
data: { type: "cmd", value: 1 },
|
|
689
|
+
};
|
|
690
|
+
await router.handleMessage(fakeSocket, "screen-1", payload);
|
|
691
|
+
|
|
692
|
+
const routed = emits.filter(
|
|
693
|
+
(entry) => entry.room === "edge-1" && entry.event === "twinMessage",
|
|
694
|
+
);
|
|
695
|
+
expect(routed).toHaveLength(1);
|
|
696
|
+
expect(routed[0].args[0]).toEqual(payload);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("twinMessage to a Screen twin routes via direct type-based delivery", async () => {
|
|
700
|
+
const cache = new TwinCache();
|
|
701
|
+
cache.addTwin(makeTwin("screen-1", TwinTypeEnum.Screen));
|
|
702
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
703
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
704
|
+
const { fakeSocket } = makeFakeSocket();
|
|
705
|
+
|
|
706
|
+
const payload: EventPayload = {
|
|
707
|
+
method: "twinMessage",
|
|
708
|
+
twinId: "screen-1",
|
|
709
|
+
sourceTwinId: "edge-1",
|
|
710
|
+
data: { type: "cmd" },
|
|
711
|
+
};
|
|
712
|
+
await router.handleMessage(fakeSocket, "edge-1", payload);
|
|
713
|
+
|
|
714
|
+
const routed = emits.filter(
|
|
715
|
+
(entry) => entry.room === "screen-1" && entry.event === "twinMessage",
|
|
716
|
+
);
|
|
717
|
+
expect(routed).toHaveLength(1);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("twinMessage delivery is deduplicated per (messageId, recipient)", async () => {
|
|
721
|
+
// Two twins with the same id can't coexist in cache; instead assert that
|
|
722
|
+
// re-delivering the identical payload within the 3s message-id window does
|
|
723
|
+
// not double-emit to the same recipient.
|
|
724
|
+
const cache = new TwinCache();
|
|
725
|
+
cache.addTwin(makeTwin("edge-1", TwinTypeEnum.Edge));
|
|
726
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
727
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
728
|
+
const { fakeSocket } = makeFakeSocket();
|
|
729
|
+
|
|
730
|
+
const payload: EventPayload = {
|
|
731
|
+
method: "twinMessage",
|
|
732
|
+
twinId: "edge-1",
|
|
733
|
+
sourceTwinId: "screen-1",
|
|
734
|
+
data: { type: "cmd", value: 7 },
|
|
735
|
+
};
|
|
736
|
+
await router.handleMessage(fakeSocket, "screen-1", payload);
|
|
737
|
+
await router.handleMessage(fakeSocket, "screen-1", payload);
|
|
738
|
+
|
|
739
|
+
const routed = emits.filter(
|
|
740
|
+
(entry) => entry.room === "edge-1" && entry.event === "twinMessage",
|
|
741
|
+
);
|
|
742
|
+
expect(routed).toHaveLength(1);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("handleIncomingTwinUpdated caches the twin and emits twinUpdated to its room", () => {
|
|
746
|
+
const cache = new TwinCache();
|
|
747
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
748
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
749
|
+
|
|
750
|
+
const twin = makeTwin("twin-1", TwinTypeEnum.Screen, {
|
|
751
|
+
properties: { desired: {}, reported: { changed: true } },
|
|
752
|
+
});
|
|
753
|
+
router.handleIncomingTwinUpdated(twin);
|
|
754
|
+
|
|
755
|
+
expect(cache.getTwin("twin-1")).toEqual(twin);
|
|
756
|
+
expect(emits).toEqual([
|
|
757
|
+
{
|
|
758
|
+
room: "twin-1",
|
|
759
|
+
event: "twinMessage",
|
|
760
|
+
args: [{ method: "twinUpdated", twinId: "twin-1", data: twin }],
|
|
761
|
+
},
|
|
762
|
+
]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test("handleIncomingTwinMessage delivers a standalone twinMessage to the target room", () => {
|
|
766
|
+
const cache = new TwinCache();
|
|
767
|
+
cache.addTwin(makeTwin("edge-1", TwinTypeEnum.Edge));
|
|
768
|
+
const { fakeIo, emits } = makeFakeIo();
|
|
769
|
+
const router = new MessageRouter(cache, fakeIo);
|
|
770
|
+
|
|
771
|
+
router.handleIncomingTwinMessage({
|
|
772
|
+
twinId: "edge-1",
|
|
773
|
+
sourceTwinId: "cloud",
|
|
774
|
+
data: { type: "cmd" },
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
const routed = emits.filter(
|
|
778
|
+
(entry) => entry.room === "edge-1" && entry.event === "twinMessage",
|
|
779
|
+
);
|
|
780
|
+
expect(routed).toHaveLength(1);
|
|
781
|
+
});
|
|
782
|
+
});
|