@xiboplayer/xmds 0.6.12 → 0.7.0

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.12",
3
+ "version": "0.7.0",
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.12"
15
+ "@xiboplayer/utils": "0.7.0"
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
  /**
@@ -108,7 +122,7 @@ export class RestClient {
108
122
  /**
109
123
  * Make an authenticated GET request with ETag caching.
110
124
  */
111
- async restGet(path, queryParams = {}) {
125
+ async restGet(path, queryParams = {}, _retrying = false) {
112
126
  const token = await this._getToken();
113
127
  const url = new URL(`${this.getRestBaseUrl()}${path}`);
114
128
  for (const [key, value] of Object.entries(queryParams)) {
@@ -131,8 +145,11 @@ export class RestClient {
131
145
 
132
146
  // Token expired mid-flight — re-auth and retry once
133
147
  if (response.status === 401) {
148
+ if (_retrying) {
149
+ throw new Error(`REST GET ${path} failed: 401 Unauthorized (after re-auth)`);
150
+ }
134
151
  this._token = null;
135
- return this.restGet(path, queryParams);
152
+ return this.restGet(path, queryParams, true);
136
153
  }
137
154
 
138
155
  if (response.status === 304) {
@@ -148,11 +165,6 @@ export class RestClient {
148
165
  throw new Error(`REST GET ${path} failed: ${response.status} ${response.statusText} ${errorBody}`);
149
166
  }
150
167
 
151
- const etag = response.headers.get('ETag');
152
- if (etag) {
153
- this._etags.set(cacheKey, etag);
154
- }
155
-
156
168
  const contentType = response.headers.get('Content-Type') || '';
157
169
  let data;
158
170
  if (contentType.includes('application/json')) {
@@ -161,14 +173,18 @@ export class RestClient {
161
173
  data = await response.text();
162
174
  }
163
175
 
164
- this._responseCache.set(cacheKey, data);
176
+ const etag = response.headers.get('ETag');
177
+ if (etag) {
178
+ this._cacheSet(cacheKey, etag, data);
179
+ }
180
+
165
181
  return data;
166
182
  }
167
183
 
168
184
  /**
169
185
  * Make an authenticated POST/PUT request with JSON body.
170
186
  */
171
- async restSend(method, path, body = {}) {
187
+ async restSend(method, path, body = {}, _retrying = false) {
172
188
  const token = await this._getToken();
173
189
  const url = new URL(`${this.getRestBaseUrl()}${path}`);
174
190
 
@@ -185,8 +201,11 @@ export class RestClient {
185
201
 
186
202
  // Token expired mid-flight — re-auth and retry once
187
203
  if (response.status === 401) {
204
+ if (_retrying) {
205
+ throw new Error(`REST ${method} ${path} failed: 401 Unauthorized (after re-auth)`);
206
+ }
188
207
  this._token = null;
189
- return this.restSend(method, path, body);
208
+ return this.restSend(method, path, body, true);
190
209
  }
191
210
 
192
211
  if (!response.ok) {
@@ -283,13 +302,14 @@ export class RestClient {
283
302
  version_instructions: attrs.version_instructions || display.version_instructions || null,
284
303
  };
285
304
 
286
- const syncConfig = display.syncGroup ? {
305
+ // Prefer structured syncConfig from REST module; fall back to flat SOAP fields
306
+ const syncConfig = display.syncConfig || (display.syncGroup ? {
287
307
  syncGroup: String(display.syncGroup),
288
308
  syncPublisherPort: parseInt(display.syncPublisherPort || '9590', 10),
289
309
  syncSwitchDelay: parseInt(display.syncSwitchDelay || '750', 10),
290
310
  syncVideoPauseDelay: parseInt(display.syncVideoPauseDelay || '100', 10),
291
311
  isLead: String(display.syncGroup) === 'lead',
292
- } : null;
312
+ } : null);
293
313
 
294
314
  return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
295
315
  }
@@ -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
+ });