@xiboplayer/xmds 0.3.7 → 0.4.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.
package/README.md CHANGED
@@ -43,9 +43,9 @@ const schedule = await client.schedule();
43
43
  | `getResource(regionId, mediaId)` | Get rendered widget HTML |
44
44
  | `notifyStatus(status)` | Report display status to CMS |
45
45
  | `mediaInventory(inventory)` | Report cached media inventory |
46
- | `submitStats(stats)` | Submit proof of play statistics |
46
+ | `submitStats(stats, hardwareKeyOverride?)` | Submit proof of play statistics (optional `hardwareKeyOverride` for delegated submissions on behalf of another display) |
47
47
  | `submitScreenShot(base64)` | Upload a screenshot to the CMS |
48
- | `submitLog(logs)` | Submit display logs |
48
+ | `submitLog(logs, hardwareKeyOverride?)` | Submit display logs (optional `hardwareKeyOverride` for delegated submissions on behalf of another display) |
49
49
 
50
50
  ## Dependencies
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/xmds",
3
- "version": "0.3.7",
3
+ "version": "0.4.1",
4
4
  "description": "XMDS SOAP client for Xibo CMS communication",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -9,7 +9,7 @@
9
9
  "./xmds": "./src/xmds.js"
10
10
  },
11
11
  "dependencies": {
12
- "@xiboplayer/utils": "0.3.7"
12
+ "@xiboplayer/utils": "0.4.1"
13
13
  },
