@xiboplayer/xmds 0.6.13 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/xmds",
3
- "version": "0.6.13",
3
+ "version": "0.7.1",
4
4
  "description": "XMDS SOAP client for Xibo CMS communication",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,7 +12,7 @@
12
12
  "./schedule-parser": "./src/schedule-parser.js"
13
13
  },
14
14
  "dependencies": {
15
- "@xiboplayer/utils": "0.6.13"
15
+ "@xiboplayer/utils": "0.7.1"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0"
@@ -29,9 +29,10 @@ export class RestClient {
29
29
  this._tokenExpiresAt = 0;
30
30
  this._displayId = null;
31
31
 
32
- // ETag-based HTTP caching
32
+ // ETag-based HTTP caching (capped at _maxCacheSize entries)
33
33
  this._etags = new Map();
34
34
  this._responseCache = new Map();
35
+ this._maxCacheSize = 100;
35
36
 
36
37
  log.info('Using REST transport');
37
38
  }
@@ -61,6 +62,19 @@ export class RestClient {
61
62
  window.location.hostname === 'localhost');
62
63
  }
63
64
 
65
+ /**
66
+ * Store a response in the ETag cache, evicting the oldest entry if full.
67
+ */
68
+ _cacheSet(path, etag, data) {
69
+ if (this._etags.size >= this._maxCacheSize) {
70
+ const oldest = this._etags.keys().next().value;
71
+ this._etags.delete(oldest);
72
+ this._responseCache.delete(oldest);
73
+ }
74
+ this._etags.set(path, etag);
75
+ this._responseCache.set(path, data);
76
+ }
77
+
64
78
  // ─── JWT auth ─────────────────────────────────────────────────
65
79
 
66
80
  /**
@@ -95,6 +109,14 @@ export class RestClient {
95
109
  log.info(`Authenticated as display ${this._displayId}`);
96
110
  }
97
111
 
112
+ /**
113
+ * Get current token synchronously (may be null or expired).
114
+ * @returns {string|null}
115
+ */
116
+ getToken() {
117
+ return this._token;
118
+ }
119
+
98
120
  /**
99
121
  * Get a valid JWT token, refreshing if expired or missing.
100
122
  */
