@xiboplayer/utils 0.1.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/docs/README.md +61 -0
- package/package.json +36 -0
- package/src/cms-api.js +764 -0
- package/src/cms-api.test.js +803 -0
- package/src/config.js +288 -0
- package/src/config.test.js +473 -0
- package/src/event-emitter.js +77 -0
- package/src/event-emitter.test.js +432 -0
- package/src/fetch-retry.js +61 -0
- package/src/fetch-retry.test.js +108 -0
- package/src/index.js +6 -0
- package/src/logger.js +237 -0
- package/src/logger.test.js +477 -0
- package/vitest.config.js +8 -0
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for CmsApiClient
|
|
3
|
+
*
|
|
4
|
+
* Tests OAuth2 authentication, token management, generic request handling,
|
|
5
|
+
* and all CRUD methods (layout, region, widget, media, campaign, schedule,
|
|
6
|
+
* display group, resolution). Uses mocked fetch.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
import { CmsApiClient } from './cms-api.js';
|
|
10
|
+
|
|
11
|
+
// Suppress logger output during tests
|
|
12
|
+
vi.mock('./logger.js', () => ({
|
|
13
|
+
createLogger: () => ({
|
|
14
|
+
info: () => {},
|
|
15
|
+
warn: () => {},
|
|
16
|
+
error: () => {},
|
|
17
|
+
debug: () => {}
|
|
18
|
+
})
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
describe('CmsApiClient', () => {
|
|
22
|
+
let api;
|
|
23
|
+
let mockFetch;
|
|
24
|
+
|
|
25
|
+
const CMS_URL = 'https://cms.example.com';
|
|
26
|
+
const CLIENT_ID = 'test-client';
|
|
27
|
+
const CLIENT_SECRET = 'test-secret';
|
|
28
|
+
|
|
29
|
+
/** Helper: create a mock JSON response */
|
|
30
|
+
function jsonResponse(data, status = 200) {
|
|
31
|
+
return {
|
|
32
|
+
ok: status >= 200 && status < 300,
|
|
33
|
+
status,
|
|
34
|
+
headers: new Headers({ 'Content-Type': 'application/json' }),
|
|
35
|
+
json: () => Promise.resolve(data),
|
|
36
|
+
text: () => Promise.resolve(JSON.stringify(data))
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Helper: create a mock empty response (204) */
|
|
41
|
+
function emptyResponse(status = 204) {
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
status,
|
|
45
|
+
headers: new Headers({}),
|
|
46
|
+
text: () => Promise.resolve('')
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Helper: create a mock error response */
|
|
51
|
+
function errorResponse(status, message) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
status,
|
|
55
|
+
headers: new Headers({ 'Content-Type': 'application/json' }),
|
|
56
|
+
json: () => Promise.resolve({ message }),
|
|
57
|
+
text: () => Promise.resolve(JSON.stringify({ message }))
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Helper: stub authentication so request() doesn't re-auth */
|
|
62
|
+
function stubAuth() {
|
|
63
|
+
api.accessToken = 'test-token';
|
|
64
|
+
api.tokenExpiry = Date.now() + 3600000; // 1 hour from now
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
mockFetch = vi.fn();
|
|
69
|
+
global.fetch = mockFetch;
|
|
70
|
+
api = new CmsApiClient({
|
|
71
|
+
baseUrl: CMS_URL,
|
|
72
|
+
clientId: CLIENT_ID,
|
|
73
|
+
clientSecret: CLIENT_SECRET
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.restoreAllMocks();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ── Constructor ──
|
|
82
|
+
|
|
83
|
+
describe('constructor', () => {
|
|
84
|
+
it('should strip trailing slashes from baseUrl', () => {
|
|
85
|
+
const a = new CmsApiClient({ baseUrl: 'https://cms.test.com///', clientId: 'x', clientSecret: 'y' });
|
|
86
|
+
expect(a.baseUrl).toBe('https://cms.test.com');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should initialize with null token', () => {
|
|
90
|
+
expect(api.accessToken).toBeNull();
|
|
91
|
+
expect(api.tokenExpiry).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── OAuth2 Authentication ──
|
|
96
|
+
|
|
97
|
+
describe('authenticate()', () => {
|
|
98
|
+
it('should POST client_credentials and store token', async () => {
|
|
99
|
+
mockFetch.mockResolvedValue(jsonResponse({
|
|
100
|
+
access_token: 'abc123',
|
|
101
|
+
expires_in: 3600,
|
|
102
|
+
token_type: 'Bearer'
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
const token = await api.authenticate();
|
|
106
|
+
|
|
107
|
+
expect(token).toBe('abc123');
|
|
108
|
+
expect(api.accessToken).toBe('abc123');
|
|
109
|
+
expect(api.tokenExpiry).toBeGreaterThan(Date.now());
|
|
110
|
+
|
|
111
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
112
|
+
expect(url).toBe(`${CMS_URL}/api/authorize/access_token`);
|
|
113
|
+
expect(opts.method).toBe('POST');
|
|
114
|
+
expect(opts.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
|
115
|
+
expect(opts.body.get('grant_type')).toBe('client_credentials');
|
|
116
|
+
expect(opts.body.get('client_id')).toBe(CLIENT_ID);
|
|
117
|
+
expect(opts.body.get('client_secret')).toBe(CLIENT_SECRET);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should throw on auth failure', async () => {
|
|
121
|
+
mockFetch.mockResolvedValue({
|
|
122
|
+
ok: false,
|
|
123
|
+
status: 401,
|
|
124
|
+
text: () => Promise.resolve('Invalid credentials')
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await expect(api.authenticate()).rejects.toThrow('OAuth2 authentication failed (401)');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should default to 3600s expiry if not provided', async () => {
|
|
131
|
+
mockFetch.mockResolvedValue(jsonResponse({ access_token: 'tok' }));
|
|
132
|
+
|
|
133
|
+
await api.authenticate();
|
|
134
|
+
|
|
135
|
+
// Token should expire roughly 3600s from now
|
|
136
|
+
const expectedExpiry = Date.now() + 3600 * 1000;
|
|
137
|
+
expect(api.tokenExpiry).toBeGreaterThan(expectedExpiry - 5000);
|
|
138
|
+
expect(api.tokenExpiry).toBeLessThan(expectedExpiry + 5000);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── Token Management ──
|
|
143
|
+
|
|
144
|
+
describe('ensureToken()', () => {
|
|
145
|
+
it('should authenticate if no token exists', async () => {
|
|
146
|
+
mockFetch.mockResolvedValue(jsonResponse({ access_token: 'new-token', expires_in: 3600 }));
|
|
147
|
+
|
|
148
|
+
await api.ensureToken();
|
|
149
|
+
|
|
150
|
+
expect(api.accessToken).toBe('new-token');
|
|
151
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should skip authentication if token is still valid', async () => {
|
|
155
|
+
stubAuth();
|
|
156
|
+
|
|
157
|
+
await api.ensureToken();
|
|
158
|
+
|
|
159
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should re-authenticate if token expires within 60 seconds', async () => {
|
|
163
|
+
api.accessToken = 'old-token';
|
|
164
|
+
api.tokenExpiry = Date.now() + 30000; // 30s left (< 60s threshold)
|
|
165
|
+
|
|
166
|
+
mockFetch.mockResolvedValue(jsonResponse({ access_token: 'refreshed', expires_in: 3600 }));
|
|
167
|
+
|
|
168
|
+
await api.ensureToken();
|
|
169
|
+
|
|
170
|
+
expect(api.accessToken).toBe('refreshed');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── Generic Request ──
|
|
175
|
+
|
|
176
|
+
describe('request()', () => {
|
|
177
|
+
beforeEach(() => stubAuth());
|
|
178
|
+
|
|
179
|
+
it('should make GET with query params', async () => {
|
|
180
|
+
mockFetch.mockResolvedValue(jsonResponse([{ id: 1 }]));
|
|
181
|
+
|
|
182
|
+
const result = await api.request('GET', '/display', { hardwareKey: 'abc', limit: 10 });
|
|
183
|
+
|
|
184
|
+
const [url] = mockFetch.mock.calls[0];
|
|
185
|
+
expect(url.toString()).toContain('/api/display');
|
|
186
|
+
expect(url.searchParams.get('hardwareKey')).toBe('abc');
|
|
187
|
+
expect(url.searchParams.get('limit')).toBe('10');
|
|
188
|
+
expect(result).toEqual([{ id: 1 }]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should skip null/undefined query params', async () => {
|
|
192
|
+
mockFetch.mockResolvedValue(jsonResponse([]));
|
|
193
|
+
|
|
194
|
+
await api.request('GET', '/display', { name: 'test', extra: null, undef: undefined });
|
|
195
|
+
|
|
196
|
+
const [url] = mockFetch.mock.calls[0];
|
|
197
|
+
expect(url.searchParams.has('name')).toBe(true);
|
|
198
|
+
expect(url.searchParams.has('extra')).toBe(false);
|
|
199
|
+
expect(url.searchParams.has('undef')).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should make POST with form-urlencoded body', async () => {
|
|
203
|
+
mockFetch.mockResolvedValue(jsonResponse({ layoutId: 42 }));
|
|
204
|
+
|
|
205
|
+
await api.request('POST', '/layout', { name: 'Test', resolutionId: 9 });
|
|
206
|
+
|
|
207
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
208
|
+
expect(opts.method).toBe('POST');
|
|
209
|
+
expect(opts.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
|
210
|
+
expect(opts.body.get('name')).toBe('Test');
|
|
211
|
+
expect(opts.body.get('resolutionId')).toBe('9');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should make PUT with form-urlencoded body', async () => {
|
|
215
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
216
|
+
|
|
217
|
+
await api.request('PUT', '/display/1', { display: 'Updated' });
|
|
218
|
+
|
|
219
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
220
|
+
expect(opts.method).toBe('PUT');
|
|
221
|
+
expect(opts.body.get('display')).toBe('Updated');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should make DELETE request', async () => {
|
|
225
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
226
|
+
|
|
227
|
+
await api.request('DELETE', '/layout/42');
|
|
228
|
+
|
|
229
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
230
|
+
expect(url.toString()).toContain('/api/layout/42');
|
|
231
|
+
expect(opts.method).toBe('DELETE');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should return null for non-JSON responses', async () => {
|
|
235
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
236
|
+
|
|
237
|
+
const result = await api.request('PUT', '/display/authorise/1');
|
|
238
|
+
|
|
239
|
+
expect(result).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should throw with parsed error message on failure', async () => {
|
|
243
|
+
mockFetch.mockResolvedValue(errorResponse(422, 'Validation failed'));
|
|
244
|
+
|
|
245
|
+
await expect(api.request('POST', '/layout', {})).rejects.toThrow('Validation failed');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should throw with raw text if error is not JSON', async () => {
|
|
249
|
+
mockFetch.mockResolvedValue({
|
|
250
|
+
ok: false,
|
|
251
|
+
status: 500,
|
|
252
|
+
headers: new Headers({}),
|
|
253
|
+
text: () => Promise.resolve('Internal Server Error')
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await expect(api.request('GET', '/fail')).rejects.toThrow('Internal Server Error');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should include Authorization header', async () => {
|
|
260
|
+
mockFetch.mockResolvedValue(jsonResponse([]));
|
|
261
|
+
|
|
262
|
+
await api.request('GET', '/display');
|
|
263
|
+
|
|
264
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
265
|
+
expect(opts.headers.Authorization).toBe('Bearer test-token');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── Multipart Request ──
|
|
270
|
+
|
|
271
|
+
describe('requestMultipart()', () => {
|
|
272
|
+
beforeEach(() => stubAuth());
|
|
273
|
+
|
|
274
|
+
it('should send FormData without Content-Type header', async () => {
|
|
275
|
+
mockFetch.mockResolvedValue(jsonResponse({ mediaId: 99 }));
|
|
276
|
+
|
|
277
|
+
const formData = new FormData();
|
|
278
|
+
formData.append('name', 'test.jpg');
|
|
279
|
+
|
|
280
|
+
await api.requestMultipart('POST', '/library', formData);
|
|
281
|
+
|
|
282
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
283
|
+
expect(opts.method).toBe('POST');
|
|
284
|
+
expect(opts.body).toBe(formData);
|
|
285
|
+
// Should NOT have Content-Type — browser adds multipart boundary
|
|
286
|
+
expect(opts.headers['Content-Type']).toBeUndefined();
|
|
287
|
+
expect(opts.headers.Authorization).toBe('Bearer test-token');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should throw on error', async () => {
|
|
291
|
+
mockFetch.mockResolvedValue(errorResponse(413, 'File too large'));
|
|
292
|
+
|
|
293
|
+
await expect(api.requestMultipart('POST', '/library', new FormData()))
|
|
294
|
+
.rejects.toThrow('File too large');
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── Display Management ──
|
|
299
|
+
|
|
300
|
+
describe('Display Management', () => {
|
|
301
|
+
beforeEach(() => stubAuth());
|
|
302
|
+
|
|
303
|
+
it('findDisplay() should return display object', async () => {
|
|
304
|
+
mockFetch.mockResolvedValue(jsonResponse([
|
|
305
|
+
{ displayId: 1, display: 'Test Display', licensed: 1 }
|
|
306
|
+
]));
|
|
307
|
+
|
|
308
|
+
const display = await api.findDisplay('pwa-abc123');
|
|
309
|
+
|
|
310
|
+
expect(display.displayId).toBe(1);
|
|
311
|
+
expect(display.display).toBe('Test Display');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('findDisplay() should return null when not found', async () => {
|
|
315
|
+
mockFetch.mockResolvedValue(jsonResponse([]));
|
|
316
|
+
|
|
317
|
+
const display = await api.findDisplay('nonexistent');
|
|
318
|
+
|
|
319
|
+
expect(display).toBeNull();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('authorizeDisplay() should PUT to correct path', async () => {
|
|
323
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
324
|
+
|
|
325
|
+
await api.authorizeDisplay(42);
|
|
326
|
+
|
|
327
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
328
|
+
expect(url.toString()).toContain('/display/authorise/42');
|
|
329
|
+
expect(opts.method).toBe('PUT');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('editDisplay() should PUT properties', async () => {
|
|
333
|
+
mockFetch.mockResolvedValue(jsonResponse({ displayId: 1, display: 'Updated' }));
|
|
334
|
+
|
|
335
|
+
const result = await api.editDisplay(1, { display: 'Updated', defaultLayoutId: 5 });
|
|
336
|
+
|
|
337
|
+
expect(result.display).toBe('Updated');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('listDisplays() should return array', async () => {
|
|
341
|
+
mockFetch.mockResolvedValue(jsonResponse([{ displayId: 1 }, { displayId: 2 }]));
|
|
342
|
+
|
|
343
|
+
const displays = await api.listDisplays();
|
|
344
|
+
|
|
345
|
+
expect(displays).toHaveLength(2);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('requestScreenshot() should PUT', async () => {
|
|
349
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
350
|
+
|
|
351
|
+
await api.requestScreenshot(5);
|
|
352
|
+
|
|
353
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
354
|
+
expect(url.toString()).toContain('/display/requestscreenshot/5');
|
|
355
|
+
expect(opts.method).toBe('PUT');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('getDisplayStatus() should GET', async () => {
|
|
359
|
+
mockFetch.mockResolvedValue(jsonResponse({ status: 1 }));
|
|
360
|
+
|
|
361
|
+
const status = await api.getDisplayStatus(5);
|
|
362
|
+
|
|
363
|
+
expect(status.status).toBe(1);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ── Layout Management ──
|
|
368
|
+
|
|
369
|
+
describe('Layout Management', () => {
|
|
370
|
+
beforeEach(() => stubAuth());
|
|
371
|
+
|
|
372
|
+
it('createLayout() should POST with name and resolutionId', async () => {
|
|
373
|
+
mockFetch.mockResolvedValue(jsonResponse({ layoutId: 10 }));
|
|
374
|
+
|
|
375
|
+
const layout = await api.createLayout({ name: 'Test Layout', resolutionId: 9 });
|
|
376
|
+
|
|
377
|
+
expect(layout.layoutId).toBe(10);
|
|
378
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
379
|
+
expect(opts.body.get('name')).toBe('Test Layout');
|
|
380
|
+
expect(opts.body.get('resolutionId')).toBe('9');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('createLayout() should include description if provided', async () => {
|
|
384
|
+
mockFetch.mockResolvedValue(jsonResponse({ layoutId: 11 }));
|
|
385
|
+
|
|
386
|
+
await api.createLayout({ name: 'Test', resolutionId: 9, description: 'A test layout' });
|
|
387
|
+
|
|
388
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
389
|
+
expect(opts.body.get('description')).toBe('A test layout');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('listLayouts() should GET with filters', async () => {
|
|
393
|
+
mockFetch.mockResolvedValue(jsonResponse([{ layoutId: 1 }]));
|
|
394
|
+
|
|
395
|
+
const layouts = await api.listLayouts({ layout: 'Test' });
|
|
396
|
+
|
|
397
|
+
const [url] = mockFetch.mock.calls[0];
|
|
398
|
+
expect(url.searchParams.get('layout')).toBe('Test');
|
|
399
|
+
expect(layouts).toHaveLength(1);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('getLayout() should GET single layout', async () => {
|
|
403
|
+
mockFetch.mockResolvedValue(jsonResponse({ layoutId: 10, layout: 'My Layout' }));
|
|
404
|
+
|
|
405
|
+
const layout = await api.getLayout(10);
|
|
406
|
+
|
|
407
|
+
const [url] = mockFetch.mock.calls[0];
|
|
408
|
+
expect(url.toString()).toContain('/layout/10');
|
|
409
|
+
expect(layout.layout).toBe('My Layout');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('deleteLayout() should DELETE', async () => {
|
|
413
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
414
|
+
|
|
415
|
+
await api.deleteLayout(10);
|
|
416
|
+
|
|
417
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
418
|
+
expect(url.toString()).toContain('/layout/10');
|
|
419
|
+
expect(opts.method).toBe('DELETE');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('publishLayout() should PUT to /layout/publish/{id}', async () => {
|
|
423
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
424
|
+
|
|
425
|
+
await api.publishLayout(10);
|
|
426
|
+
|
|
427
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
428
|
+
expect(url.toString()).toContain('/layout/publish/10');
|
|
429
|
+
expect(opts.method).toBe('PUT');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('checkoutLayout() should PUT to /layout/checkout/{id}', async () => {
|
|
433
|
+
mockFetch.mockResolvedValue(jsonResponse({ layoutId: 20 }));
|
|
434
|
+
|
|
435
|
+
const draft = await api.checkoutLayout(10);
|
|
436
|
+
|
|
437
|
+
const [url] = mockFetch.mock.calls[0];
|
|
438
|
+
expect(url.toString()).toContain('/layout/checkout/10');
|
|
439
|
+
expect(draft.layoutId).toBe(20);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('editLayoutBackground() should PUT background params', async () => {
|
|
443
|
+
mockFetch.mockResolvedValue(jsonResponse({ layoutId: 10 }));
|
|
444
|
+
|
|
445
|
+
await api.editLayoutBackground(10, { backgroundColor: '#FF0000', backgroundImageId: 5 });
|
|
446
|
+
|
|
447
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
448
|
+
expect(url.toString()).toContain('/layout/background/10');
|
|
449
|
+
expect(opts.body.get('backgroundColor')).toBe('#FF0000');
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ── Region Management ──
|
|
454
|
+
|
|
455
|
+
describe('Region Management', () => {
|
|
456
|
+
beforeEach(() => stubAuth());
|
|
457
|
+
|
|
458
|
+
it('addRegion() should POST to /region/{layoutId}', async () => {
|
|
459
|
+
mockFetch.mockResolvedValue(jsonResponse({
|
|
460
|
+
regionId: 5,
|
|
461
|
+
playlists: [{ playlistId: 100 }]
|
|
462
|
+
}));
|
|
463
|
+
|
|
464
|
+
const region = await api.addRegion(10, { width: 1920, height: 1080, top: 0, left: 0 });
|
|
465
|
+
|
|
466
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
467
|
+
expect(url.toString()).toContain('/region/10');
|
|
468
|
+
expect(opts.method).toBe('POST');
|
|
469
|
+
expect(opts.body.get('width')).toBe('1920');
|
|
470
|
+
expect(region.playlists[0].playlistId).toBe(100);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('editRegion() should PUT', async () => {
|
|
474
|
+
mockFetch.mockResolvedValue(jsonResponse({ regionId: 5 }));
|
|
475
|
+
|
|
476
|
+
await api.editRegion(5, { width: 960, height: 540 });
|
|
477
|
+
|
|
478
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
479
|
+
expect(url.toString()).toContain('/region/5');
|
|
480
|
+
expect(opts.method).toBe('PUT');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('deleteRegion() should DELETE', async () => {
|
|
484
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
485
|
+
|
|
486
|
+
await api.deleteRegion(5);
|
|
487
|
+
|
|
488
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
489
|
+
expect(url.toString()).toContain('/region/5');
|
|
490
|
+
expect(opts.method).toBe('DELETE');
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// ── Widget Management ──
|
|
495
|
+
|
|
496
|
+
describe('Widget Management', () => {
|
|
497
|
+
beforeEach(() => stubAuth());
|
|
498
|
+
|
|
499
|
+
it('addWidget() should POST to /playlist/widget/{type}/{playlistId}', async () => {
|
|
500
|
+
// addWidget() is a two-step process:
|
|
501
|
+
// Step 1: POST creates the widget shell (only templateId/displayOrder)
|
|
502
|
+
// Step 2: PUT sets all widget properties (text, duration, etc.)
|
|
503
|
+
mockFetch
|
|
504
|
+
.mockResolvedValueOnce(jsonResponse({ widgetId: 77 })) // POST create
|
|
505
|
+
.mockResolvedValueOnce(jsonResponse({ widgetId: 77 })); // PUT edit
|
|
506
|
+
|
|
507
|
+
const widget = await api.addWidget('text', 100, { text: 'Hello', duration: 10 });
|
|
508
|
+
|
|
509
|
+
// First call: POST to create the widget
|
|
510
|
+
const [url1, opts1] = mockFetch.mock.calls[0];
|
|
511
|
+
expect(url1.toString()).toContain('/playlist/widget/text/100');
|
|
512
|
+
expect(opts1.method).toBe('POST');
|
|
513
|
+
|
|
514
|
+
// Second call: PUT to set properties (text, duration, useDuration)
|
|
515
|
+
const [url2, opts2] = mockFetch.mock.calls[1];
|
|
516
|
+
expect(url2.toString()).toContain('/playlist/widget/77');
|
|
517
|
+
expect(opts2.method).toBe('PUT');
|
|
518
|
+
expect(opts2.body.get('text')).toBe('Hello');
|
|
519
|
+
expect(opts2.body.get('duration')).toBe('10');
|
|
520
|
+
expect(widget.widgetId).toBe(77);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('editWidget() should PUT to /playlist/widget/{widgetId}', async () => {
|
|
524
|
+
mockFetch.mockResolvedValue(jsonResponse({ widgetId: 77 }));
|
|
525
|
+
|
|
526
|
+
await api.editWidget(77, { text: 'Updated' });
|
|
527
|
+
|
|
528
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
529
|
+
expect(url.toString()).toContain('/playlist/widget/77');
|
|
530
|
+
expect(opts.method).toBe('PUT');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('deleteWidget() should DELETE', async () => {
|
|
534
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
535
|
+
|
|
536
|
+
await api.deleteWidget(77);
|
|
537
|
+
|
|
538
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
539
|
+
expect(url.toString()).toContain('/playlist/widget/77');
|
|
540
|
+
expect(opts.method).toBe('DELETE');
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// ── Media / Library ──
|
|
545
|
+
|
|
546
|
+
describe('Media / Library', () => {
|
|
547
|
+
beforeEach(() => stubAuth());
|
|
548
|
+
|
|
549
|
+
it('uploadMedia() should use requestMultipart', async () => {
|
|
550
|
+
mockFetch.mockResolvedValue(jsonResponse({ mediaId: 50 }));
|
|
551
|
+
|
|
552
|
+
const formData = new FormData();
|
|
553
|
+
formData.append('files', new Blob(['test']), 'test.jpg');
|
|
554
|
+
|
|
555
|
+
const result = await api.uploadMedia(formData);
|
|
556
|
+
|
|
557
|
+
expect(result.mediaId).toBe(50);
|
|
558
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
559
|
+
expect(opts.body).toBe(formData);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('listMedia() should GET with filters', async () => {
|
|
563
|
+
mockFetch.mockResolvedValue(jsonResponse([{ mediaId: 1 }, { mediaId: 2 }]));
|
|
564
|
+
|
|
565
|
+
const media = await api.listMedia({ type: 'image' });
|
|
566
|
+
|
|
567
|
+
const [url] = mockFetch.mock.calls[0];
|
|
568
|
+
expect(url.searchParams.get('type')).toBe('image');
|
|
569
|
+
expect(media).toHaveLength(2);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('getMedia() should GET single media', async () => {
|
|
573
|
+
mockFetch.mockResolvedValue(jsonResponse({ mediaId: 50, name: 'test.jpg' }));
|
|
574
|
+
|
|
575
|
+
const media = await api.getMedia(50);
|
|
576
|
+
|
|
577
|
+
expect(media.name).toBe('test.jpg');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('deleteMedia() should DELETE', async () => {
|
|
581
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
582
|
+
|
|
583
|
+
await api.deleteMedia(50);
|
|
584
|
+
|
|
585
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
586
|
+
expect(url.toString()).toContain('/library/50');
|
|
587
|
+
expect(opts.method).toBe('DELETE');
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// ── Campaign Management ──
|
|
592
|
+
|
|
593
|
+
describe('Campaign Management', () => {
|
|
594
|
+
beforeEach(() => stubAuth());
|
|
595
|
+
|
|
596
|
+
it('createCampaign() should POST with name', async () => {
|
|
597
|
+
mockFetch.mockResolvedValue(jsonResponse({ campaignId: 20 }));
|
|
598
|
+
|
|
599
|
+
const campaign = await api.createCampaign('Test Campaign');
|
|
600
|
+
|
|
601
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
602
|
+
expect(opts.body.get('name')).toBe('Test Campaign');
|
|
603
|
+
expect(campaign.campaignId).toBe(20);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('listCampaigns() should GET', async () => {
|
|
607
|
+
mockFetch.mockResolvedValue(jsonResponse([{ campaignId: 1 }]));
|
|
608
|
+
|
|
609
|
+
const campaigns = await api.listCampaigns();
|
|
610
|
+
|
|
611
|
+
expect(campaigns).toHaveLength(1);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('deleteCampaign() should DELETE', async () => {
|
|
615
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
616
|
+
|
|
617
|
+
await api.deleteCampaign(20);
|
|
618
|
+
|
|
619
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
620
|
+
expect(url.toString()).toContain('/campaign/20');
|
|
621
|
+
expect(opts.method).toBe('DELETE');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('assignLayoutToCampaign() should POST with layoutId', async () => {
|
|
625
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
626
|
+
|
|
627
|
+
await api.assignLayoutToCampaign(20, 10);
|
|
628
|
+
|
|
629
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
630
|
+
expect(url.toString()).toContain('/campaign/layout/assign/20');
|
|
631
|
+
expect(opts.body.get('layoutId')).toBe('10');
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('assignLayoutToCampaign() should include displayOrder if provided', async () => {
|
|
635
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
636
|
+
|
|
637
|
+
await api.assignLayoutToCampaign(20, 10, 3);
|
|
638
|
+
|
|
639
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
640
|
+
expect(opts.body.get('displayOrder')).toBe('3');
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// ── Schedule Management ──
|
|
645
|
+
|
|
646
|
+
describe('Schedule Management', () => {
|
|
647
|
+
beforeEach(() => stubAuth());
|
|
648
|
+
|
|
649
|
+
it('createSchedule() should POST with displayGroupIds as array params', async () => {
|
|
650
|
+
mockFetch.mockResolvedValue(jsonResponse({ eventId: 99 }));
|
|
651
|
+
|
|
652
|
+
await api.createSchedule({
|
|
653
|
+
eventTypeId: 1,
|
|
654
|
+
campaignId: 20,
|
|
655
|
+
displayGroupIds: [1, 2],
|
|
656
|
+
fromDt: '2026-01-01 00:00:00',
|
|
657
|
+
toDt: '2026-12-31 23:59:59',
|
|
658
|
+
isPriority: 0
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
662
|
+
expect(url).toBe(`${CMS_URL}/api/schedule`);
|
|
663
|
+
expect(opts.method).toBe('POST');
|
|
664
|
+
|
|
665
|
+
// Verify array params are sent as displayGroupIds[]
|
|
666
|
+
const body = opts.body;
|
|
667
|
+
expect(body.getAll('displayGroupIds[]')).toEqual(['1', '2']);
|
|
668
|
+
expect(body.get('eventTypeId')).toBe('1');
|
|
669
|
+
expect(body.get('campaignId')).toBe('20');
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('deleteSchedule() should DELETE', async () => {
|
|
673
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
674
|
+
|
|
675
|
+
await api.deleteSchedule(99);
|
|
676
|
+
|
|
677
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
678
|
+
expect(url.toString()).toContain('/schedule/99');
|
|
679
|
+
expect(opts.method).toBe('DELETE');
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it('listSchedules() should GET events', async () => {
|
|
683
|
+
mockFetch.mockResolvedValue(jsonResponse({ events: [{ eventId: 1 }] }));
|
|
684
|
+
|
|
685
|
+
const schedules = await api.listSchedules();
|
|
686
|
+
|
|
687
|
+
expect(schedules).toEqual([{ eventId: 1 }]);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('listSchedules() should handle direct array response', async () => {
|
|
691
|
+
mockFetch.mockResolvedValue(jsonResponse([{ eventId: 1 }]));
|
|
692
|
+
|
|
693
|
+
const schedules = await api.listSchedules();
|
|
694
|
+
|
|
695
|
+
expect(schedules).toEqual([{ eventId: 1 }]);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// ── Display Group Management ──
|
|
700
|
+
|
|
701
|
+
describe('Display Group Management', () => {
|
|
702
|
+
beforeEach(() => stubAuth());
|
|
703
|
+
|
|
704
|
+
it('listDisplayGroups() should GET', async () => {
|
|
705
|
+
mockFetch.mockResolvedValue(jsonResponse([{ displayGroupId: 1, displayGroup: 'Group 1' }]));
|
|
706
|
+
|
|
707
|
+
const groups = await api.listDisplayGroups();
|
|
708
|
+
|
|
709
|
+
expect(groups).toHaveLength(1);
|
|
710
|
+
expect(groups[0].displayGroup).toBe('Group 1');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('createDisplayGroup() should POST with displayGroup param', async () => {
|
|
714
|
+
mockFetch.mockResolvedValue(jsonResponse({ displayGroupId: 5 }));
|
|
715
|
+
|
|
716
|
+
const group = await api.createDisplayGroup('Test Group', 'A test group');
|
|
717
|
+
|
|
718
|
+
const [, opts] = mockFetch.mock.calls[0];
|
|
719
|
+
expect(opts.body.get('displayGroup')).toBe('Test Group');
|
|
720
|
+
expect(opts.body.get('description')).toBe('A test group');
|
|
721
|
+
expect(group.displayGroupId).toBe(5);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('deleteDisplayGroup() should DELETE', async () => {
|
|
725
|
+
mockFetch.mockResolvedValue(emptyResponse());
|
|
726
|
+
|
|
727
|
+
await api.deleteDisplayGroup(5);
|
|
728
|
+
|
|
729
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
730
|
+
expect(url.toString()).toContain('/displaygroup/5');
|
|
731
|
+
expect(opts.method).toBe('DELETE');
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('assignDisplayToGroup() should POST displayId[] param', async () => {
|
|
735
|
+
mockFetch.mockResolvedValue({ ok: true, status: 200, headers: new Headers({}) });
|
|
736
|
+
|
|
737
|
+
await api.assignDisplayToGroup(5, 42);
|
|
738
|
+
|
|
739
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
740
|
+
expect(url).toBe(`${CMS_URL}/api/displaygroup/5/display/assign`);
|
|
741
|
+
expect(opts.method).toBe('POST');
|
|
742
|
+
expect(opts.body.getAll('displayId[]')).toEqual(['42']);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('assignDisplayToGroup() should throw on error', async () => {
|
|
746
|
+
mockFetch.mockResolvedValue({
|
|
747
|
+
ok: false,
|
|
748
|
+
status: 404,
|
|
749
|
+
headers: new Headers({}),
|
|
750
|
+
text: () => Promise.resolve('Not found')
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
await expect(api.assignDisplayToGroup(5, 42))
|
|
754
|
+
.rejects.toThrow('assign display to group failed (404)');
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('unassignDisplayFromGroup() should POST displayId[] param to unassign path', async () => {
|
|
758
|
+
mockFetch.mockResolvedValue({ ok: true, status: 200, headers: new Headers({}) });
|
|
759
|
+
|
|
760
|
+
await api.unassignDisplayFromGroup(5, 42);
|
|
761
|
+
|
|
762
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
763
|
+
expect(url).toBe(`${CMS_URL}/api/displaygroup/5/display/unassign`);
|
|
764
|
+
expect(opts.body.getAll('displayId[]')).toEqual(['42']);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// ── Resolution Management ──
|
|
769
|
+
|
|
770
|
+
describe('Resolution Management', () => {
|
|
771
|
+
beforeEach(() => stubAuth());
|
|
772
|
+
|
|
773
|
+
it('listResolutions() should GET', async () => {
|
|
774
|
+
mockFetch.mockResolvedValue(jsonResponse([
|
|
775
|
+
{ resolutionId: 9, resolution: '1080p HD', width: 1920, height: 1080 }
|
|
776
|
+
]));
|
|
777
|
+
|
|
778
|
+
const resolutions = await api.listResolutions();
|
|
779
|
+
|
|
780
|
+
expect(resolutions).toHaveLength(1);
|
|
781
|
+
expect(resolutions[0].width).toBe(1920);
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// ── Token Auto-Refresh Integration ──
|
|
786
|
+
|
|
787
|
+
describe('Token auto-refresh', () => {
|
|
788
|
+
it('should auto-authenticate on first request', async () => {
|
|
789
|
+
// First call = authenticate, second call = actual request
|
|
790
|
+
mockFetch
|
|
791
|
+
.mockResolvedValueOnce(jsonResponse({ access_token: 'auto-token', expires_in: 3600 }))
|
|
792
|
+
.mockResolvedValueOnce(jsonResponse([]));
|
|
793
|
+
|
|
794
|
+
await api.listDisplays();
|
|
795
|
+
|
|
796
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
797
|
+
// First call should be auth
|
|
798
|
+
expect(mockFetch.mock.calls[0][0]).toContain('/authorize/access_token');
|
|
799
|
+
// Second call should be the actual API request
|
|
800
|
+
expect(mockFetch.mock.calls[1][0].toString()).toContain('/display');
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
});
|