14
14
  "devDependencies": {
15
15
  "vitest": "^2.0.0"
@@ -35,6 +35,32 @@ export class RestClient {
35
35
  return base.replace(/\/+$/, '');
36
36
  }
37
37
 
38
+ /**
39
+ * Check if running behind the local proxy (Electron or Chromium kiosk).
40
+ */
41
+ _isProxyMode() {
42
+ return typeof window !== 'undefined' &&
43
+ (window.electronAPI?.isElectron ||
44
+ (window.location.hostname === 'localhost' && window.location.port === '8765'));
45
+ }
46
+
47
+ /**
48
+ * Rewrite an absolute REST URL to go through /rest-proxy.
49
+ * Preserves all query params from the original URL.
50
+ */
51
+ _rewriteForProxy(urlString) {
52
+ if (!this._isProxyMode() || !urlString.startsWith('http')) return urlString;
53
+ const parsed = new URL(urlString);
54
+ const proxyUrl = new URL('/rest-proxy', window.location.origin);
55
+ proxyUrl.searchParams.set('cms', parsed.origin);
56
+ proxyUrl.searchParams.set('path', parsed.pathname);
57
+ // Forward all original query params
58
+ for (const [key, value] of parsed.searchParams) {
59
+ proxyUrl.searchParams.set(key, value);
60
+ }
61
+ return proxyUrl.toString();
62
+ }
63
+
38
64
  /**
39
65
  * Make a REST GET request with optional ETag caching.
40
66
  * Returns the parsed JSON body, or cached data on 304.
@@ -57,7 +83,7 @@ export class RestClient {
57
83
 
58
84
  log.debug(`GET ${path}`, queryParams);
59
85
 
60
- const response = await fetchWithRetry(url.toString(), {
86
+ const response = await fetchWithRetry(this._rewriteForProxy(url.toString()), {
61
87
  method: 'GET',
62
88
  headers,
63
89
  }, this.retryOptions);
@@ -107,7 +133,7 @@ export class RestClient {
107
133
 
108
134
  log.debug(`${method} ${path}`);
109
135
 
110
- const response = await fetchWithRetry(url.toString(), {
136
+ const response = await fetchWithRetry(this._rewriteForProxy(url.toString()), {
111
137
  method,
112
138
  headers: { 'Content-Type': 'application/json' },
113
139
  body: JSON.stringify({
@@ -142,13 +168,14 @@ export class RestClient {
142
168
 
143
169
  const json = await this.restSend('POST', '/register', {
144
170
  displayName: this.config.displayName,
145
- clientType: 'chromeOS',
146
- clientVersion: '0.1.0',
147
- clientCode: 1,
171
+ clientType: this.config.clientType || 'chromeOS',
172
+ clientVersion: this.config.clientVersion || '0.1.0',
173
+ clientCode: this.config.clientCode || 1,
148
174
  operatingSystem: os,
149
175
  macAddress: this.config.macAddress || 'n/a',
150
176
  xmrChannel: this.config.xmrChannel,
151
177
  xmrPubKey: this.config.xmrPubKey || '',
178
+ licenceResult: 'licensed',
152
179
  });
153
180
 
154
181
  return this._parseRegisterDisplayJson(json);
@@ -169,14 +196,53 @@ export class RestClient {
169
196
  }
170
197
 
171
198
  const settings = {};
199
+ let tags = [];
200
+ let commands = [];
172
201
  for (const [key, value] of Object.entries(display)) {
173
- if (key === '@attributes' || key === 'commands' || key === 'file') continue;
202
+ if (key === '@attributes' || key === 'file') continue;
203
+ if (key === 'commands') {
204
+ // Parse commands: array of {code/commandCode, commandString} objects
205
+ if (Array.isArray(value)) {
206
+ commands = value.map(c => ({
207
+ commandCode: c.code || c.commandCode || '',
208
+ commandString: c.commandString || ''
209
+ }));
210
+ }
211
+ continue;
212
+ }
213
+ if (key === 'tags') {
214
+ // Parse tags from CMS JSON (SimpleXMLElement serialization varies):
215
+ // Array of strings: ["geoApiKey|AIzaSy..."]
216
+ // Array of objects: [{tag: "geoApiKey|AIzaSy..."}]
217
+ // Single-tag object: {tag: "geoApiKey|AIzaSy..."} (SimpleXMLElement collapses single-element arrays)
218
+ // String: "geoApiKey|AIzaSy..."
219
+ const extractTag = (t) => typeof t === 'object' ? (t.tag || t.value || '') : String(t);
220
+ if (Array.isArray(value)) {
221
+ tags = value.map(extractTag).filter(Boolean);
222
+ } else if (value && typeof value === 'object') {
223
+ // Single tag: {tag: "value"} — wrap in array
224
+ const t = extractTag(value);
225
+ if (t) tags = [t];
226
+ } else if (typeof value === 'string' && value) {
227
+ tags = [value];
228
+ }
229
+ continue;
230
+ }
174
231
  settings[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
175
232
  }
176
233
 
177
234
  const checkRf = attrs.checkRf || '';
178
235
  const checkSchedule = attrs.checkSchedule || '';
179
236
 
237
+ // Extract display-level attributes from CMS (server time, status, version info)
238
+ const displayAttrs = {
239
+ date: attrs.date || display.date || null,
240
+ timezone: attrs.timezone || display.timezone || null,
241
+ status: attrs.status || display.status || null,
242
+ localDate: attrs.localDate || display.localDate || null,
243
+ version_instructions: attrs.version_instructions || display.version_instructions || null,
244
+ };
245
+
180
246
  // Extract sync group config if present (multi-display sync coordination)
181
247
  // syncGroup: "lead" if this display is leader, or leader's LAN IP if follower
182
248
  const syncConfig = display.syncGroup ? {
@@ -187,7 +253,7 @@ export class RestClient {
187
253
  isLead: String(display.syncGroup) === 'lead',
188
254
  } : null;
189
255
 
190
- return { code, message, settings, checkRf, checkSchedule, syncConfig };
256
+ return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
191
257
  }
192
258
 
193
259
  /**
@@ -221,6 +287,8 @@ export class RestClient {
221
287
  md5: attrs.md5 || null,
222
288
  download: attrs.download || null,
223
289
  path,
290
+ saveAs: attrs.saveAs || null,
291
+ fileType: attrs.fileType || null,
224
292
  code: attrs.code || null,
225
293
  layoutid: attrs.layoutid || null,
226
294
  regionid: attrs.regionid || null,
@@ -228,7 +296,19 @@ export class RestClient {
228
296
  });
229
297
  }
230
298
 
231
- return files;
299
+ // Parse purge items — files CMS wants the player to delete
300
+ const purgeItems = [];
301
+ let purgeList = json.purge?.item || [];
302
+ if (!Array.isArray(purgeList)) purgeList = [purgeList];
303
+ for (const p of purgeList) {
304
+ const pAttrs = p['@attributes'] || p;
305
+ purgeItems.push({
306
+ id: pAttrs.id || null,
307
+ storedAs: pAttrs.storedAs || null,
308
+ });
309
+ }
310
+
311
+ return { files, purge: purgeItems };
232
312
  }
233
313
 
234
314
  /**
@@ -272,6 +352,11 @@ export class RestClient {
272
352
  status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
273
353
  }
274
354
 
355
+ // Add statusDialog (summary for CMS display status page) if not provided
356
+ if (!status.statusDialog) {
357
+ status.statusDialog = `Current Layout: ${status.currentLayoutId || 'None'}`;
358
+ }
359
+
275
360
  return this.restSend('PUT', '/status', {
276
361
  statusData: status,
277
362
  });
@@ -291,22 +376,35 @@ export class RestClient {
291
376
 
292
377
  /**
293
378
  * BlackList - report broken media to CMS
294
- *
295
- * BlackList has no REST equivalent endpoint.
296
- * Log a warning and return false.
379
+ * POST /blacklist → JSON acknowledgement
380
+ * @param {string|number} mediaId - The media ID
381
+ * @param {string} type - File type ('media' or 'layout')
382
+ * @param {string} reason - Reason for blacklisting
383
+ * @returns {Promise<boolean>}
297
384
  */
298
385
  async blackList(mediaId, type, reason) {
299
- log.warn(`BlackList not available via REST (${type}/${mediaId}: ${reason})`);
300
- return false;
386
+ try {
387
+ const result = await this.restSend('POST', '/blacklist', {
388
+ mediaId: String(mediaId),
389
+ type: type || 'media',
390
+ reason: reason || 'Failed to render',
391
+ });
392
+ log.info(`BlackListed ${type}/${mediaId}: ${reason}`);
393
+ return result?.success === true;
394
+ } catch (error) {
395
+ log.warn('BlackList failed:', error);
396
+ return false;
397
+ }
301
398
  }
302
399
 
303
400
  /**
304
401
  * SubmitLog - submit player logs to CMS
305
402
  * POST /log → JSON acknowledgement
306
403
  */
307
- async submitLog(logXml) {
404
+ async submitLog(logXml, hardwareKeyOverride = null) {
308
405
  // Accept array (JSON-native) or string (XML) — send under the right key
309
406
  const body = Array.isArray(logXml) ? { logs: logXml } : { logXml };
407
+ if (hardwareKeyOverride) body.hardwareKey = hardwareKeyOverride;
310
408
  const result = await this.restSend('POST', '/log', body);
311
409
  return result?.success === true;
312
410
  }
@@ -337,10 +435,20 @@ export class RestClient {
337
435
  return result?.success === true;
338
436
  }
339
437
 
340
- async submitStats(statsXml) {
438
+ /**
439
+ * GetWeather - get current weather data for schedule criteria
440
+ * GET /weather → JSON weather data
441
+ * @returns {Promise<Object>} Weather data from CMS
442
+ */
443
+ async getWeather() {
444
+ return this.restGet('/weather');
445
+ }
446
+
447
+ async submitStats(statsXml, hardwareKeyOverride = null) {
341
448
  try {
342
449
  // Accept array (JSON-native) or string (XML) — send under the right key
343
450
  const body = Array.isArray(statsXml) ? { stats: statsXml } : { statXml: statsXml };
451
+ if (hardwareKeyOverride) body.hardwareKey = hardwareKeyOverride;
344
452
  const result = await this.restSend('POST', '/stats', body);
345
453
  const success = result?.success === true;
346
454
  log.info(`SubmitStats result: ${success}`);
@@ -16,12 +16,13 @@
16
16
  */
17
17
  function parseCriteria(parentEl) {
18
18
  const criteria = [];
19
- for (const criteriaEl of parentEl.querySelectorAll(':scope > criteria')) {
19
+ for (const child of parentEl.children) {
20
+ if (child.tagName !== 'criteria') continue;
20
21
  criteria.push({
21
- metric: criteriaEl.getAttribute('metric') || '',
22
- condition: criteriaEl.getAttribute('condition') || '',
23
- type: criteriaEl.getAttribute('type') || 'string',
24
- value: criteriaEl.textContent || ''
22
+ metric: child.getAttribute('metric') || '',
23
+ condition: child.getAttribute('condition') || '',
24
+ type: child.getAttribute('type') || 'string',
25
+ value: child.textContent || ''
25
26
  });
26
27
  }
27
28
  return criteria;
@@ -39,6 +40,8 @@ export function parseScheduleResponse(xml) {
39
40
 
40
41
  const schedule = {
41
42
  default: null,
43
+ defaultDependants: [],
44
+ dependants: [], // Global dependants that gate ALL layouts
42
45
  layouts: [],
43
46
  campaigns: [],
44
47
  overlays: [],
@@ -47,9 +50,29 @@ export function parseScheduleResponse(xml) {
47
50
  dataConnectors: []
48
51
  };
49
52
 
53
+ // Parse global dependants (root-level <dependants> — must be cached before any layout plays)
54
+ const scheduleEl = doc.querySelector('schedule');
55
+ if (scheduleEl) {
56
+ const globalDeps = Array.from(scheduleEl.children).filter(
57
+ el => el.tagName === 'dependants'
58
+ );
59
+ for (const depContainer of globalDeps) {
60
+ // Skip if this is nested inside <default>, <layout>, etc.
61
+ if (depContainer.parentElement !== scheduleEl) continue;
62
+ for (const fileEl of depContainer.querySelectorAll('file')) {
63
+ if (fileEl.textContent) schedule.dependants.push(fileEl.textContent);
64
+ }
65
+ }
66
+ }
67
+
50
68
  const defaultEl = doc.querySelector('default');
51
69
  if (defaultEl) {
52
70
  schedule.default = defaultEl.getAttribute('file');
71
+ // Parse dependants — files that must be cached before this layout plays
72
+ const defaultDeps = defaultEl.querySelectorAll('dependants > file');
73
+ if (defaultDeps.length > 0) {
74
+ schedule.defaultDependants = [...defaultDeps].map(el => el.textContent);
75
+ }
53
76
  }
54
77
 
55
78
  // Parse campaigns (groups of layouts with shared priority)
@@ -60,12 +83,23 @@ export function parseScheduleResponse(xml) {
60
83
  fromdt: campaignEl.getAttribute('fromdt'),
61
84
  todt: campaignEl.getAttribute('todt'),
62
85
  scheduleid: campaignEl.getAttribute('scheduleid'),
86
+ maxPlaysPerHour: parseInt(campaignEl.getAttribute('maxPlaysPerHour') || '0'),
87
+ shareOfVoice: parseInt(campaignEl.getAttribute('shareOfVoice') || '0'),
88
+ isGeoAware: campaignEl.getAttribute('isGeoAware') === '1',
89
+ geoLocation: campaignEl.getAttribute('geoLocation') || '',
90
+ syncEvent: campaignEl.getAttribute('syncEvent') === '1',
91
+ recurrenceType: campaignEl.getAttribute('recurrenceType') || null,
92
+ recurrenceDetail: parseInt(campaignEl.getAttribute('recurrenceDetail') || '0') || null,
93
+ recurrenceRepeatsOn: campaignEl.getAttribute('recurrenceRepeatsOn') || null,
94
+ recurrenceRange: campaignEl.getAttribute('recurrenceRange') || null,
95
+ criteria: parseCriteria(campaignEl),
63
96
  layouts: []
64
97
  };
65
98
 
66
99
  // Parse layouts within this campaign
67
100
  for (const layoutEl of campaignEl.querySelectorAll('layout')) {
68
101
  const fileId = layoutEl.getAttribute('file');
102
+ const depEls = layoutEl.querySelectorAll('dependants > file');
69
103
  campaign.layouts.push({
70
104
  id: String(fileId), // Normalized string ID for consistent type usage
71
105
  file: fileId,
@@ -80,6 +114,11 @@ export function parseScheduleResponse(xml) {
80
114
  geoLocation: layoutEl.getAttribute('geoLocation') || '',
81
115
  syncEvent: layoutEl.getAttribute('syncEvent') === '1',
82
116
  shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
117
+ duration: parseInt(layoutEl.getAttribute('duration') || '0'),
118
+ cyclePlayback: layoutEl.getAttribute('cyclePlayback') === '1',
119
+ groupKey: layoutEl.getAttribute('groupKey') || null,
120
+ playCount: parseInt(layoutEl.getAttribute('playCount') || '0'),
121
+ dependants: depEls.length > 0 ? [...depEls].map(el => el.textContent) : [],
83
122
  criteria: parseCriteria(layoutEl)
84
123
  });
85
124
  }
@@ -90,6 +129,7 @@ export function parseScheduleResponse(xml) {
90
129
  // Parse standalone layouts (not in campaigns)
91
130
  for (const layoutEl of doc.querySelectorAll('schedule > layout')) {
92
131
  const fileId = layoutEl.getAttribute('file');
132
+ const depEls = layoutEl.querySelectorAll('dependants > file');
93
133
  schedule.layouts.push({
94
134
  id: String(fileId), // Normalized string ID for consistent type usage
95
135
  file: fileId,
@@ -103,6 +143,15 @@ export function parseScheduleResponse(xml) {
103
143
  geoLocation: layoutEl.getAttribute('geoLocation') || '',
104
144
  syncEvent: layoutEl.getAttribute('syncEvent') === '1',
105
145
  shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
146
+ duration: parseInt(layoutEl.getAttribute('duration') || '0'),
147
+ cyclePlayback: layoutEl.getAttribute('cyclePlayback') === '1',
148
+ groupKey: layoutEl.getAttribute('groupKey') || null,
149
+ playCount: parseInt(layoutEl.getAttribute('playCount') || '0'),
150
+ recurrenceType: layoutEl.getAttribute('recurrenceType') || null,
151
+ recurrenceDetail: parseInt(layoutEl.getAttribute('recurrenceDetail') || '0') || null,
152
+ recurrenceRepeatsOn: layoutEl.getAttribute('recurrenceRepeatsOn') || null,
153
+ recurrenceRange: layoutEl.getAttribute('recurrenceRange') || null,
154
+ dependants: depEls.length > 0 ? [...depEls].map(el => el.textContent) : [],
106
155
  criteria: parseCriteria(layoutEl)
107
156
  });
108
157
  }
@@ -116,14 +165,18 @@ export function parseScheduleResponse(xml) {
116
165
  id: String(fileId), // Normalized string ID for consistent type usage
117
166
  duration: parseInt(overlayEl.getAttribute('duration') || '60'),
118
167
  file: fileId,
119
- fromDt: overlayEl.getAttribute('fromdt'),
120
- toDt: overlayEl.getAttribute('todt'),
168
+ fromdt: overlayEl.getAttribute('fromdt'),
169
+ todt: overlayEl.getAttribute('todt'),
121
170
  priority: parseInt(overlayEl.getAttribute('priority') || '0'),
122
- scheduleId: overlayEl.getAttribute('scheduleid'),
171
+ scheduleid: overlayEl.getAttribute('scheduleid'),
123
172
  isGeoAware: overlayEl.getAttribute('isGeoAware') === '1',
124
173
  geoLocation: overlayEl.getAttribute('geoLocation') || '',
125
174
  syncEvent: overlayEl.getAttribute('syncEvent') === '1',
126
175
  maxPlaysPerHour: parseInt(overlayEl.getAttribute('maxPlaysPerHour') || '0'),
176
+ recurrenceType: overlayEl.getAttribute('recurrenceType') || null,
177
+ recurrenceDetail: parseInt(overlayEl.getAttribute('recurrenceDetail') || '0') || null,
178
+ recurrenceRepeatsOn: overlayEl.getAttribute('recurrenceRepeatsOn') || null,
179
+ recurrenceRange: overlayEl.getAttribute('recurrenceRange') || null,
127
180
  criteria: parseCriteria(overlayEl)
128
181
  });
129
182
  }
@@ -139,10 +192,10 @@ export function parseScheduleResponse(xml) {
139
192
  layoutCode: actionEl.getAttribute('layoutCode') || '',
140
193
  commandCode: actionEl.getAttribute('commandCode') || '',
141
194
  duration: parseInt(actionEl.getAttribute('duration') || '0'),
142
- fromDt: actionEl.getAttribute('fromdt'),
143
- toDt: actionEl.getAttribute('todt'),
195
+ fromdt: actionEl.getAttribute('fromdt'),
196
+ todt: actionEl.getAttribute('todt'),
144
197
  priority: parseInt(actionEl.getAttribute('priority') || '0'),
145
- scheduleId: actionEl.getAttribute('scheduleid'),
198
+ scheduleid: actionEl.getAttribute('scheduleid'),
146
199
  isGeoAware: actionEl.getAttribute('isGeoAware') === '1',
147
200
  geoLocation: actionEl.getAttribute('geoLocation') || ''
148
201
  });
@@ -152,20 +205,27 @@ export function parseScheduleResponse(xml) {
152
205
  // Parse server commands (remote control)
153
206
  for (const cmdEl of doc.querySelectorAll('schedule > command')) {
154
207
  schedule.commands.push({
155
- code: cmdEl.getAttribute('command') || '',
208
+ code: cmdEl.getAttribute('code') || '',
156
209
  date: cmdEl.getAttribute('date') || ''
157
210
  });
158
211
  }
159
212
 
160
213
  // Parse data connectors (real-time data sources for widgets)
161
- for (const dcEl of doc.querySelectorAll('dataconnector')) {
162
- schedule.dataConnectors.push({
163
- id: dcEl.getAttribute('id') || '',
164
- dataConnectorId: dcEl.getAttribute('dataConnectorId') || '',
165
- dataKey: dcEl.getAttribute('dataKey') || '',
166
- url: dcEl.getAttribute('url') || '',
167
- updateInterval: parseInt(dcEl.getAttribute('updateInterval') || '300', 10)
168
- });
214
+ // Spec: <dataConnectors><connector dataSetId="" dataParams="" js=""/></dataConnectors>
215
+ const dataConnectorsContainer = doc.querySelector('dataConnectors');
216
+ if (dataConnectorsContainer) {
217
+ for (const dcEl of dataConnectorsContainer.querySelectorAll('connector')) {
218
+ schedule.dataConnectors.push({
219
+ id: dcEl.getAttribute('id') || '',
220
+ dataConnectorId: dcEl.getAttribute('dataConnectorId') || '',
221
+ dataSetId: dcEl.getAttribute('dataSetId') || '',
222
+ dataKey: dcEl.getAttribute('dataKey') || '',
223
+ dataParams: dcEl.getAttribute('dataParams') || '',
224
+ js: dcEl.getAttribute('js') || '',
225
+ url: dcEl.getAttribute('url') || '',
226
+ updateInterval: parseInt(dcEl.getAttribute('updateInterval') || '300', 10)
227
+ });
228
+ }
169
229
  }
170
230
 
171
231
  return schedule;
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Schedule Parser Tests
3
+ *
4
+ * Tests for XML parsing of schedule responses, ensuring all attributes
5
+ * are correctly extracted — especially recurrence/dayparting fields.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import { parseScheduleResponse } from './schedule-parser.js';
10
+
11
+ describe('parseScheduleResponse', () => {
12
+ it('should parse default layout', () => {
13
+ const xml = '<schedule><default file="100.xlf"/></schedule>';
14
+ const result = parseScheduleResponse(xml);
15
+ expect(result.default).toBe('100.xlf');
16
+ });
17
+
18
+ it('should parse standalone layout with basic attributes', () => {
19
+ const xml = `<schedule>
20
+ <layout file="200.xlf" fromdt="2025-01-01 09:00:00" todt="2025-12-31 17:00:00"
21
+ scheduleid="5" priority="3"/>
22
+ </schedule>`;
23
+ const result = parseScheduleResponse(xml);
24
+ expect(result.layouts).toHaveLength(1);
25
+ expect(result.layouts[0].file).toBe('200.xlf');
26
+ expect(result.layouts[0].priority).toBe(3);
27
+ expect(result.layouts[0].scheduleid).toBe('5');
28
+ });
29
+
30
+ it('should parse recurrence attributes on standalone layouts', () => {
31
+ const xml = `<schedule>
32
+ <layout file="300.xlf" fromdt="2025-01-06 09:00:00" todt="2025-01-06 17:00:00"
33
+ scheduleid="10" priority="1"
34
+ recurrenceType="Week"
35
+ recurrenceRepeatsOn="1,2,3,4,5"
36
+ recurrenceRange="2025-12-31 23:59:59"/>
37
+ </schedule>`;
38
+ const result = parseScheduleResponse(xml);
39
+ const layout = result.layouts[0];
40
+
41
+ expect(layout.recurrenceType).toBe('Week');
42
+ expect(layout.recurrenceRepeatsOn).toBe('1,2,3,4,5');
43
+ expect(layout.recurrenceRange).toBe('2025-12-31 23:59:59');
44
+ });
45
+
46
+ it('should set recurrence fields to null when absent', () => {
47
+ const xml = `<schedule>
48
+ <layout file="400.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
49
+ scheduleid="20" priority="0"/>
50
+ </schedule>`;
51
+ const result = parseScheduleResponse(xml);
52
+ const layout = result.layouts[0];
53
+
54
+ expect(layout.recurrenceType).toBeNull();
55
+ expect(layout.recurrenceRepeatsOn).toBeNull();
56
+ expect(layout.recurrenceRange).toBeNull();
57
+ });
58
+
59
+ it('should parse recurrence attributes on campaigns', () => {
60
+ const xml = `<schedule>
61
+ <campaign id="c1" priority="5" fromdt="2025-01-06 08:00:00" todt="2025-01-06 18:00:00"
62
+ scheduleid="30"
63
+ recurrenceType="Week"
64
+ recurrenceRepeatsOn="6,7">
65
+ <layout file="500.xlf"/>
66
+ <layout file="501.xlf"/>
67
+ </campaign>
68
+ </schedule>`;
69
+ const result = parseScheduleResponse(xml);
70
+
71
+ expect(result.campaigns).toHaveLength(1);
72
+ const campaign = result.campaigns[0];
73
+ expect(campaign.recurrenceType).toBe('Week');
74
+ expect(campaign.recurrenceRepeatsOn).toBe('6,7');
75
+ expect(campaign.recurrenceRange).toBeNull();
76
+ expect(campaign.layouts).toHaveLength(2);
77
+ });
78
+
79
+ it('should parse recurrence attributes on overlays', () => {
80
+ const xml = `<schedule>
81
+ <overlays>
82
+ <overlay file="600.xlf" fromdt="2025-01-01 12:00:00" todt="2025-01-01 13:00:00"
83
+ scheduleid="40" priority="2" duration="30"
84
+ recurrenceType="Week"
85
+ recurrenceRepeatsOn="1,3,5"/>
86
+ </overlays>
87
+ </schedule>`;
88
+ const result = parseScheduleResponse(xml);
89
+
90
+ expect(result.overlays).toHaveLength(1);
91
+ const overlay = result.overlays[0];
92
+ expect(overlay.recurrenceType).toBe('Week');
93
+ expect(overlay.recurrenceRepeatsOn).toBe('1,3,5');
94
+ expect(overlay.recurrenceRange).toBeNull();
95
+ });
96
+
97
+ it('should parse criteria on layouts', () => {
98
+ const xml = `<schedule>
99
+ <layout file="700.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
100
+ scheduleid="50" priority="1">
101
+ <criteria metric="dayOfWeek" condition="equals" type="string">Monday</criteria>
102
+ </layout>
103
+ </schedule>`;
104
+ const result = parseScheduleResponse(xml);
105
+ const layout = result.layouts[0];
106
+
107
+ expect(layout.criteria).toHaveLength(1);
108
+ expect(layout.criteria[0].metric).toBe('dayOfWeek');
109
+ expect(layout.criteria[0].condition).toBe('equals');
110
+ expect(layout.criteria[0].value).toBe('Monday');
111
+ });
112
+
113
+ it('should parse dependants on default layout', () => {
114
+ const xml = `<schedule>
115
+ <default file="100.xlf">
116
+ <dependants><file>bg.jpg</file><file>logo.png</file></dependants>
117
+ </default>
118
+ </schedule>`;
119
+ const result = parseScheduleResponse(xml);
120
+ expect(result.default).toBe('100.xlf');
121
+ expect(result.defaultDependants).toEqual(['bg.jpg', 'logo.png']);
122
+ });
123
+
124
+ it('should parse dependants on standalone layouts', () => {
125
+ const xml = `<schedule>
126
+ <layout file="200.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
127
+ scheduleid="5" priority="1">
128
+ <dependants><file>video.mp4</file></dependants>
129
+ </layout>
130
+ </schedule>`;
131
+ const result = parseScheduleResponse(xml);
132
+ expect(result.layouts[0].dependants).toEqual(['video.mp4']);
133
+ });
134
+
135
+ it('should parse dependants on campaign layouts', () => {
136
+ const xml = `<schedule>
137
+ <campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
138
+ scheduleid="30">
139
+ <layout file="300.xlf">
140
+ <dependants><file>font.woff2</file></dependants>
141
+ </layout>
142
+ </campaign>
143
+ </schedule>`;
144
+ const result = parseScheduleResponse(xml);
145
+ expect(result.campaigns[0].layouts[0].dependants).toEqual(['font.woff2']);
146
+ });
147
+
148
+ it('should return empty array when no dependants', () => {
149
+ const xml = `<schedule>
150
+ <layout file="400.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
151
+ scheduleid="20" priority="0"/>
152
+ </schedule>`;
153
+ const result = parseScheduleResponse(xml);
154
+ expect(result.layouts[0].dependants).toEqual([]);
155
+ });
156
+
157
+ it('should parse duration, cyclePlayback, groupKey, playCount on standalone layouts', () => {
158
+ const xml = `<schedule>
159
+ <layout file="500.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
160
+ scheduleid="60" priority="1" duration="120"
161
+ cyclePlayback="1" groupKey="group-A" playCount="3"/>
162
+ </schedule>`;
163
+ const result = parseScheduleResponse(xml);
164
+ const layout = result.layouts[0];
165
+ expect(layout.duration).toBe(120);
166
+ expect(layout.cyclePlayback).toBe(true);
167
+ expect(layout.groupKey).toBe('group-A');
168
+ expect(layout.playCount).toBe(3);
169
+ });
170
+
171
+ it('should parse command code attribute correctly', () => {
172
+ const xml = `<schedule>
173
+ <command code="collectNow" date="2026-01-01"/>
174
+ <command code="reboot" date="2026-01-02"/>
175
+ </schedule>`;
176
+ const result = parseScheduleResponse(xml);
177
+ expect(result.commands).toHaveLength(2);
178
+ expect(result.commands[0].code).toBe('collectNow');
179
+ expect(result.commands[1].code).toBe('reboot');
180
+ });
181
+
182
+ it('should parse dataConnectors with connector child elements', () => {
183
+ const xml = `<schedule>
184
+ <dataConnectors>
185
+ <connector id="dc1" dataSetId="42" dataParams="limit=10" js="render.js"
186
+ url="http://cms.example.com/data" updateInterval="60"/>
187
+ </dataConnectors>
188
+ </schedule>`;
189
+ const result = parseScheduleResponse(xml);
190
+ expect(result.dataConnectors).toHaveLength(1);
191
+ expect(result.dataConnectors[0].id).toBe('dc1');
192
+ expect(result.dataConnectors[0].dataSetId).toBe('42');
193
+ expect(result.dataConnectors[0].dataParams).toBe('limit=10');
194
+ expect(result.dataConnectors[0].js).toBe('render.js');
195
+ expect(result.dataConnectors[0].url).toBe('http://cms.example.com/data');
196
+ expect(result.dataConnectors[0].updateInterval).toBe(60);
197
+ });
198
+
199
+ it('should return empty dataConnectors when no dataConnectors element', () => {
200
+ const xml = '<schedule><default file="0"/></schedule>';
201
+ const result = parseScheduleResponse(xml);
202
+ expect(result.dataConnectors).toEqual([]);
203
+ });
204
+
205
+ it('should parse global dependants from schedule root', () => {
206
+ const xml = `<schedule>
207
+ <default file="0"/>
208
+ <dependants><file>global-font.woff2</file><file>shared-bg.jpg</file></dependants>
209
+ </schedule>`;
210
+ const result = parseScheduleResponse(xml);
211
+ expect(result.dependants).toEqual(['global-font.woff2', 'shared-bg.jpg']);
212
+ });
213
+
214
+ it('should default global dependants to empty array', () => {
215
+ const xml = '<schedule><default file="0"/></schedule>';
216
+ const result = parseScheduleResponse(xml);
217
+ expect(result.dependants).toEqual([]);
218
+ });
219
+
220
+ it('should parse campaign-level geo/sync/shareOfVoice attributes', () => {
221
+ const xml = `<schedule>
222
+ <campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
223
+ scheduleid="30" shareOfVoice="50" isGeoAware="1"
224
+ geoLocation="51.5,-0.1" syncEvent="1">
225
+ <layout file="500.xlf"/>
226
+ </campaign>
227
+ </schedule>`;
228
+ const result = parseScheduleResponse(xml);
229
+ const campaign = result.campaigns[0];
230
+ expect(campaign.shareOfVoice).toBe(50);
231
+ expect(campaign.isGeoAware).toBe(true);
232
+ expect(campaign.geoLocation).toBe('51.5,-0.1');
233
+ expect(campaign.syncEvent).toBe(true);
234
+ });
235
+
236
+ it('should parse criteria on campaigns', () => {
237
+ const xml = `<schedule>
238
+ <campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
239
+ scheduleid="30">
240
+ <criteria metric="temperature" condition="gt" type="number">25</criteria>
241
+ <layout file="500.xlf"/>
242
+ </campaign>
243
+ </schedule>`;
244
+ const result = parseScheduleResponse(xml);
245
+ expect(result.campaigns[0].criteria).toHaveLength(1);
246
+ expect(result.campaigns[0].criteria[0].metric).toBe('temperature');
247
+ expect(result.campaigns[0].criteria[0].value).toBe('25');
248
+ });
249
+
250
+ it('should parse duration, cyclePlayback, groupKey, playCount on campaign layouts', () => {
251
+ const xml = `<schedule>
252
+ <campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
253
+ scheduleid="30">
254
+ <layout file="500.xlf" duration="90" cyclePlayback="1" groupKey="group-B" playCount="2"/>
255
+ </campaign>
256
+ </schedule>`;
257
+ const result = parseScheduleResponse(xml);
258
+ const layout = result.campaigns[0].layouts[0];
259
+ expect(layout.duration).toBe(90);
260
+ expect(layout.cyclePlayback).toBe(true);
261
+ expect(layout.groupKey).toBe('group-B');
262
+ expect(layout.playCount).toBe(2);
263
+ });
264
+
265
+ it('should use consistent lowercase casing for fromdt/todt/scheduleid on overlays', () => {
266
+ const xml = `<schedule>
267
+ <overlays>
268
+ <overlay file="600.xlf" fromdt="2025-01-01 12:00:00" todt="2025-01-01 13:00:00"
269
+ scheduleid="40" priority="2"/>
270
+ </overlays>
271
+ </schedule>`;
272
+ const result = parseScheduleResponse(xml);
273
+ const overlay = result.overlays[0];
274
+ expect(overlay.fromdt).toBe('2025-01-01 12:00:00');
275
+ expect(overlay.todt).toBe('2025-01-01 13:00:00');
276
+ expect(overlay.scheduleid).toBe('40');
277
+ // Verify old camelCase properties are NOT present
278
+ expect(overlay.fromDt).toBeUndefined();
279
+ expect(overlay.toDt).toBeUndefined();
280
+ expect(overlay.scheduleId).toBeUndefined();
281
+ });
282
+ });
@@ -149,13 +149,14 @@ export class XmdsClient {
149
149
  serverKey: this.config.cmsKey,
150
150
  hardwareKey: this.config.hardwareKey,
151
151
  displayName: this.config.displayName,
152
- clientType: 'chromeOS',
153
- clientVersion: '0.1.0',
154
- clientCode: '1',
152
+ clientType: this.config.clientType || 'chromeOS',
153
+ clientVersion: this.config.clientVersion || '0.1.0',
154
+ clientCode: this.config.clientCode || '1',
155
155
  operatingSystem: os,
156
156
  macAddress: this.config.macAddress || 'n/a',
157
- xmrChannel: this.config.xmrChannel,
158
- xmrPubKey: this.config.xmrPubKey || ''
157
+ xmrChannel: this.config.xmrChannel || '',
158
+ xmrPubKey: this.config.xmrPubKey || '',
159
+ licenceResult: 'licensed'
159
160
  });
160
161
 
161
162
  return this.parseRegisterDisplayResponse(xml);
@@ -181,16 +182,54 @@ export class XmdsClient {
181
182
  }
182
183
 
183
184
  const settings = {};
185
+ const tags = [];
186
+ const commands = [];
184
187
  for (const child of display.children) {
185
- if (!['commands', 'file'].includes(child.tagName.toLowerCase())) {
186
- settings[child.tagName] = child.textContent;
188
+ const name = child.tagName.toLowerCase();
189
+ if (name === 'file') continue;
190
+ if (name === 'commands') {
191
+ // Parse <commands><command code="xyz" commandString="args"/></commands>
192
+ for (const cmdEl of child.querySelectorAll('command')) {
193
+ commands.push({
194
+ commandCode: cmdEl.getAttribute('code') || cmdEl.getAttribute('commandCode') || '',
195
+ commandString: cmdEl.getAttribute('commandString') || ''
196
+ });
197
+ }
198
+ continue;
187
199
  }
200
+ if (name === 'tags') {
201
+ // Parse <tags><tag>value</tag>...</tags> into array
202
+ for (const tagEl of child.querySelectorAll('tag')) {
203
+ if (tagEl.textContent) tags.push(tagEl.textContent);
204
+ }
205
+ continue;
206
+ }
207
+ settings[child.tagName] = child.textContent;
188
208
  }
189
209
 
190
210
  const checkRf = display.getAttribute('checkRf') || '';
191
211
  const checkSchedule = display.getAttribute('checkSchedule') || '';
192
212
 
193
- return { code, message, settings, checkRf, checkSchedule };
213
+ // Extract display-level attributes from CMS (server time, status, version info)
214
+ const displayAttrs = {
215
+ date: display.getAttribute('date') || null,
216
+ timezone: display.getAttribute('timezone') || null,
217
+ status: display.getAttribute('status') || null,
218
+ localDate: display.getAttribute('localDate') || null,
219
+ version_instructions: display.getAttribute('version_instructions') || null,
220
+ };
221
+
222
+ // Extract sync group config if present (multi-display sync coordination)
223
+ const syncGroupVal = settings.syncGroup || null;
224
+ const syncConfig = syncGroupVal ? {
225
+ syncGroup: syncGroupVal,
226
+ syncPublisherPort: parseInt(settings.syncPublisherPort || '9590', 10),
227
+ syncSwitchDelay: parseInt(settings.syncSwitchDelay || '750', 10),
228
+ syncVideoPauseDelay: parseInt(settings.syncVideoPauseDelay || '100', 10),
229
+ isLead: syncGroupVal === 'lead',
230
+ } : null;
231
+
232
+ return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
194
233
  }
195
234
 
196
235
  /**
@@ -221,6 +260,8 @@ export class XmdsClient {
221
260
  md5: fileEl.getAttribute('md5'),
222
261
  download: fileEl.getAttribute('download'),
223
262
  path: fileEl.getAttribute('path'),
263
+ saveAs: fileEl.getAttribute('saveAs') || null,
264
+ fileType: fileEl.getAttribute('fileType') || null,
224
265
  code: fileEl.getAttribute('code'),
225
266
  layoutid: fileEl.getAttribute('layoutid'),
226
267
  regionid: fileEl.getAttribute('regionid'),
@@ -228,7 +269,19 @@ export class XmdsClient {
228
269
  });
229
270
  }
230
271
 
231
- return files;
272
+ // Parse purge block — files CMS wants the player to delete
273
+ const purgeItems = [];
274
+ const purgeEl = doc.querySelector('purge');
275
+ if (purgeEl) {
276
+ for (const itemEl of purgeEl.querySelectorAll('item')) {
277
+ purgeItems.push({
278
+ id: itemEl.getAttribute('id'),
279
+ storedAs: itemEl.getAttribute('storedAs')
280
+ });
281
+ }
282
+ }
283
+
284
+ return { files, purge: purgeItems };
232
285
  }
233
286
 
234
287
  /**
@@ -277,6 +330,11 @@ export class XmdsClient {
277
330
  status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
278
331
  }
279
332
 
333
+ // Add statusDialog (summary for CMS display status page) if not provided
334
+ if (!status.statusDialog) {
335
+ status.statusDialog = `Current Layout: ${status.currentLayoutId || 'None'}`;
336
+ }
337
+
280
338
  return await this.call('NotifyStatus', {
281
339
  serverKey: this.config.cmsKey,
282
340
  hardwareKey: this.config.hardwareKey,
@@ -324,10 +382,10 @@ export class XmdsClient {
324
382
  * @param {string} logXml - XML string containing log entries
325
383
  * @returns {Promise<boolean>} - true if logs were successfully submitted
326
384
  */
327
- async submitLog(logXml) {
385
+ async submitLog(logXml, hardwareKeyOverride = null) {
328
386
  const xml = await this.call('SubmitLog', {
329
387
  serverKey: this.config.cmsKey,
330
- hardwareKey: this.config.hardwareKey,
388
+ hardwareKey: hardwareKeyOverride || this.config.hardwareKey,
331
389
  logXml: logXml
332
390
  });
333
391
 
@@ -367,11 +425,22 @@ export class XmdsClient {
367
425
  });
368
426
  }
369
427
 
370
- async submitStats(statsXml) {
428
+ /**
429
+ * GetWeather - get current weather data for schedule criteria
430
+ * @returns {Promise<string>} Weather data XML from CMS
431
+ */
432
+ async getWeather() {
433
+ return this.call('GetWeather', {
434
+ serverKey: this.config.cmsKey,
435
+ hardwareKey: this.config.hardwareKey
436
+ });
437
+ }
438
+
439
+ async submitStats(statsXml, hardwareKeyOverride = null) {
371
440
  try {
372
441
  const xml = await this.call('SubmitStats', {
373
442
  serverKey: this.config.cmsKey,
374
- hardwareKey: this.config.hardwareKey,
443
+ hardwareKey: hardwareKeyOverride || this.config.hardwareKey,
375
444
  statXml: statsXml
376
445
  });
377
446
 
@@ -25,8 +25,8 @@ describe('Schedule Parsing - Overlays', () => {
25
25
  expect(schedule.overlays[0].file).toBe('101.xlf');
26
26
  expect(schedule.overlays[0].duration).toBe(60);
27
27
  expect(schedule.overlays[0].priority).toBe(10);
28
- expect(schedule.overlays[0].fromDt).toBe('2026-01-01 00:00:00');
29
- expect(schedule.overlays[0].toDt).toBe('2026-12-31 23:59:59');
28
+ expect(schedule.overlays[0].fromdt).toBe('2026-01-01 00:00:00');
29
+ expect(schedule.overlays[0].todt).toBe('2026-12-31 23:59:59');
30
30
  });
31
31
 
32
32
  it('should parse multiple overlays', () => {
@@ -353,17 +353,18 @@ describe('RestClient - RequiredFiles', () => {
353
353
  ]
354
354
  }, { etag: '"files-v1"' }));
355
355
 
356
- const files = await client.requiredFiles();
356
+ const result = await client.requiredFiles();
357
357
 
358
- expect(files).toHaveLength(3);
359
- expect(files[0]).toEqual({
358
+ expect(result.files).toHaveLength(3);
359
+ expect(result.files[0]).toEqual(expect.objectContaining({
360
360
  type: 'media', id: '42', size: 1024, md5: 'abc', download: 'http',
361
361
  path: '/media/42.jpg', code: null, layoutid: null, regionid: null, mediaid: null,
362
- });
363
- expect(files[1].type).toBe('layout');
364
- expect(files[2].layoutid).toBe('10');
365
- expect(files[2].regionid).toBe('5');
366
- expect(files[2].mediaid).toBe('99');
362
+ }));
363
+ expect(result.files[1].type).toBe('layout');
364
+ expect(result.files[2].layoutid).toBe('10');
365
+ expect(result.files[2].regionid).toBe('5');
366
+ expect(result.files[2].mediaid).toBe('99');
367
+ expect(result.purge).toEqual([]);
367
368
  });
368
369
 
369
370
  it('should handle single file (not array)', async () => {
@@ -371,16 +372,16 @@ describe('RestClient - RequiredFiles', () => {
371
372
  file: { '@attributes': { type: 'media', id: '1', size: '100', md5: 'x' } }
372
373
  }));
373
374
 
374
- const files = await client.requiredFiles();
375
- expect(files).toHaveLength(1);
376
- expect(files[0].id).toBe('1');
375
+ const result = await client.requiredFiles();
376
+ expect(result.files).toHaveLength(1);
377
+ expect(result.files[0].id).toBe('1');
377
378
  });
378
379
 
379
380
  it('should handle empty file list', async () => {
380
381
  mockFetch.mockResolvedValue(jsonResponse({}));
381
382
 
382
- const files = await client.requiredFiles();
383
- expect(files).toHaveLength(0);
383
+ const result = await client.requiredFiles();
384
+ expect(result.files).toHaveLength(0);
384
385
  });
385
386
 
386
387
  it('should use ETag caching', async () => {
@@ -407,12 +408,12 @@ describe('RestClient - RequiredFiles', () => {
407
408
 
408
409
  const result = client._parseRequiredFilesJson(json);
409
410
 
410
- expect(result[0].type).toBe('media');
411
- expect(result[0].id).toBe('42');
412
- expect(result[0].size).toBe(1024);
413
- expect(result[0].md5).toBe('abc');
414
- expect(result[0].download).toBe('http');
415
- expect(result[0].path).toBe('/media/42.jpg');
411
+ expect(result.files[0].type).toBe('media');
412
+ expect(result.files[0].id).toBe('42');
413
+ expect(result.files[0].size).toBe(1024);
414
+ expect(result.files[0].md5).toBe('abc');
415
+ expect(result.files[0].download).toBe('http');
416
+ expect(result.files[0].path).toBe('/media/42.jpg');
416
417
  });
417
418
  });
418
419
 
@@ -676,10 +677,41 @@ describe('RestClient - MediaInventory', () => {
676
677
  });
677
678
  });
678
679
 
679
- // ─── BlackList (always SOAP) ─────────────────────────────────────────
680
+ // ─── BlackList ───────────────────────────────────────────────────────
680
681
 
681
682
  describe('RestClient - BlackList', () => {
682
- it('should return false since REST has no BlackList endpoint', async () => {
683
+ let mockFetch;
684
+
685
+ beforeEach(() => {
686
+ mockFetch = vi.fn();
687
+ global.fetch = mockFetch;
688
+ });
689
+
690
+ it('should POST to /blacklist endpoint', async () => {
691
+ mockFetch.mockResolvedValueOnce({
692
+ ok: true,
693
+ status: 200,
694
+ headers: { get: () => 'application/json' },
695
+ json: () => Promise.resolve({ success: true }),
696
+ });
697
+
698
+ const client = createRestClient();
699
+ const result = await client.blackList('42', 'media', 'Broken file');
700
+
701
+ expect(result).toBe(true);
702
+ expect(mockFetch).toHaveBeenCalledWith(
703
+ expect.stringContaining('/blacklist'),
704
+ expect.objectContaining({ method: 'POST' })
705
+ );
706
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
707
+ expect(body.mediaId).toBe('42');
708
+ expect(body.type).toBe('media');
709
+ expect(body.reason).toBe('Broken file');
710
+ });
711
+
712
+ it('should return false on failure', async () => {
713
+ mockFetch.mockRejectedValue(new Error('Network error'));
714
+
683
715
  const client = createRestClient();
684
716
  const result = await client.blackList('42', 'media', 'Broken');
685
717
  expect(result).toBe(false);
package/src/xmds.test.js CHANGED
@@ -883,6 +883,105 @@ describe('XmdsClient - NotifyStatus', () => {
883
883
  });
884
884
  });
885
885
 
886
+ describe('XmdsClient - GetWeather', () => {
887
+ let client;
888
+ let mockFetch;
889
+
890
+ beforeEach(() => {
891
+ client = new XmdsClient({
892
+ cmsAddress: 'https://cms.example.com',
893
+ cmsKey: 'test-server-key',
894
+ hardwareKey: 'test-hardware-key',
895
+ retryOptions: { maxRetries: 0 }
896
+ });
897
+
898
+ mockFetch = vi.fn();
899
+ global.fetch = mockFetch;
900
+ });
901
+
902
+ it('should build correct SOAP envelope for GetWeather', async () => {
903
+ mockFetch.mockResolvedValue({
904
+ ok: true,
905
+ text: async () => `<?xml version="1.0"?>
906
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
907
+ <soap:Body>
908
+ <GetWeatherResponse>
909
+ <weather>{"temperature":22,"humidity":65,"windSpeed":10,"condition":"Clear","cloudCover":15}</weather>
910
+ </GetWeatherResponse>
911
+ </soap:Body>
912
+ </soap:Envelope>`
913
+ });
914
+
915
+ await client.getWeather();
916
+
917
+ expect(mockFetch).toHaveBeenCalledWith(
918
+ 'https://cms.example.com/xmds.php?v=5&method=GetWeather',
919
+ expect.objectContaining({
920
+ method: 'POST',
921
+ headers: {
922
+ 'Content-Type': 'text/xml; charset=utf-8'
923
+ }
924
+ })
925
+ );
926
+
927
+ const body = mockFetch.mock.calls[0][1].body;
928
+ expect(body).toContain('<tns:GetWeather>');
929
+ expect(body).toContain('<serverKey xsi:type="xsd:string">test-server-key</serverKey>');
930
+ expect(body).toContain('<hardwareKey xsi:type="xsd:string">test-hardware-key</hardwareKey>');
931
+ });
932
+
933
+ it('should return weather data from SOAP response', async () => {
934
+ const weatherJson = '{"temperature":22,"humidity":65,"windSpeed":10,"condition":"Clear","cloudCover":15}';
935
+ mockFetch.mockResolvedValue({
936
+ ok: true,
937
+ text: async () => `<?xml version="1.0"?>
938
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
939
+ <soap:Body>
940
+ <GetWeatherResponse>
941
+ <weather>${weatherJson}</weather>
942
+ </GetWeatherResponse>
943
+ </soap:Body>
944
+ </soap:Envelope>`
945
+ });
946
+
947
+ const result = await client.getWeather();
948
+ expect(result).toBe(weatherJson);
949
+ });
950
+
951
+ it('should handle SOAP fault', async () => {
952
+ mockFetch.mockResolvedValue({
953
+ ok: true,
954
+ text: async () => `<?xml version="1.0"?>
955
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
956
+ <soap:Body>
957
+ <soap:Fault>
958
+ <faultcode>Server</faultcode>
959
+ <faultstring>Weather service unavailable</faultstring>
960
+ </soap:Fault>
961
+ </soap:Body>
962
+ </soap:Envelope>`
963
+ });
964
+
965
+ await expect(client.getWeather()).rejects.toThrow('SOAP Fault: Weather service unavailable');
966
+ });
967
+
968
+ it('should handle HTTP errors', async () => {
969
+ mockFetch.mockResolvedValue({
970
+ ok: false,
971
+ status: 500,
972
+ statusText: 'Internal Server Error'
973
+ });
974
+
975
+ await expect(client.getWeather()).rejects.toThrow('XMDS GetWeather failed: 500 Internal Server Error');
976
+ });
977
+
978
+ it('should handle network errors', async () => {
979
+ mockFetch.mockRejectedValue(new Error('Network error'));
980
+
981
+ await expect(client.getWeather()).rejects.toThrow('Network error');
982
+ });
983
+ });
984
+
886
985
  describe('XmdsClient - MediaInventory', () => {
887
986
  let client;
888
987
  let mockFetch;