@@ -108,7 +130,7 @@ export class RestClient {
108
130
  /**
109
131
  * Make an authenticated GET request with ETag caching.
110
132
  */
111
- async restGet(path, queryParams = {}) {
133
+ async restGet(path, queryParams = {}, _retrying = false) {
112
134
  const token = await this._getToken();
113
135
  const url = new URL(`${this.getRestBaseUrl()}${path}`);
114
136
  for (const [key, value] of Object.entries(queryParams)) {
@@ -131,8 +153,11 @@ export class RestClient {
131
153
 
132
154
  // Token expired mid-flight — re-auth and retry once
133
155
  if (response.status === 401) {
156
+ if (_retrying) {
157
+ throw new Error(`REST GET ${path} failed: 401 Unauthorized (after re-auth)`);
158
+ }
134
159
  this._token = null;
135
- return this.restGet(path, queryParams);
160
+ return this.restGet(path, queryParams, true);
136
161
  }
137
162
 
138
163
  if (response.status === 304) {
@@ -148,11 +173,6 @@ export class RestClient {
148
173
  throw new Error(`REST GET ${path} failed: ${response.status} ${response.statusText} ${errorBody}`);
149
174
  }
150
175
 
151
- const etag = response.headers.get('ETag');
152
- if (etag) {
153
- this._etags.set(cacheKey, etag);
154
- }
155
-
156
176
  const contentType = response.headers.get('Content-Type') || '';
157
177
  let data;
158
178
  if (contentType.includes('application/json')) {
@@ -161,14 +181,18 @@ export class RestClient {
161
181
  data = await response.text();
162
182
  }
163
183
 
164
- this._responseCache.set(cacheKey, data);
184
+ const etag = response.headers.get('ETag');
185
+ if (etag) {
186
+ this._cacheSet(cacheKey, etag, data);
187
+ }
188
+
165
189
  return data;
166
190
  }
167
191
 
168
192
  /**
169
193
  * Make an authenticated POST/PUT request with JSON body.
170
194
  */
171
- async restSend(method, path, body = {}) {
195
+ async restSend(method, path, body = {}, _retrying = false) {
172
196
  const token = await this._getToken();
173
197
  const url = new URL(`${this.getRestBaseUrl()}${path}`);
174
198
 
@@ -185,8 +209,11 @@ export class RestClient {
185
209
 
186
210
  // Token expired mid-flight — re-auth and retry once
187
211
  if (response.status === 401) {
212
+ if (_retrying) {
213
+ throw new Error(`REST ${method} ${path} failed: 401 Unauthorized (after re-auth)`);
214
+ }
188
215
  this._token = null;
189
- return this.restSend(method, path, body);
216
+ return this.restSend(method, path, body, true);
190
217
  }
191
218
 
192
219
  if (!response.ok) {
@@ -283,13 +310,14 @@ export class RestClient {
283
310
  version_instructions: attrs.version_instructions || display.version_instructions || null,
284
311
  };
285
312
 
286
- const syncConfig = display.syncGroup ? {
313
+ // Prefer structured syncConfig from REST module; fall back to flat SOAP fields
314
+ const syncConfig = display.syncConfig || (display.syncGroup ? {
287
315
  syncGroup: String(display.syncGroup),
288
316
  syncPublisherPort: parseInt(display.syncPublisherPort || '9590', 10),
289
317
  syncSwitchDelay: parseInt(display.syncSwitchDelay || '750', 10),
290
318
  syncVideoPauseDelay: parseInt(display.syncVideoPauseDelay || '100', 10),
291
319
  isLead: String(display.syncGroup) === 'lead',
292
- } : null;
320
+ } : null);
293
321
 
294
322
  return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
295
323
  }
@@ -0,0 +1,307 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
3
+ // @vitest-environment jsdom
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+
6
+ // Mock @xiboplayer/utils before importing RestClient
7
+ vi.mock('@xiboplayer/utils', () => ({
8
+ createLogger: () => ({
9
+ info: vi.fn(),
10
+ debug: vi.fn(),
11
+ warn: vi.fn(),
12
+ error: vi.fn(),
13
+ }),
14
+ fetchWithRetry: vi.fn(),
15
+ PLAYER_API: '/player/api/v2',
16
+ }));
17
+
18
+ import { RestClient } from './rest-client.js';
19
+ import { fetchWithRetry } from '@xiboplayer/utils';
20
+
21
+ const mockConfig = {
22
+ cmsUrl: 'https://cms.example.com',
23
+ cmsKey: 'serverkey123',
24
+ hardwareKey: 'hw-001',
25
+ displayName: 'Test Display',
26
+ xmrChannel: 'xmr-ch',
27
+ };
28
+
29
+ /** Helper: create a mock Response object */
30
+ function mockResponse(status, body, headers = {}) {
31
+ return {
32
+ ok: status >= 200 && status < 300,
33
+ status,
34
+ statusText: status === 200 ? 'OK' : status === 304 ? 'Not Modified' : status === 401 ? 'Unauthorized' : 'Error',
35
+ headers: {
36
+ get: (name) => headers[name] || null,
37
+ },
38
+ json: vi.fn().mockResolvedValue(body),
39
+ text: vi.fn().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)),
40
+ };
41
+ }
42
+
43
+ /** Helper: mock a successful auth response */
44
+ function mockAuthResponse() {
45
+ return mockResponse(200, {
46
+ token: 'jwt-token-123',
47
+ displayId: 42,
48
+ expiresIn: 3600,
49
+ }, { 'Content-Type': 'application/json' });
50
+ }
51
+
52
+ describe('RestClient', () => {
53
+ let client;
54
+
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ client = new RestClient(mockConfig);
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ // ─── JWT authentication ─────────────────────────────────────
65
+
66
+ describe('JWT authentication', () => {
67
+ it('authenticates and stores token on first request', async () => {
68
+ const authResp = mockAuthResponse();
69
+ const dataResp = mockResponse(200, { ok: true }, { 'Content-Type': 'application/json' });
70
+
71
+ fetchWithRetry
72
+ .mockResolvedValueOnce(authResp) // POST /auth
73
+ .mockResolvedValueOnce(dataResp); // GET /schedule
74
+
75
+ const result = await client.restGet('/displays/42/schedule');
76
+
77
+ expect(result).toEqual({ ok: true });
78
+ expect(fetchWithRetry).toHaveBeenCalledTimes(2);
79
+ // First call is auth
80
+ expect(fetchWithRetry.mock.calls[0][1].method).toBe('POST');
81
+ expect(client._token).toBe('jwt-token-123');
82
+ expect(client._displayId).toBe(42);
83
+ });
84
+
85
+ it('reuses token on subsequent requests', async () => {
86
+ const authResp = mockAuthResponse();
87
+ const resp1 = mockResponse(200, { a: 1 }, { 'Content-Type': 'application/json' });
88
+ const resp2 = mockResponse(200, { b: 2 }, { 'Content-Type': 'application/json' });
89
+
90
+ fetchWithRetry
91
+ .mockResolvedValueOnce(authResp)
92
+ .mockResolvedValueOnce(resp1)
93
+ .mockResolvedValueOnce(resp2);
94
+
95
+ await client.restGet('/foo');
96
+ await client.restGet('/bar');
97
+
98
+ // Only one auth call, two data calls
99
+ expect(fetchWithRetry).toHaveBeenCalledTimes(3);
100
+ });
101
+
102
+ it('throws on auth failure', async () => {
103
+ fetchWithRetry.mockResolvedValueOnce(mockResponse(403, 'Forbidden'));
104
+
105
+ await expect(client.restGet('/anything')).rejects.toThrow('Auth failed: 403');
106
+ });
107
+ });
108
+
109
+ // ─── ETag caching ───────────────────────────────────────────
110
+
111
+ describe('ETag caching', () => {
112
+ it('returns cached response on 304', async () => {
113
+ const authResp = mockAuthResponse();
114
+ const firstResp = mockResponse(200, { data: 'fresh' }, {
115
+ 'Content-Type': 'application/json',
116
+ 'ETag': '"etag-1"',
117
+ });
118
+ const cachedResp = mockResponse(304, null);
119
+
120
+ fetchWithRetry
121
+ .mockResolvedValueOnce(authResp)
122
+ .mockResolvedValueOnce(firstResp)
123
+ .mockResolvedValueOnce(cachedResp);
124
+
125
+ // First call populates cache
126
+ const first = await client.restGet('/resource');
127
+ expect(first).toEqual({ data: 'fresh' });
128
+
129
+ // Second call gets 304, returns cached
130
+ const second = await client.restGet('/resource');
131
+ expect(second).toEqual({ data: 'fresh' });
132
+ });
133
+
134
+ it('sends If-None-Match header when ETag is cached', async () => {
135
+ const authResp = mockAuthResponse();
136
+ const firstResp = mockResponse(200, { data: 1 }, {
137
+ 'Content-Type': 'application/json',
138
+ 'ETag': '"my-etag"',
139
+ });
140
+ const secondResp = mockResponse(304, null);
141
+
142
+ fetchWithRetry
143
+ .mockResolvedValueOnce(authResp)
144
+ .mockResolvedValueOnce(firstResp)
145
+ .mockResolvedValueOnce(secondResp);
146
+
147
+ await client.restGet('/path');
148
+ await client.restGet('/path');
149
+
150
+ // Third fetchWithRetry call (second GET) should include If-None-Match
151
+ const secondGetCall = fetchWithRetry.mock.calls[2];
152
+ expect(secondGetCall[1].headers['If-None-Match']).toBe('"my-etag"');
153
+ });
154
+ });
155
+
156
+ // ─── 401 retry guard ───────────────────────────────────────
157
+
158
+ describe('401 retry guard', () => {
159
+ it('restGet retries once on 401 then succeeds', async () => {
160
+ const authResp1 = mockAuthResponse();
161
+ const unauthorizedResp = mockResponse(401, 'Unauthorized');
162
+ const authResp2 = mockAuthResponse();
163
+ const successResp = mockResponse(200, { ok: true }, { 'Content-Type': 'application/json' });
164
+
165
+ fetchWithRetry
166
+ .mockResolvedValueOnce(authResp1) // first auth
167
+ .mockResolvedValueOnce(unauthorizedResp) // GET returns 401
168
+ .mockResolvedValueOnce(authResp2) // re-auth
169
+ .mockResolvedValueOnce(successResp); // retry GET succeeds
170
+
171
+ const result = await client.restGet('/resource');
172
+ expect(result).toEqual({ ok: true });
173
+ });
174
+
175
+ it('restGet throws on second consecutive 401 (no infinite recursion)', async () => {
176
+ const authResp1 = mockAuthResponse();
177
+ const unauth1 = mockResponse(401, 'Unauthorized');
178
+ const authResp2 = mockAuthResponse();
179
+ const unauth2 = mockResponse(401, 'Unauthorized');
180
+
181
+ fetchWithRetry
182
+ .mockResolvedValueOnce(authResp1)
183
+ .mockResolvedValueOnce(unauth1)
184
+ .mockResolvedValueOnce(authResp2)
185
+ .mockResolvedValueOnce(unauth2);
186
+
187
+ await expect(client.restGet('/resource'))
188
+ .rejects.toThrow('401 Unauthorized (after re-auth)');
189
+ });
190
+
191
+ it('restSend retries once on 401 then succeeds', async () => {
192
+ const authResp1 = mockAuthResponse();
193
+ const unauth = mockResponse(401, 'Unauthorized');
194
+ const authResp2 = mockAuthResponse();
195
+ const success = mockResponse(200, { saved: true }, { 'Content-Type': 'application/json' });
196
+
197
+ fetchWithRetry
198
+ .mockResolvedValueOnce(authResp1)
199
+ .mockResolvedValueOnce(unauth)
200
+ .mockResolvedValueOnce(authResp2)
201
+ .mockResolvedValueOnce(success);
202
+
203
+ const result = await client.restSend('PUT', '/resource', { key: 'value' });
204
+ expect(result).toEqual({ saved: true });
205
+ });
206
+
207
+ it('restSend throws on second consecutive 401 (no infinite recursion)', async () => {
208
+ const authResp1 = mockAuthResponse();
209
+ const unauth1 = mockResponse(401, 'Unauthorized');
210
+ const authResp2 = mockAuthResponse();
211
+ const unauth2 = mockResponse(401, 'Unauthorized');
212
+
213
+ fetchWithRetry
214
+ .mockResolvedValueOnce(authResp1)
215
+ .mockResolvedValueOnce(unauth1)
216
+ .mockResolvedValueOnce(authResp2)
217
+ .mockResolvedValueOnce(unauth2);
218
+
219
+ await expect(client.restSend('POST', '/resource', {}))
220
+ .rejects.toThrow('401 Unauthorized (after re-auth)');
221
+ });
222
+ });
223
+
224
+ // ─── Cache eviction ─────────────────────────────────────────
225
+
226
+ describe('cache eviction', () => {
227
+ it('evicts oldest entry when cache exceeds max size', () => {
228
+ // Fill cache to max
229
+ for (let i = 0; i < 100; i++) {
230
+ client._cacheSet(`/path-${i}`, `etag-${i}`, { i });
231
+ }
232
+ expect(client._etags.size).toBe(100);
233
+ expect(client._responseCache.size).toBe(100);
234
+
235
+ // Adding one more should evict /path-0
236
+ client._cacheSet('/path-new', 'etag-new', { new: true });
237
+ expect(client._etags.size).toBe(100);
238
+ expect(client._etags.has('/path-0')).toBe(false);
239
+ expect(client._responseCache.has('/path-0')).toBe(false);
240
+ expect(client._etags.has('/path-new')).toBe(true);
241
+ expect(client._responseCache.get('/path-new')).toEqual({ new: true });
242
+ });
243
+
244
+ it('does not evict when under max size', () => {
245
+ client._cacheSet('/a', 'etag-a', 'data-a');
246
+ client._cacheSet('/b', 'etag-b', 'data-b');
247
+ expect(client._etags.size).toBe(2);
248
+ expect(client._etags.has('/a')).toBe(true);
249
+ expect(client._etags.has('/b')).toBe(true);
250
+ });
251
+
252
+ it('evicts multiple oldest entries on sequential inserts', () => {
253
+ for (let i = 0; i < 100; i++) {
254
+ client._cacheSet(`/p-${i}`, `e-${i}`, i);
255
+ }
256
+ // Add 3 more
257
+ client._cacheSet('/x1', 'ex1', 'x1');
258
+ client._cacheSet('/x2', 'ex2', 'x2');
259
+ client._cacheSet('/x3', 'ex3', 'x3');
260
+
261
+ expect(client._etags.size).toBe(100);
262
+ expect(client._etags.has('/p-0')).toBe(false);
263
+ expect(client._etags.has('/p-1')).toBe(false);
264
+ expect(client._etags.has('/p-2')).toBe(false);
265
+ expect(client._etags.has('/p-3')).toBe(true); // still present
266
+ expect(client._etags.has('/x3')).toBe(true);
267
+ });
268
+ });
269
+
270
+ // ─── Proxy mode detection ──────────────────────────────────
271
+
272
+ describe('proxy mode detection', () => {
273
+ it('detects proxy mode when electronAPI.isElectron is set', () => {
274
+ window.electronAPI = { isElectron: true };
275
+ expect(client._isProxyMode()).toBe(true);
276
+ delete window.electronAPI;
277
+ });
278
+
279
+ it('detects proxy mode when hostname is localhost', () => {
280
+ // jsdom defaults to localhost
281
+ const original = window.location.hostname;
282
+ expect(client._isProxyMode()).toBe(original === 'localhost');
283
+ });
284
+
285
+ it('returns direct URL in non-proxy mode', () => {
286
+ // Ensure no proxy indicators
287
+ delete window.electronAPI;
288
+ // jsdom hostname is localhost, so override _isProxyMode
289
+ vi.spyOn(client, '_isProxyMode').mockReturnValue(false);
290
+
291
+ const url = client.getRestBaseUrl();
292
+ expect(url).toBe('https://cms.example.com/player/api/v2');
293
+ });
294
+
295
+ it('returns local origin URL in proxy mode via electronAPI', () => {
296
+ window.electronAPI = { isElectron: true };
297
+
298
+ const url = client.getRestBaseUrl();
299
+ expect(url).toContain('/player/api/v2');
300
+ // In proxy mode, uses window.location.origin (which is cms.example.com in vitest)
301
+ // The key assertion is that _isProxyMode is true and the URL uses origin
302
+ expect(url).toBe(`${window.location.origin}/player/api/v2`);
303
+
304
+ delete window.electronAPI;
305
+ });
306
+ });
307
+ });