@xiboplayer/xmds 0.5.20 → 0.6.1

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.
@@ -1,28 +1,23 @@
1
1
  /**
2
- * XMDS REST API — Live Integration Tests
2
+ * REST API — Live Integration Tests
3
3
  *
4
- * Tests the REST transport against a real Xibo CMS instance.
5
- * These tests verify end-to-end communication between the XmdsClient
6
- * (REST mode) and the CMS Player REST API endpoints.
4
+ * Tests the RestClient transport against a real Xibo CMS instance
5
+ * with the Player API module deployed.
7
6
  *
8
7
  * Prerequisites:
9
- * - CMS at CMS_URL must have the Player REST API patch applied
8
+ * - CMS at CMS_URL must have /api/v2/player/* endpoints deployed
10
9
  * - A display with the given HARDWARE_KEY must exist and be authorized
11
10
  * - The SERVER_KEY must match the CMS setting
12
11
  *
13
12
  * Run with:
14
- * CMS_URL=https://your-cms.example.com \
15
- * CMS_KEY=your-cms-key \
16
- * HARDWARE_KEY=pwa-your-hardware-key \
13
+ * CMS_URL=https://displays.superpantalles.com \
14
+ * CMS_KEY=isiSdUCy \
15
+ * HARDWARE_KEY=pwa-11e79847294d418ba74df4ba534d \
17
16
  * npx vitest run src/xmds.rest.integration.test.js
18
- *
19
- * Or:
20
- * npm test -- --testPathPattern=integration
21
17
  */
22
18
 
23
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
19
+ import { describe, it, expect, beforeAll } from 'vitest';
24
20
  import { RestClient } from './rest-client.js';
25
- import { XmdsClient } from './xmds-client.js';
26
21
 
27
22
  // ─── Configuration ─────────────────────────────────────────────────
28
23
 
@@ -31,219 +26,170 @@ const CMS_KEY = process.env.CMS_KEY || 'your-cms-key';
31
26
  const HARDWARE_KEY = process.env.HARDWARE_KEY || 'pwa-your-hardware-key';
32
27
  const DISPLAY_NAME = process.env.DISPLAY_NAME || 'REST Integration Test';
33
28
 
34
- // Skip all tests if no CMS_URL is provided and we're not in CI
29
+ // Skip all tests if no CMS_URL is provided
35
30
  const SKIP = !process.env.CMS_URL && !process.env.CI && !process.env.RUN_INTEGRATION;
36
31
 
37
32
  // ─── Test Suite ────────────────────────────────────────────────────
38
33
 
