@node-i3x/app 0.1.0 → 0.2.3

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/tests/e2e.test.ts DELETED
@@ -1,766 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // End-to-end test: real node-opcua server → full i3X stack
3
- // ─────────────────────────────────────────────────────────────
4
- //
5
- // This test spins up a representative OPC UA server with:
6
- // - A "ProductionLine" folder containing two machines
7
- // - Each machine has temperature + speed variables
8
- // - One machine has a "Reset" method
9
- // - Variables update on a timer to exercise subscriptions
10
- //
11
- // Then it boots the full i3X stack (connector → core → REST)
12
- // and validates end-to-end flows against the HTTP API.
13
- // ─────────────────────────────────────────────────────────────
14
-
15
- import {
16
- consoleLogger,
17
- HistoryService,
18
- ModelService,
19
- SubscriptionService,
20
- ValueService,
21
- } from '@node-i3x/core';
22
- import { OpcUaClient, OpcUaDataSourceAdapter } from '@node-i3x/opcua-connector';
23
- import { createApp } from '@node-i3x/rest-server';
24
- import {
25
- DataType,
26
- type Namespace,
27
- nodesets,
28
- OPCUAServer,
29
- StatusCodes,
30
- Variant,
31
- } from 'node-opcua';
32
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
33
-
34
- // ── Server factory ───────────────────────────────────────────
35
-
36
- let serverPort: number;
37
- let opcuaServer: OPCUAServer;
38
-
39
- function getRandomPort(): number {
40
- return 48_100 + Math.floor(Math.random() * 900);
41
- }
42
-
43
- async function startTestOpcUaServer(): Promise<OPCUAServer> {
44
- serverPort = getRandomPort();
45
- const server = new OPCUAServer({
46
- port: serverPort,
47
- resourcePath: '/UA/i3xTest',
48
- nodeset_filename: [nodesets.standard],
49
- maxConnectionsPerEndpoint: 10,
50
- serverInfo: {
51
- applicationName: { text: 'i3x E2E Test Server' },
52
- applicationUri: 'urn:i3x:e2e:test',
53
- productUri: 'urn:i3x:e2e:test',
54
- },
55
- });
56
-
57
- await server.initialize();
58
-
59
- const addressSpace = server.engine.addressSpace;
60
- if (!addressSpace) throw new Error('Address space not initialized');
61
- const namespace: Namespace = addressSpace.getOwnNamespace();
62
-
63
- // ── ProductionLine (folder) ──────────────────────────────
64
- const productionLine = namespace.addObject({
65
- organizedBy: addressSpace.rootFolder.objects,
66
- browseName: 'ProductionLine',
67
- displayName: 'Production Line #1',
68
- });
69
-
70
- // ── Machine A ────────────────────────────────────────────
71
- const machineA = namespace.addObject({
72
- componentOf: productionLine,
73
- browseName: 'MachineA',
74
- displayName: 'CNC Milling Machine',
75
- });
76
-
77
- let temperatureA = 65.0;
78
- namespace.addVariable({
79
- componentOf: machineA,
80
- browseName: 'Temperature',
81
- displayName: 'Temperature',
82
- dataType: DataType.Double,
83
- value: {
84
- get: () => new Variant({ dataType: DataType.Double, value: temperatureA }),
85
- },
86
- });
87
-
88
- let speedA = 1500;
89
- namespace.addVariable({
90
- componentOf: machineA,
91
- browseName: 'SpindleSpeed',
92
- displayName: 'Spindle Speed',
93
- dataType: DataType.Int32,
94
- value: {
95
- get: () => new Variant({ dataType: DataType.Int32, value: speedA }),
96
- },
97
- });
98
-
99
- namespace.addVariable({
100
- componentOf: machineA,
101
- browseName: 'Status',
102
- displayName: 'Status',
103
- dataType: DataType.String,
104
- value: {
105
- get: () => new Variant({ dataType: DataType.String, value: 'Running' }),
106
- },
107
- });
108
-
109
- // Method: Reset
110
- const uaResetMethod = namespace.addMethod(machineA, {
111
- browseName: 'Reset',
112
- displayName: 'Reset Machine',
113
- inputArguments: [],
114
- outputArguments: [],
115
- });
116
- uaResetMethod.bindMethod(async (_inputArguments, _context) => {
117
- temperatureA = 25.0;
118
- speedA = 0;
119
- return { statusCode: StatusCodes.Good };
120
- });
121
-
122
- // ── Machine B ────────────────────────────────────────────
123
- const machineB = namespace.addObject({
124
- componentOf: productionLine,
125
- browseName: 'MachineB',
126
- displayName: 'Laser Cutter',
127
- });
128
-
129
- namespace.addVariable({
130
- componentOf: machineB,
131
- browseName: 'Temperature',
132
- displayName: 'Temperature',
133
- dataType: DataType.Double,
134
- value: {
135
- get: () => new Variant({ dataType: DataType.Double, value: 42.7 }),
136
- },
137
- });
138
-
139
- namespace.addVariable({
140
- componentOf: machineB,
141
- browseName: 'LaserPower',
142
- displayName: 'Laser Power',
143
- dataType: DataType.Float,
144
- value: {
145
- get: () => new Variant({ dataType: DataType.Float, value: 2400.5 }),
146
- },
147
- });
148
-
149
- namespace.addVariable({
150
- componentOf: machineB,
151
- browseName: 'JobCount',
152
- displayName: 'Job Count',
153
- dataType: DataType.UInt32,
154
- value: {
155
- get: () => new Variant({ dataType: DataType.UInt32, value: 1247 }),
156
- },
157
- });
158
-
159
- // ── CoffeeMachine (deeply nested for subscription test) ──
160
- const coffeeMachine = namespace.addObject({
161
- componentOf: productionLine,
162
- browseName: 'CoffeeMachine',
163
- displayName: 'Coffee Machine Pro 3000',
164
- });
165
-
166
- // ParameterSet (sub-object containing UAVariables)
167
- const parameterSet = namespace.addObject({
168
- componentOf: coffeeMachine,
169
- browseName: 'ParameterSet',
170
- displayName: 'ParameterSet',
171
- });
172
-
173
- // All CoffeeMachine variables MONOTONICALLY INCREASE
174
- // so every OPC UA sample is a guaranteed DataChange
175
- let brewTemperature = 93.0;
176
- let _brewTick = 0;
177
- namespace.addVariable({
178
- componentOf: parameterSet,
179
- browseName: 'BrewTemperature',
180
- displayName: 'Brew Temperature',
181
- dataType: DataType.Double,
182
- value: {
183
- get: () => new Variant({ dataType: DataType.Double, value: brewTemperature }),
184
- },
185
- });
186
-
187
- let pumpPressure = 9.0;
188
- namespace.addVariable({
189
- componentOf: parameterSet,
190
- browseName: 'PumpPressure',
191
- displayName: 'Pump Pressure',
192
- dataType: DataType.Double,
193
- value: {
194
- get: () => new Variant({ dataType: DataType.Double, value: pumpPressure }),
195
- },
196
- });
197
-
198
- let waterLevel = 100.0;
199
- namespace.addVariable({
200
- componentOf: parameterSet,
201
- browseName: 'WaterLevel',
202
- displayName: 'Water Level',
203
- dataType: DataType.Float,
204
- value: {
205
- get: () => new Variant({ dataType: DataType.Float, value: waterLevel }),
206
- },
207
- });
208
-
209
- // GrinderUnit — nested 2 levels deep inside CoffeeMachine
210
- const grinderUnit = namespace.addObject({
211
- componentOf: coffeeMachine,
212
- browseName: 'GrinderUnit',
213
- displayName: 'Grinder Unit',
214
- });
215
-
216
- let grinderRPM = 1200;
217
- namespace.addVariable({
218
- componentOf: grinderUnit,
219
- browseName: 'RPM',
220
- displayName: 'Grinder RPM',
221
- dataType: DataType.Int32,
222
- value: {
223
- get: () => new Variant({ dataType: DataType.Int32, value: grinderRPM }),
224
- },
225
- });
226
-
227
- let grindSizeTick = 0;
228
- const grindSizes = ['Coarse', 'Medium', 'Fine', 'Extra-Fine'];
229
- namespace.addVariable({
230
- componentOf: grinderUnit,
231
- browseName: 'GrindSize',
232
- displayName: 'Grind Size',
233
- dataType: DataType.String,
234
- value: {
235
- get: () =>
236
- new Variant({
237
- dataType: DataType.String,
238
- value: grindSizes[grindSizeTick % grindSizes.length],
239
- }),
240
- },
241
- });
242
-
243
- // Monotonically increase every 200ms for clear evidence
244
- const interval = setInterval(() => {
245
- _brewTick++;
246
- temperatureA += 0.5;
247
- brewTemperature += 0.1; // 93.0 → 93.1 → 93.2 → ...
248
- pumpPressure += 0.05; // 9.0 → 9.05 → 9.10 → ...
249
- waterLevel -= 0.3; // 100 → 99.7 → 99.4 → ...
250
- grinderRPM += 10; // 1200 → 1210 → 1220 → ...
251
- grindSizeTick++; // cycles through sizes
252
- }, 200);
253
- (server as Record<string, unknown>)._e2eInterval = interval;
254
-
255
- await server.start();
256
- return server;
257
- }
258
-
259
- // ── Test suite ───────────────────────────────────────────────
260
-
261
- describe('E2E: OPC UA Server → i3X REST API', () => {
262
- let app: Awaited<ReturnType<typeof createApp>>;
263
- let modelService: ModelService;
264
- let subscriptionService: SubscriptionService;
265
-
266
- beforeAll(async () => {
267
- // 1. Start a real OPC UA server
268
- opcuaServer = await startTestOpcUaServer();
269
- const endpointUrl = `opc.tcp://localhost:${serverPort}/UA/i3xTest`;
270
-
271
- // 2. Create the connector
272
- const logger = consoleLogger;
273
- const opcuaClient = new OpcUaClient(
274
- {
275
- endpointUrl,
276
- securityMode: 'None',
277
- optimizedClient: 'auto',
278
- },
279
- logger,
280
- );
281
- const dataSource = new OpcUaDataSourceAdapter(opcuaClient, logger);
282
-
283
- // 3. Domain services
284
- modelService = new ModelService(dataSource, logger);
285
- const valueService = new ValueService(dataSource, modelService, logger);
286
- const historyService = new HistoryService(dataSource, modelService, logger);
287
- subscriptionService = new SubscriptionService(dataSource, modelService, logger, 1);
288
-
289
- // 4. REST server
290
- app = await createApp({
291
- dataSource,
292
- modelService,
293
- valueService,
294
- historyService,
295
- subscriptionService,
296
- logger,
297
- });
298
-
299
- // 5. Connect and preload
300
- await dataSource.connect();
301
- await modelService.preloadModel();
302
- }, 30_000);
303
-
304
- afterAll(async () => {
305
- await subscriptionService.close();
306
- const ds = (app as Record<string, unknown>).deps as Record<
307
- string,
308
- { disconnect: () => Promise<void> }
309
- >;
310
- if (ds?.dataSource) await ds.dataSource.disconnect();
311
- clearInterval(
312
- (opcuaServer as Record<string, unknown>)._e2eInterval as NodeJS.Timeout,
313
- );
314
- await opcuaServer.shutdown(500);
315
- }, 15_000);
316
-
317
- // ── Info ─────────────────────────────────────────────────
318
-
319
- it('GET /v1/info returns server capabilities', async () => {
320
- const res = await app.inject({ method: 'GET', url: '/v1/info' });
321
- expect(res.statusCode).toBe(200);
322
- const body = res.json();
323
- expect(body.success).toBe(true);
324
- expect(body.result.specVersion).toBe('beta');
325
- expect(body.result.capabilities.query.history).toBe(true);
326
- });
327
-
328
- // ── Health ───────────────────────────────────────────────
329
-
330
- it('GET /health returns ok', async () => {
331
- const res = await app.inject({ method: 'GET', url: '/health' });
332
- expect(res.statusCode).toBe(200);
333
- expect(res.json().status).toBe('ok');
334
- });
335
-
336
- it('GET /ready returns ready when connected', async () => {
337
- const res = await app.inject({ method: 'GET', url: '/ready' });
338
- expect(res.statusCode).toBe(200);
339
- });
340
-
341
- // ── Namespaces ───────────────────────────────────────────
342
-
343
- it('GET /v1/namespaces returns real OPC UA namespaces', async () => {
344
- const res = await app.inject({ method: 'GET', url: '/v1/namespaces' });
345
- expect(res.statusCode).toBe(200);
346
- const body = res.json();
347
- expect(body.success).toBe(true);
348
- expect(body.result.length).toBeGreaterThanOrEqual(2);
349
- // First namespace is always the OPC UA standard namespace
350
- expect(body.result[0].uri).toContain('opcfoundation.org');
351
- });
352
-
353
- // ── Object types ─────────────────────────────────────────
354
-
355
- it('GET /v1/objecttypes returns browsed types', async () => {
356
- const res = await app.inject({ method: 'GET', url: '/v1/objecttypes' });
357
- expect(res.statusCode).toBe(200);
358
- const body = res.json();
359
- expect(body.success).toBe(true);
360
- expect(body.result.length).toBeGreaterThanOrEqual(1);
361
- });
362
-
363
- // ── Objects ──────────────────────────────────────────────
364
-
365
- it('GET /v1/objects returns root-level objects from OPC UA', async () => {
366
- const res = await app.inject({ method: 'GET', url: '/v1/objects' });
367
- expect(res.statusCode).toBe(200);
368
- const body = res.json();
369
- expect(body.success).toBe(true);
370
- expect(body.result.length).toBeGreaterThanOrEqual(1);
371
- // Should find our ProductionLine
372
- const names = body.result.map((r: Record<string, string>) => r.displayName);
373
- expect(names).toContain('Production Line #1');
374
- });
375
-
376
- it('POST /v1/objects/list resolves a real element by id', async () => {
377
- const model = await modelService.getOrBuildModel();
378
- const rootId = model.rootIds[0]!;
379
-
380
- const res = await app.inject({
381
- method: 'POST',
382
- url: '/v1/objects/list',
383
- payload: { elementIds: [rootId] },
384
- });
385
- expect(res.statusCode).toBe(200);
386
- const body = res.json();
387
- expect(body.results[0].success).toBe(true);
388
- expect(body.results[0].result.displayName).toBeTruthy();
389
- });
390
-
391
- // ── Values ───────────────────────────────────────────────
392
-
393
- it('POST /v1/objects/value reads real OPC UA variable values', async () => {
394
- const model = await modelService.getOrBuildModel();
395
-
396
- // Find a property node (a Variable)
397
- const propId = [...model.propertyToSource.keys()][0]!;
398
-
399
- const res = await app.inject({
400
- method: 'POST',
401
- url: '/v1/objects/value',
402
- payload: { elementIds: [propId], maxDepth: 1 },
403
- });
404
- expect(res.statusCode).toBe(200);
405
- const body = res.json();
406
- expect(body.results[0].success).toBe(true);
407
- expect(body.results[0].result.value).not.toBeNull();
408
- expect(body.results[0].result.quality).toBe('Good');
409
- });
410
-
411
- it('POST /v1/objects/value returns composition for asset nodes', async () => {
412
- const model = await modelService.getOrBuildModel();
413
-
414
- // Find a root asset with children
415
- const assetId = model.rootIds.find((id) => {
416
- const node = model.nodesById.get(id);
417
- return node && node.children.length > 0;
418
- });
419
-
420
- if (!assetId) return; // skip if server has no suitable nodes
421
-
422
- const res = await app.inject({
423
- method: 'POST',
424
- url: '/v1/objects/value',
425
- payload: { elementIds: [assetId], maxDepth: 2 },
426
- });
427
- expect(res.statusCode).toBe(200);
428
- const body = res.json();
429
- expect(body.results[0].success).toBe(true);
430
- expect(body.results[0].result.isComposition).toBe(true);
431
- });
432
-
433
- // ── Subscriptions (full lifecycle) ───────────────────────
434
-
435
- it('full subscription lifecycle: create → register → sync → delete', async () => {
436
- const model = await modelService.getOrBuildModel();
437
- const propId = [...model.propertyToSource.keys()][0]!;
438
-
439
- // Create subscription
440
- const createRes = await app.inject({
441
- method: 'POST',
442
- url: '/v1/subscriptions',
443
- payload: { clientId: 'e2e-test', displayName: 'E2E Subscription' },
444
- });
445
- expect(createRes.statusCode).toBe(200);
446
- const subId = createRes.json().result.subscriptionId;
447
- expect(subId).toBeTruthy();
448
-
449
- // Register a monitored item
450
- const regRes = await app.inject({
451
- method: 'POST',
452
- url: '/v1/subscriptions/register',
453
- payload: { subscriptionId: subId, elementIds: [propId], maxDepth: 1 },
454
- });
455
- expect(regRes.statusCode).toBe(200);
456
- expect(regRes.json().success).toBe(true);
457
- expect(regRes.json().results[0].success).toBe(true);
458
- expect(regRes.json().results[0].elementId).toBe(propId);
459
-
460
- // Wait a moment for data changes to arrive
461
- await new Promise((r) => setTimeout(r, 2000));
462
-
463
- // Sync — should have updates
464
- const syncRes = await app.inject({
465
- method: 'POST',
466
- url: '/v1/subscriptions/sync',
467
- payload: { subscriptionId: subId, acknowledgeSequence: 0 },
468
- });
469
- expect(syncRes.statusCode).toBe(200);
470
- const updates = syncRes.json().result;
471
- // Updates may or may not be there depending on timing,
472
- // but the call must succeed
473
- expect(Array.isArray(updates)).toBe(true);
474
-
475
- // List subscriptions
476
- const listRes = await app.inject({
477
- method: 'POST',
478
- url: '/v1/subscriptions/list',
479
- payload: { subscriptionIds: [subId] },
480
- });
481
- expect(listRes.statusCode).toBe(200);
482
- expect(listRes.json().results[0].success).toBe(true);
483
- expect(listRes.json().results[0].subscriptionId).toBe(subId);
484
- expect(listRes.json().results[0].result.subscriptionId).toBe(subId);
485
-
486
- // Delete
487
- const delRes = await app.inject({
488
- method: 'POST',
489
- url: '/v1/subscriptions/delete',
490
- payload: { subscriptionIds: [subId] },
491
- });
492
- expect(delRes.statusCode).toBe(200);
493
- expect(delRes.json().results[0].success).toBe(true);
494
- }, 15_000);
495
-
496
- // ── Deep subscription: CoffeeMachine nested monitoring ───
497
-
498
- it('deep subscribe: monitoring CoffeeMachine auto-discovers nested ParameterSet + GrinderUnit variables', async () => {
499
- const model = await modelService.getOrBuildModel();
500
-
501
- // 1. Find the CoffeeMachine asset (top-level object)
502
- const coffeeNode = [...model.nodesById.values()].find(
503
- (n) => n.name === 'Coffee Machine Pro 3000',
504
- );
505
- expect(coffeeNode).toBeTruthy();
506
- const coffeeId = coffeeNode!.id;
507
-
508
- // Verify nested structure exists in the model
509
- const childIds = model.childrenById.get(coffeeId) ?? [];
510
- expect(childIds.length).toBeGreaterThanOrEqual(2); // ParameterSet + GrinderUnit
511
-
512
- // Find ParameterSet children
513
- const paramSetNode = [...model.nodesById.values()].find(
514
- (n) => n.name === 'ParameterSet' && childIds.includes(n.id),
515
- );
516
- expect(paramSetNode).toBeTruthy();
517
-
518
- const paramChildren = model.childrenById.get(paramSetNode!.id) ?? [];
519
- const paramPropertyNames = paramChildren
520
- .map((id) => model.nodesById.get(id))
521
- .filter(Boolean)
522
- .filter((n) => n!.kind === 'property')
523
- .map((n) => n!.name);
524
- expect(paramPropertyNames).toContain('Brew Temperature');
525
- expect(paramPropertyNames).toContain('Pump Pressure');
526
- expect(paramPropertyNames).toContain('Water Level');
527
-
528
- // Find GrinderUnit children
529
- const grinderNode = [...model.nodesById.values()].find(
530
- (n) => n.name === 'Grinder Unit' && childIds.includes(n.id),
531
- );
532
- expect(grinderNode).toBeTruthy();
533
-
534
- const grinderChildren = model.childrenById.get(grinderNode!.id) ?? [];
535
- const grinderPropertyNames = grinderChildren
536
- .map((id) => model.nodesById.get(id))
537
- .filter(Boolean)
538
- .filter((n) => n!.kind === 'property')
539
- .map((n) => n!.name);
540
- expect(grinderPropertyNames).toContain('Grinder RPM');
541
- expect(grinderPropertyNames).toContain('Grind Size');
542
-
543
- // 2. Create subscription
544
- const createRes = await app.inject({
545
- method: 'POST',
546
- url: '/v1/subscriptions',
547
- payload: { clientId: 'deep-test', displayName: 'Deep CoffeeMachine Sub' },
548
- });
549
- expect(createRes.statusCode).toBe(200);
550
- const subId = createRes.json().result.subscriptionId;
551
-
552
- // 3. Register the TOP-LEVEL CoffeeMachine with maxDepth=3
553
- // This should auto-discover ALL nested variables:
554
- // CoffeeMachine (depth 0)
555
- // → ParameterSet (depth 1)
556
- // → BrewTemperature, PumpPressure, WaterLevel (depth 2, property)
557
- // → GrinderUnit (depth 1)
558
- // → RPM, GrindSize (depth 2, property)
559
- const regRes = await app.inject({
560
- method: 'POST',
561
- url: '/v1/subscriptions/register',
562
- payload: {
563
- subscriptionId: subId,
564
- elementIds: [coffeeId],
565
- maxDepth: 3,
566
- },
567
- });
568
- expect(regRes.statusCode).toBe(200);
569
- expect(regRes.json().success).toBe(true);
570
- expect(regRes.json().results[0].success).toBe(true);
571
- expect(regRes.json().results[0].elementId).toBe(coffeeId);
572
-
573
- // 4. Verify list shows the CoffeeMachine as monitored
574
- const listRes = await app.inject({
575
- method: 'POST',
576
- url: '/v1/subscriptions/list',
577
- payload: { subscriptionIds: [subId] },
578
- });
579
- expect(listRes.statusCode).toBe(200);
580
- const detail = listRes.json().results[0].result;
581
- expect(detail.monitoredObjects).toHaveLength(1);
582
- expect(detail.monitoredObjects[0].elementId).toBe(coffeeId);
583
- expect(detail.monitoredObjects[0].maxDepth).toBe(3);
584
- // Should be running in native mode (real OPC UA subscription)
585
- expect(detail.mode).toBe('native');
586
-
587
- // 5. Wait for data change notifications
588
- // Values change every 200ms, subscription publishes at 1s
589
- // → after 3s we should have multiple notifications
590
- await new Promise((r) => setTimeout(r, 3000));
591
-
592
- // 6. Sync — should have composite updates for the CoffeeMachine
593
- const syncRes = await app.inject({
594
- method: 'POST',
595
- url: '/v1/subscriptions/sync',
596
- payload: { subscriptionId: subId, acknowledgeSequence: 0 },
597
- });
598
- expect(syncRes.statusCode).toBe(200);
599
- const updates = syncRes.json().result;
600
- expect(Array.isArray(updates)).toBe(true);
601
- expect(updates.length).toBeGreaterThan(0);
602
-
603
- // With composite values, all updates have elementId = CoffeeMachine
604
- for (const update of updates) {
605
- expect(update.elementId).toBe(coffeeId);
606
- expect(update.sequenceNumber).toBeGreaterThan(0);
607
- expect(update.timestamp).toBeTruthy();
608
- }
609
-
610
- // The latest update should be a composite with components
611
- // from nested properties (BrewTemperature, PumpPressure, etc.)
612
- const latest = updates[updates.length - 1];
613
- expect(latest.value).toBeTruthy();
614
- expect(latest.value.isComposition).toBe(true);
615
- expect(latest.value.components).toBeTruthy();
616
-
617
- const componentKeys = Object.keys(latest.value.components);
618
- // Should have at least 3 nested property components
619
- expect(componentKeys.length).toBeGreaterThanOrEqual(3);
620
-
621
- // Each component should be a VQT
622
- for (const key of componentKeys) {
623
- const vqt = latest.value.components[key];
624
- expect(vqt.quality).toBeTruthy();
625
- expect(vqt.timestamp).toBeTruthy();
626
- // Value should be defined (initial data change fired)
627
- expect(vqt.value).toBeDefined();
628
- }
629
-
630
- // ── Print evidence ──
631
- console.log(`\n╔══════════════════════════════════════════════════╗`);
632
- console.log(`║ Deep Subscription: CoffeeMachine composite ║`);
633
- console.log(
634
- `║ ${updates.length} updates, ${componentKeys.length} components ║`,
635
- );
636
- console.log(`╠══════════════════════════════════════════════════╣`);
637
- for (const key of componentKeys) {
638
- const vqt = latest.value.components[key];
639
- const name = model.nodesById.get(key)?.name ?? key;
640
- const val =
641
- typeof vqt.value === 'number' ? vqt.value.toFixed(2) : String(vqt.value);
642
- console.log(`║ ${name.padEnd(20)} │ ${val}`);
643
- }
644
- console.log(`╚══════════════════════════════════════════════════╝\n`);
645
-
646
- // 7. Cleanup
647
- const delRes = await app.inject({
648
- method: 'POST',
649
- url: '/v1/subscriptions/delete',
650
- payload: { subscriptionIds: [subId] },
651
- });
652
- expect(delRes.statusCode).toBe(200);
653
- }, 20_000);
654
-
655
- // ── Subscription value must match /objects/value shape ─────
656
-
657
- it('subscription composite matches /objects/value format for the same asset', async () => {
658
- const model = await modelService.getOrBuildModel();
659
-
660
- // Find the CoffeeMachine asset
661
- const coffeeNode = [...model.nodesById.values()].find(
662
- (n) => n.name === 'Coffee Machine Pro 3000',
663
- );
664
- expect(coffeeNode).toBeTruthy();
665
- const coffeeId = coffeeNode!.id;
666
-
667
- // ── Step 1: Read the canonical value via /objects/value ──
668
- const valueRes = await app.inject({
669
- method: 'POST',
670
- url: '/v1/objects/value',
671
- payload: { elementIds: [coffeeId], maxDepth: 3 },
672
- });
673
- expect(valueRes.statusCode).toBe(200);
674
- const valueResult = valueRes.json().results[0].result;
675
- expect(valueResult.isComposition).toBe(true);
676
- expect(valueResult.components).toBeTruthy();
677
-
678
- const canonicalKeys = Object.keys(valueResult.components).sort();
679
- expect(canonicalKeys.length).toBeGreaterThanOrEqual(3);
680
-
681
- // ── Step 2: Create subscription and register same asset ──
682
- const createRes = await app.inject({
683
- method: 'POST',
684
- url: '/v1/subscriptions',
685
- payload: { clientId: 'match-test' },
686
- });
687
- const subId = createRes.json().result.subscriptionId;
688
-
689
- const regRes = await app.inject({
690
- method: 'POST',
691
- url: '/v1/subscriptions/register',
692
- payload: { subscriptionId: subId, elementIds: [coffeeId], maxDepth: 3 },
693
- });
694
- expect(regRes.statusCode).toBe(200);
695
- expect(regRes.json().results[0].success).toBe(true);
696
-
697
- // ── Step 3: Wait for initial data, then sync ──
698
- await new Promise((r) => setTimeout(r, 3000));
699
-
700
- const syncRes = await app.inject({
701
- method: 'POST',
702
- url: '/v1/subscriptions/sync',
703
- payload: { subscriptionId: subId, acknowledgeSequence: 0 },
704
- });
705
- expect(syncRes.statusCode).toBe(200);
706
- const updates = syncRes.json().result;
707
- expect(updates.length).toBeGreaterThan(0);
708
-
709
- // ── Step 4: The critical assertion ──
710
- // The latest sync update must have the SAME component keys
711
- // as /objects/value — if they differ, the explorer can't
712
- // correlate subscription data with its object model.
713
- const latest = updates[updates.length - 1];
714
-
715
- // elementId must match what we registered
716
- expect(latest.elementId).toBe(coffeeId);
717
-
718
- // value must be a composition
719
- expect(latest.value.isComposition).toBe(true);
720
- expect(latest.value.components).toBeTruthy();
721
- expect(latest.value.components).not.toEqual({});
722
-
723
- const subscriptionKeys = Object.keys(latest.value.components).sort();
724
-
725
- // THE CRITICAL CHECK: same component keys as /objects/value
726
- expect(subscriptionKeys).toEqual(canonicalKeys);
727
-
728
- // Each component must have VQT shape (value, quality, timestamp)
729
- for (const key of subscriptionKeys) {
730
- const vqt = latest.value.components[key];
731
- expect(vqt).toHaveProperty('value');
732
- expect(vqt).toHaveProperty('quality');
733
- expect(vqt).toHaveProperty('timestamp');
734
- }
735
-
736
- // Cleanup
737
- await app.inject({
738
- method: 'POST',
739
- url: '/v1/subscriptions/delete',
740
- payload: { subscriptionIds: [subId] },
741
- });
742
- }, 20_000);
743
-
744
- // ── Error handling ───────────────────────────────────────
745
-
746
- it('POST /v1/objects/list returns 404 for unknown elements', async () => {
747
- const res = await app.inject({
748
- method: 'POST',
749
- url: '/v1/objects/list',
750
- payload: { elementIds: ['nonexistent-element-id'] },
751
- });
752
- expect(res.statusCode).toBe(200);
753
- const body = res.json();
754
- expect(body.results[0].success).toBe(false);
755
- expect(body.results[0].error.code).toBe(404);
756
- });
757
-
758
- it('POST /v1/subscriptions/stream returns 404 for unknown subscription', async () => {
759
- const res = await app.inject({
760
- method: 'POST',
761
- url: '/v1/subscriptions/stream',
762
- payload: { subscriptionId: 'does-not-exist' },
763
- });
764
- expect(res.statusCode).toBe(404);
765
- });
766
- });