@node-i3x/app 0.1.0 → 0.2.4
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/LICENSE +54 -0
- package/LICENSE-AGPL-3.0 +235 -0
- package/LICENSING.md +107 -0
- package/README.md +9 -3
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +22 -15
- package/src/banner.ts +0 -38
- package/src/cli.ts +0 -50
- package/src/config.ts +0 -119
- package/src/demo.ts +0 -246
- package/src/index.ts +0 -3
- package/src/server.ts +0 -82
- package/tests/e2e.test.ts +0 -766
- package/tsconfig.json +0 -13
- package/tsup.config.ts +0 -41
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
|
-
});
|