39
- describe.skipIf(SKIP)('XMDS REST API — Live Integration', () => {
34
+ describe.skipIf(SKIP)('REST API — Live Integration', () => {
40
35
  /** @type {RestClient} */
41
36
  let client;
42
- /** @type {XmdsClient} */
43
- let soapClient;
44
37
 
45
38
  beforeAll(() => {
46
39
  // Restore real fetch (vitest.setup.js mocks it for unit tests)
47
- global.fetch = global.__nativeFetch;
40
+ if (global.__nativeFetch) global.fetch = global.__nativeFetch;
48
41
 
49
- // REST client
50
42
  client = new RestClient({
51
43
  cmsUrl: CMS_URL,
52
44
  cmsKey: CMS_KEY,
53
45
  hardwareKey: HARDWARE_KEY,
54
46
  displayName: DISPLAY_NAME,
55
- xmrChannel: 'integration-test-channel',
56
- retryOptions: { maxRetries: 1, baseDelayMs: 500 },
57
- });
58
-
59
- // SOAP client for parity comparison
60
- soapClient = new XmdsClient({
61
- cmsUrl: CMS_URL,
62
- cmsKey: CMS_KEY,
63
- hardwareKey: HARDWARE_KEY,
64
- displayName: DISPLAY_NAME,
65
- xmrChannel: 'integration-test-channel',
47
+ xmrChannel: 'rest-integration-test',
66
48
  retryOptions: { maxRetries: 1, baseDelayMs: 500 },
67
49
  });
68
50
  });
69
51
 
70
- // ────────────────────────────────────────────────────────────────
71
- // RegisterDisplay
72
- // ────────────────────────────────────────────────────────────────
73
-
74
- describe('RegisterDisplay', () => {
75
- it('should register and return READY for an authorized display', async () => {
76
- const result = await client.registerDisplay();
52
+ // ──────────────────────────────────────────────────────────────────
53
+ // Health & Availability
54
+ // ──────────────────────────────────────────────────────────────────
77
55
 
78
- expect(result).toBeDefined();
79
- expect(result.code).toBeDefined();
80
- expect(['READY', 'WAITING', 'ADDED']).toContain(result.code);
81
-
82
- if (result.code === 'READY') {
83
- expect(result.settings).toBeDefined();
84
- expect(result.settings).not.toBeNull();
85
- expect(result.message).toContain('Display is active');
86
- } else {
87
- expect(result.message).toContain('awaiting');
88
- }
56
+ describe('Health & Availability', () => {
57
+ it('should detect REST availability via static isAvailable()', async () => {
58
+ const available = await RestClient.isAvailable(CMS_URL);
59
+ expect(available).toBe(true);
89
60
  });
90
61
 
91
- it('should return READY with expected settings keys', async () => {
92
- const result = await client.registerDisplay();
62
+ it('should return false for a CMS without REST API', async () => {
63
+ const available = await RestClient.isAvailable('http://127.0.0.1:1', { maxRetries: 0 });
64
+ expect(available).toBe(false);
65
+ }, 10000);
66
+ });
93
67
 
94
- if (result.code !== 'READY') {
95
- console.warn('Display not authorized — skipping settings check');
96
- return;
97
- }
68
+ // ──────────────────────────────────────────────────────────────────
69
+ // JWT Authentication
70
+ // ──────────────────────────────────────────────────────────────────
98
71
 
99
- // Core settings that every Xibo display receives
100
- // Note: downloadStartWindow/downloadEndWindow are optional per CMS config
101
- const expectedKeys = [
102
- 'collectInterval',
103
- 'statsEnabled',
104
- 'xmrNetworkAddress',
105
- ];
72
+ describe('JWT Authentication', () => {
73
+ it('should authenticate and obtain a JWT token', async () => {
74
+ client._token = null;
75
+ await client._authenticate();
106
76
 
107
- for (const key of expectedKeys) {
108
- expect(result.settings, `Missing setting: ${key}`).toHaveProperty(key);
109
- }
77
+ expect(client._token).toBeDefined();
78
+ expect(client._token.length).toBeGreaterThan(50);
79
+ expect(client._displayId).toBeGreaterThan(0);
80
+ expect(client._tokenExpiresAt).toBeGreaterThan(Date.now());
81
+ });
82
+
83
+ it('should reuse token for subsequent calls', async () => {
84
+ const token1 = await client._getToken();
85
+ const token2 = await client._getToken();
86
+ expect(token1).toBe(token2);
110
87
  });
111
88
 
112
- it('should fail gracefully with wrong server key', async () => {
113
- const badClient = new RestClient({
89
+ it('should fail auth with wrong server key', async () => {
90
+ const bad = new RestClient({
114
91
  cmsUrl: CMS_URL,
115
92
  cmsKey: 'wrong-key',
116
93
  hardwareKey: HARDWARE_KEY,
117
- displayName: DISPLAY_NAME,
118
- xmrChannel: 'test',
119
94
  retryOptions: { maxRetries: 0 },
120
95
  });
121
96
 
122
- // The CMS returns error code 0 (not an HTTP error)
123
- // So we expect either an Error or a result with error info
124
- try {
125
- const result = await badClient.registerDisplay();
126
- // If it doesn't throw, the code should indicate failure
127
- expect(result.code).not.toBe('READY');
128
- } catch (e) {
129
- expect(e.message).toMatch(/server key|failed|400|500/i);
130
- }
97
+ await expect(bad._authenticate()).rejects.toThrow(/403|forbidden|server key/i);
131
98
  });
132
99
 
133
- it('should produce the same code as SOAP transport', async () => {
134
- const restResult = await client.registerDisplay();
135
- const soapResult = await soapClient.registerDisplay();
100
+ it('should fail auth with wrong hardware key', async () => {
101
+ const bad = new RestClient({
102
+ cmsUrl: CMS_URL,
103
+ cmsKey: CMS_KEY,
104
+ hardwareKey: 'nonexistent-display',
105
+ retryOptions: { maxRetries: 0 },
106
+ });
136
107
 
137
- expect(restResult.code).toBe(soapResult.code);
108
+ await expect(bad._authenticate()).rejects.toThrow(/403|not found|denied/i);
138
109
  });
139
110
  });
140
111
 
141
- // ────────────────────────────────────────────────────────────────
142
- // RequiredFiles
143
- // ────────────────────────────────────────────────────────────────
112
+ // ──────────────────────────────────────────────────────────────────
113
+ // RegisterDisplay
114
+ // ──────────────────────────────────────────────────────────────────
144
115
 
145
- describe('RequiredFiles', () => {
146
- it('should return a file manifest array', async () => {
147
- const files = await client.requiredFiles();
116
+ describe('RegisterDisplay', () => {
117
+ it('should register and return READY', async () => {
118
+ const result = await client.registerDisplay();
148
119
 
149
- expect(Array.isArray(files)).toBe(true);
150
- expect(files.length).toBeGreaterThan(0);
120
+ expect(result).toBeDefined();
121
+ expect(result.code).toBe('READY');
122
+ expect(result.settings).toBeDefined();
123
+ expect(result.message).toContain('Display is active');
151
124
  });
152
125
 
153
- it('should include media files with proper attributes', async () => {
154
- const files = await client.requiredFiles();
155
- const mediaFiles = files.filter(f => f.type === 'media');
156
-
157
- if (mediaFiles.length === 0) {
158
- console.warn('No media files in manifest — skipping');
159
- return;
160
- }
126
+ it('should include expected settings keys', async () => {
127
+ const result = await client.registerDisplay();
128
+ if (result.code !== 'READY') return;
161
129
 
162
- for (const file of mediaFiles) {
163
- expect(file.id).toBeDefined();
164
- expect(file.size).toBeGreaterThan(0);
165
- expect(file.md5).toBeDefined();
166
- expect(file.md5).toHaveLength(32);
167
- expect(file.path).toBeDefined();
130
+ for (const key of ['collectInterval', 'statsEnabled', 'xmrNetworkAddress']) {
131
+ expect(result.settings, `Missing setting: ${key}`).toHaveProperty(key);
168
132
  }
169
133
  });
134
+ });
170
135
 
171
- it('should include layout files', async () => {
172
- const files = await client.requiredFiles();
173
- const layoutFiles = files.filter(f => f.type === 'layout');
136
+ // ──────────────────────────────────────────────────────────────────
137
+ // RequiredFiles (media)
138
+ // ──────────────────────────────────────────────────────────────────
174
139
 
175
- expect(layoutFiles.length).toBeGreaterThan(0);
140
+ describe('RequiredFiles', () => {
141
+ it('should return files in flat format', async () => {
142
+ const result = await client.requiredFiles();
176
143
 
177
- for (const file of layoutFiles) {
178
- expect(file.id).toBeDefined();
179
- expect(file.size).toBeGreaterThan(0);
180
- expect(file.md5).toBeDefined();
181
- }
144
+ expect(result).toHaveProperty('files');
145
+ expect(result).toHaveProperty('purge');
146
+ expect(Array.isArray(result.files)).toBe(true);
147
+ expect(result.files.length).toBeGreaterThan(0);
182
148
  });
183
149
 
184
- it('should include resource files with layout/region/media IDs', async () => {
185
- const files = await client.requiredFiles();
186
- const resourceFiles = files.filter(f => f.type === 'resource');
187
-
188
- if (resourceFiles.length === 0) {
189
- console.warn('No resource files — skipping');
190
- return;
191
- }
150
+ it('should include media files with download URLs', async () => {
151
+ const { files } = await client.requiredFiles();
152
+ const media = files.filter(f => f.type === 'media');
192
153
 
193
- for (const file of resourceFiles) {
194
- expect(file.layoutid).toBeDefined();
195
- expect(file.regionid).toBeDefined();
196
- expect(file.mediaid).toBeDefined();
154
+ expect(media.length).toBeGreaterThan(0);
155
+ for (const file of media) {
156
+ expect(file.id).toBeDefined();
157
+ expect(file.md5).toBeDefined();
158
+ expect(file.path).toMatch(/^https?:\/\//);
197
159
  }
198
160
  });
199
161
 
200
- it('should support ETag caching (second call uses cache)', async () => {
201
- // First call populates cache
202
- const files1 = await client.requiredFiles();
203
- expect(files1.length).toBeGreaterThan(0);
204
-
205
- // Second call should get 304 + cached result
206
- const files2 = await client.requiredFiles();
207
- expect(files2.length).toBe(files1.length);
162
+ it('should include layout files', async () => {
163
+ const { files } = await client.requiredFiles();
164
+ const layouts = files.filter(f => f.type === 'layout');
208
165
 
209
- // Verify ETag was stored
210
- expect(client._etags.has('/requiredFiles')).toBe(true);
166
+ expect(layouts.length).toBeGreaterThan(0);
167
+ for (const layout of layouts) {
168
+ expect(layout.id).toBeDefined();
169
+ expect(layout.md5).toBeDefined();
170
+ }
211
171
  });
212
172
 
213
- it('should produce same file count as SOAP transport', async () => {
214
- // Clear cache for fair comparison
173
+ it('should support ETag caching', async () => {
215
174
  client._etags.clear();
216
175
  client._responseCache.clear();
217
176
 
218
- const restFiles = await client.requiredFiles();
219
- const soapFiles = await soapClient.requiredFiles();
220
-
221
- expect(restFiles.length).toBe(soapFiles.length);
222
-
223
- // Compare file IDs (order may differ)
224
- const restIds = restFiles.map(f => `${f.type}-${f.id}`).sort();
225
- const soapIds = soapFiles.map(f => `${f.type}-${f.id}`).sort();
226
- expect(restIds).toEqual(soapIds);
227
- });
228
-
229
- it('should include download URLs for media files', async () => {
230
- const files = await client.requiredFiles();
231
- const mediaFiles = files.filter(f => f.type === 'media');
177
+ await client.requiredFiles();
178
+ const hasEtag = client._etags.has(`/displays/${client._displayId}/media`);
179
+ expect(hasEtag).toBe(true);
232
180
 
233
- for (const file of mediaFiles) {
234
- expect(file.path).toBeDefined();
235
- // Path should be a valid URL
236
- expect(file.path).toMatch(/^https?:\/\//);
237
- }
181
+ // Second call should use cache
182
+ const result2 = await client.requiredFiles();
183
+ expect(result2.files.length).toBeGreaterThan(0);
238
184
  });
239
185
  });
240
186
 
241
- // ────────────────────────────────────────────────────────────────
187
+ // ──────────────────────────────────────────────────────────────────
242
188
  // Schedule
243
- // ────────────────────────────────────────────────────────────────
189
+ // ──────────────────────────────────────────────────────────────────
244
190
 
245
191
  describe('Schedule', () => {
246
- it('should return a schedule object', async () => {
192
+ it('should return native JSON schedule', async () => {
247
193
  const schedule = await client.schedule();
248
194
 
249
195
  expect(schedule).toBeDefined();
@@ -252,408 +198,126 @@ describe.skipIf(SKIP)('XMDS REST API — Live Integration', () => {
252
198
  expect(Array.isArray(schedule.layouts)).toBe(true);
253
199
  });
254
200
 
255
- it('should include layout entries with required attributes', async () => {
201
+ it('should include default layout', async () => {
256
202
  const schedule = await client.schedule();
257
-
258
- if (schedule.layouts.length === 0 && !schedule.default) {
259
- console.warn('Empty schedule — skipping');
260
- return;
261
- }
262
-
263
- for (const layout of schedule.layouts) {
264
- expect(layout.file).toBeDefined();
265
- expect(layout.fromdt).toBeDefined();
266
- expect(layout.todt).toBeDefined();
267
- expect(layout.scheduleid).toBeDefined();
268
- expect(layout.priority).toBeDefined();
269
- }
203
+ expect(schedule.default).toBeDefined();
270
204
  });
271
205
 
272
206
  it('should support ETag caching', async () => {
273
207
  client._etags.clear();
274
208
  client._responseCache.clear();
275
209
 
276
- const schedule1 = await client.schedule();
277
- const schedule2 = await client.schedule();
278
-
279
- // Both should return same data
280
- expect(schedule1.layouts.length).toBe(schedule2.layouts.length);
281
-
282
- // ETag should be cached
283
- expect(client._etags.has('/schedule')).toBe(true);
284
- });
285
-
286
- it('should match SOAP schedule layout count', async () => {
287
- client._etags.clear();
288
- client._responseCache.clear();
289
-
290
- const restSchedule = await client.schedule();
291
- const soapSchedule = await soapClient.schedule();
292
-
293
- expect(restSchedule.layouts.length).toBe(soapSchedule.layouts.length);
294
- });
295
-
296
- it('should include schedule metadata (default layout, dependents)', async () => {
297
- const schedule = await client.schedule();
298
-
299
- // Check layouts have dependents array
300
- for (const layout of schedule.layouts) {
301
- if (layout.dependents) {
302
- expect(Array.isArray(layout.dependents)).toBe(true);
303
- }
304
- }
210
+ await client.schedule();
211
+ expect(client._etags.has(`/displays/${client._displayId}/schedule`)).toBe(true);
305
212
  });
306
213
  });
307
214
 
308
- // ────────────────────────────────────────────────────────────────
309
- // GetResource
310
- // ────────────────────────────────────────────────────────────────
311
-
312
- describe('GetResource', () => {
313
- let resourceFiles;
215
+ // ──────────────────────────────────────────────────────────────────
216
+ // Reporting Endpoints
217
+ // ──────────────────────────────────────────────────────────────────
314
218
 
315
- beforeAll(async () => {
316
- const files = await client.requiredFiles();
317
- resourceFiles = files.filter(f => f.type === 'resource');
318
- });
319
-
320
- it('should fetch a widget resource as HTML', async () => {
321
- if (resourceFiles.length === 0) {
322
- console.warn('No resources to test — skipping');
323
- return;
324
- }
325
-
326
- const res = resourceFiles[0];
327
- const html = await client.getResource(res.layoutid, res.regionid, res.mediaid);
328
-
329
- expect(html).toBeDefined();
330
- expect(typeof html).toBe('string');
331
- expect(html.length).toBeGreaterThan(0);
332
- });
333
-
334
- it('should return valid HTML for all resource files', async () => {
335
- if (resourceFiles.length === 0) {
336
- console.warn('No resources — skipping');
337
- return;
338
- }
339
-
340
- // Test first 3 resources max to avoid slow test
341
- const toTest = resourceFiles.slice(0, 3);
342
-
343
- for (const res of toTest) {
344
- const html = await client.getResource(res.layoutid, res.regionid, res.mediaid);
345
- expect(html, `Resource ${res.mediaid} returned empty`).toBeDefined();
346
- expect(html.length, `Resource ${res.mediaid} is empty`).toBeGreaterThan(0);
347
- }
219
+ describe('NotifyStatus', () => {
220
+ it('should report status successfully', async () => {
221
+ const result = await client.notifyStatus({ currentLayoutId: 483 });
222
+ expect(result).toBeDefined();
223
+ expect(result.success).toBe(true);
348
224
  });
349
225
  });
350
226
 
351
- // ────────────────────────────────────────────────────────────────
352
- // SubmitLog
353
- // ────────────────────────────────────────────────────────────────
354
-
355
227
  describe('SubmitLog', () => {
356
- it('should submit a log entry successfully', async () => {
357
- const result = await client.submitLog([
358
- {
359
- date: new Date().toISOString(),
360
- category: 'General',
361
- type: 'info',
362
- message: 'Integration test log entry',
363
- },
364
- ]);
365
-
366
- expect(result).toBeDefined();
367
- // REST returns { success: true }
368
- if (typeof result === 'object') {
369
- expect(result.success).toBe(true);
370
- }
228
+ it('should submit logs as XML string', async () => {
229
+ const result = await client.submitLog(
230
+ '<logs><log date="2026-03-01" category="info" type="info" message="REST integration test" method="test" /></logs>'
231
+ );
232
+ expect(result).toBe(true);
371
233
  });
372
234
 
373
- it('should handle multiple log entries', async () => {
374
- const logs = Array.from({ length: 5 }, (_, i) => ({
375
- date: new Date().toISOString(),
376
- category: 'General',
377
- type: i === 0 ? 'error' : 'info',
378
- message: `Integration test log ${i + 1} of 5`,
379
- }));
380
-
381
- const result = await client.submitLog(logs);
382
-
383
- expect(result).toBeDefined();
384
- if (typeof result === 'object') {
385
- expect(result.success).toBe(true);
386
- }
235
+ it('should submit logs as array', async () => {
236
+ const result = await client.submitLog([
237
+ { date: new Date().toISOString(), category: 'General', type: 'info', message: 'REST array log test' },
238
+ ]);
239
+ expect(result).toBe(true);
387
240
  });
388
241
  });
389
242
 
390
- // ────────────────────────────────────────────────────────────────
391
- // SubmitStats
392
- // ────────────────────────────────────────────────────────────────
393
-
394
243
  describe('SubmitStats', () => {
395
- it('should submit proof-of-play stats successfully', async () => {
244
+ it('should submit proof-of-play stats', async () => {
396
245
  const now = new Date();
397
- const from = new Date(now - 60000);
398
-
399
- const result = await client.submitStats([
400
- {
401
- type: 'layout',
402
- fromDt: from.toISOString(),
403
- toDt: now.toISOString(),
404
- scheduleId: '0',
405
- layoutId: '1',
406
- mediaId: '',
407
- tag: 'integration-test',
408
- },
409
- ]);
410
-
411
- expect(result).toBeDefined();
412
- if (typeof result === 'object') {
413
- expect(result.success).toBe(true);
414
- }
246
+ const result = await client.submitStats([{
247
+ type: 'layout',
248
+ fromDt: new Date(now - 60000).toISOString(),
249
+ toDt: now.toISOString(),
250
+ scheduleId: '0',
251
+ layoutId: '483',
252
+ mediaId: '',
253
+ tag: 'rest-integration-test',
254
+ }]);
255
+ expect(result).toBe(true);
415
256
  });
416
257
  });
417
258
 
418
- // ────────────────────────────────────────────────────────────────
419
- // MediaInventory
420
- // ────────────────────────────────────────────────────────────────
421
-
422
259
  describe('MediaInventory', () => {
423
- it('should submit media inventory successfully', async () => {
260
+ it('should submit inventory as array', async () => {
424
261
  const result = await client.mediaInventory([
425
- { id: '1', complete: '1', md5: 'abc123', lastChecked: new Date().toISOString() },
262
+ { id: '1', complete: '1', md5: 'abf73257821e2cf601d299c509726c03', lastChecked: new Date().toISOString() },
426
263
  ]);
427
-
428
264
  expect(result).toBeDefined();
429
- if (typeof result === 'object') {
430
- expect(result.success).toBe(true);
431
- }
265
+ expect(result.success).toBe(true);
432
266
  });
433
267
  });
434
268
 
435
- // ────────────────────────────────────────────────────────────────
436
- // Full Workflow: Complete Player Boot Sequence
437
- // ────────────────────────────────────────────────────────────────
269
+ // ──────────────────────────────────────────────────────────────────
270
+ // Full Boot Sequence
271
+ // ──────────────────────────────────────────────────────────────────
438
272
 
439
- describe('Full Player Boot Workflow', () => {
440
- it('should execute the complete player boot sequence via REST', async () => {
441
- // Fresh client with no cache
273
+ describe('Full Player Boot Sequence', () => {
274
+ it('should execute the complete boot sequence', async () => {
442
275
  const bootClient = new RestClient({
443
276
  cmsUrl: CMS_URL,
444
277
  cmsKey: CMS_KEY,
445
278
  hardwareKey: HARDWARE_KEY,
446
279
  displayName: 'Boot Sequence Test',
447
- xmrChannel: 'boot-test-channel',
280
+ xmrChannel: 'boot-test',
448
281
  retryOptions: { maxRetries: 1, baseDelayMs: 500 },
449
282
  });
450
283
 
451
- // Step 1: Register
452
- const registration = await bootClient.registerDisplay();
453
- expect(registration).toBeDefined();
454
- expect(registration.code).toBeDefined();
455
- console.log(` Register: ${registration.code}`);
456
-
457
- if (registration.code !== 'READY') {
458
- console.warn(' Display not authorized — cannot complete boot sequence');
459
- return;
460
- }
284
+ // 1. Register
285
+ const reg = await bootClient.registerDisplay();
286
+ expect(reg.code).toBe('READY');
461
287
 
462
- // Step 2: RequiredFiles
463
- const files = await bootClient.requiredFiles();
288
+ // 2. RequiredFiles
289
+ const { files } = await bootClient.requiredFiles();
464
290
  expect(files.length).toBeGreaterThan(0);
465
- const mediaCount = files.filter(f => f.type === 'media').length;
466
- const layoutCount = files.filter(f => f.type === 'layout').length;
467
- const resourceCount = files.filter(f => f.type === 'resource').length;
468
- console.log(` RequiredFiles: ${files.length} total (${mediaCount} media, ${layoutCount} layouts, ${resourceCount} resources)`);
469
291
 
470
- // Step 3: Schedule
292
+ // 3. Schedule
471
293
  const schedule = await bootClient.schedule();
472
294
  expect(schedule).toBeDefined();
473
- console.log(` Schedule: ${schedule.layouts.length} layouts, ${schedule.overlays.length} overlays`);
474
-
475
- // Step 4: GetResource (for first resource if available)
476
- const resources = files.filter(f => f.type === 'resource');
477
- if (resources.length > 0) {
478
- const res = resources[0];
479
- const html = await bootClient.getResource(res.layoutid, res.regionid, res.mediaid);
480
- expect(html).toBeDefined();
481
- console.log(` GetResource: ${html.length} chars for widget ${res.mediaid}`);
482
- }
483
295
 
484
- // Step 5: Submit log
485
- const logResult = await bootClient.submitLog([{
486
- date: new Date().toISOString(),
487
- category: 'General',
488
- type: 'info',
489
- message: 'Player boot sequence completed via REST API',
296
+ // 4. Status
297
+ const status = await bootClient.notifyStatus({ currentLayoutId: schedule.default || 0 });
298
+ expect(status.success).toBe(true);
299
+
300
+ // 5. Log
301
+ const logOk = await bootClient.submitLog([{
302
+ date: new Date().toISOString(), category: 'General', type: 'info',
303
+ message: 'REST boot sequence complete',
490
304
  }]);
491
- expect(logResult).toBeDefined();
492
- console.log(` SubmitLog: OK`);
305
+ expect(logOk).toBe(true);
493
306
 
494
- // Step 6: Submit stats
307
+ // 6. Stats
495
308
  const now = new Date();
496
- const statsResult = await bootClient.submitStats([{
497
- type: 'layout',
498
- fromDt: new Date(now - 10000).toISOString(),
499
- toDt: now.toISOString(),
500
- scheduleId: '0',
501
- layoutId: String(schedule.layouts[0]?.file || '0'),
502
- mediaId: '',
503
- tag: 'integration-test-boot',
309
+ const statsOk = await bootClient.submitStats([{
310
+ type: 'layout', fromDt: new Date(now - 10000).toISOString(),
311
+ toDt: now.toISOString(), scheduleId: '0',
312
+ layoutId: String(schedule.default || '0'), mediaId: '', tag: 'boot',
504
313
  }]);
505
- expect(statsResult).toBeDefined();
506
- console.log(` SubmitStats: OK`);
507
-
508
- // Step 7: MediaInventory
509
- const inventory = files.filter(f => f.type === 'media').slice(0, 5).map(f => ({
510
- id: f.id,
511
- complete: '1',
512
- md5: f.md5,
513
- lastChecked: now.toISOString(),
514
- }));
515
- if (inventory.length > 0) {
516
- const invResult = await bootClient.mediaInventory(inventory);
517
- expect(invResult).toBeDefined();
518
- console.log(` MediaInventory: ${inventory.length} items reported`);
519
- }
520
-
521
- console.log(' Boot sequence: COMPLETE');
522
- });
523
- });
524
-
525
- // ────────────────────────────────────────────────────────────────
526
- // REST vs SOAP Parity
527
- // ────────────────────────────────────────────────────────────────
528
-
529
- describe('REST ↔ SOAP Transport Parity', () => {
530
- it('should expose the same business methods as SOAP transport', () => {
531
- // Compare only business-level API methods, not transport-specific helpers
532
- // (REST has restGet/restSend, SOAP has buildEnvelope/call/parseResponse, etc.)
533
- const businessMethods = [
534
- 'blackList', 'getResource', 'mediaInventory', 'notifyStatus',
535
- 'registerDisplay', 'requiredFiles', 'schedule',
536
- 'submitLog', 'submitScreenShot', 'submitStats',
537
- ].sort();
538
-
539
- const restMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(client))
540
- .filter(m => !m.startsWith('_') && m !== 'constructor')
541
- .sort();
542
- const soapMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(soapClient))
543
- .filter(m => !m.startsWith('_') && m !== 'constructor')
544
- .sort();
545
-
546
- for (const method of businessMethods) {
547
- expect(restMethods).toContain(method);
548
- expect(soapMethods).toContain(method);
549
- }
550
- });
551
-
552
- it('should return same requiredFiles file types and counts', async () => {
553
- client._etags.clear();
554
- client._responseCache.clear();
555
-
556
- const restFiles = await client.requiredFiles();
557
- const soapFiles = await soapClient.requiredFiles();
558
-
559
- const restTypes = {};
560
- for (const f of restFiles) {
561
- restTypes[f.type] = (restTypes[f.type] || 0) + 1;
562
- }
563
-
564
- const soapTypes = {};
565
- for (const f of soapFiles) {
566
- soapTypes[f.type] = (soapTypes[f.type] || 0) + 1;
567
- }
568
-
569
- expect(restTypes).toEqual(soapTypes);
570
- });
571
-
572
- it('should return same schedule layout IDs', async () => {
573
- client._etags.clear();
574
- client._responseCache.clear();
575
-
576
- const restSchedule = await client.schedule();
577
- const soapSchedule = await soapClient.schedule();
578
-
579
- const restIds = restSchedule.layouts.map(l => l.file).sort();
580
- const soapIds = soapSchedule.layouts.map(l => l.file).sort();
581
-
582
- expect(restIds).toEqual(soapIds);
583
- });
584
- });
585
-
586
- // ────────────────────────────────────────────────────────────────
587
- // Error Handling
588
- // ────────────────────────────────────────────────────────────────
589
-
590
- describe('Error Handling', () => {
591
- it('should handle invalid hardware key gracefully', async () => {
592
- const badClient = new RestClient({
593
- cmsUrl: CMS_URL,
594
- cmsKey: CMS_KEY,
595
- hardwareKey: 'nonexistent-display',
596
- displayName: 'Bad Display',
597
- xmrChannel: 'test',
598
- retryOptions: { maxRetries: 0 },
599
- });
600
-
601
- try {
602
- const result = await badClient.requiredFiles();
603
- // Some endpoints may return empty rather than error
604
- expect(Array.isArray(result)).toBe(true);
605
- } catch (e) {
606
- // Expected — invalid display
607
- expect(e.message).toBeDefined();
608
- }
609
- });
610
-
611
- it('should handle unreachable CMS', async () => {
612
- const badClient = new RestClient({
613
- cmsUrl: 'https://nonexistent.example.com',
614
- cmsKey: 'test',
615
- hardwareKey: 'test',
616
- displayName: 'Unreachable',
617
- xmrChannel: 'test',
618
- retryOptions: { maxRetries: 0 },
619
- });
620
-
621
- await expect(badClient.registerDisplay()).rejects.toThrow();
622
- });
623
- });
624
-
625
- // ────────────────────────────────────────────────────────────────
626
- // Performance & Caching
627
- // ────────────────────────────────────────────────────────────────
628
-
629
- describe('Performance & Caching', () => {
630
- it('should cache requiredFiles ETag across calls', async () => {
631
- client._etags.clear();
632
- client._responseCache.clear();
633
-
634
- // Call 1: fresh fetch
635
- const t1 = Date.now();
636
- await client.requiredFiles();
637
- const firstDuration = Date.now() - t1;
638
-
639
- // Call 2: should use 304 cache
640
- const t2 = Date.now();
641
- await client.requiredFiles();
642
- const secondDuration = Date.now() - t2;
643
-
644
- console.log(` First fetch: ${firstDuration}ms, Cached fetch: ${secondDuration}ms`);
645
-
646
- expect(client._etags.has('/requiredFiles')).toBe(true);
647
- });
648
-
649
- it('should cache schedule ETag across calls', async () => {
650
- client._etags.clear();
651
- client._responseCache.clear();
652
-
653
- await client.schedule();
654
- await client.schedule();
314
+ expect(statsOk).toBe(true);
655
315
 
656
- expect(client._etags.has('/schedule')).toBe(true);
316
+ // 7. Inventory
317
+ const inv = await bootClient.mediaInventory(files.filter(f => f.type === 'media').slice(0, 5).map(f => ({
318
+ id: f.id, complete: '1', md5: f.md5 || '', lastChecked: now.toISOString(),
319
+ })));
320
+ expect(inv.success).toBe(true);
657
321
  });
658
322
  });
659
323
  });