@xiboplayer/cache 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/CACHE_PROXY_ARCHITECTURE.md +439 -0
- package/docs/README.md +118 -0
- package/package.json +41 -0
- package/src/cache-proxy.js +493 -0
- package/src/cache-proxy.test.js +391 -0
- package/src/cache.js +739 -0
- package/src/cache.test.js +760 -0
- package/src/download-manager.js +434 -0
- package/src/download-manager.test.js +726 -0
- package/src/index.js +4 -0
- package/src/test-utils.js +133 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache Manager Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for file caching with Cache API + IndexedDB
|
|
5
|
+
* Including large file downloads with parallel chunking
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
import { CacheManager } from './cache.js';
|
|
10
|
+
|
|
11
|
+
describe('CacheManager', () => {
|
|
12
|
+
let manager;
|
|
13
|
+
let mockCache;
|
|
14
|
+
let mockDB;
|
|
15
|
+
let mockIndexedDB;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
// Mock Cache API — stores raw text/blob and headers as plain objects
|
|
19
|
+
mockCache = {
|
|
20
|
+
_storage: new Map(),
|
|
21
|
+
async match(key) {
|
|
22
|
+
const keyStr = typeof key === 'string' ? key : key.toString();
|
|
23
|
+
const entry = this._storage.get(keyStr);
|
|
24
|
+
if (!entry) return undefined;
|
|
25
|
+
|
|
26
|
+
// Reconstruct a real Response from stored data
|
|
27
|
+
return new Response(entry.body, {
|
|
28
|
+
headers: entry.headers
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
async put(key, response) {
|
|
32
|
+
const keyStr = typeof key === 'string' ? key : key.toString();
|
|
33
|
+
// Read the body as text (preserves strings) and store headers as plain object
|
|
34
|
+
const bodyText = await response.text();
|
|
35
|
+
const headers = {};
|
|
36
|
+
response.headers.forEach((value, name) => {
|
|
37
|
+
headers[name] = value;
|
|
38
|
+
});
|
|
39
|
+
this._storage.set(keyStr, { body: bodyText, headers });
|
|
40
|
+
},
|
|
41
|
+
async delete(key) {
|
|
42
|
+
const keyStr = typeof key === 'string' ? key : key.toString();
|
|
43
|
+
return this._storage.delete(keyStr);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Setup global mocks
|
|
48
|
+
global.caches = {
|
|
49
|
+
async open() {
|
|
50
|
+
return mockCache;
|
|
51
|
+
},
|
|
52
|
+
async delete() {
|
|
53
|
+
mockCache._storage.clear();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Use real fake-indexeddb (provided by vitest.setup.js)
|
|
58
|
+
// Clean the database between tests to avoid data leaking
|
|
59
|
+
const deleteRequest = indexedDB.deleteDatabase('xibo-player');
|
|
60
|
+
await new Promise((resolve) => {
|
|
61
|
+
deleteRequest.onsuccess = resolve;
|
|
62
|
+
deleteRequest.onerror = resolve;
|
|
63
|
+
deleteRequest.onblocked = resolve;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Mock @xiboplayer/utils config
|
|
67
|
+
vi.mock('@xiboplayer/utils', () => ({
|
|
68
|
+
config: {
|
|
69
|
+
cmsAddress: 'https://test.cms.com'
|
|
70
|
+
}
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// Mock fetch
|
|
74
|
+
global.fetch = vi.fn();
|
|
75
|
+
|
|
76
|
+
// Mock window events
|
|
77
|
+
global.window = {
|
|
78
|
+
dispatchEvent: vi.fn(),
|
|
79
|
+
location: {
|
|
80
|
+
origin: 'https://test.cms.com',
|
|
81
|
+
pathname: '/player/pwa/index.html'
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Mock navigator.serviceWorker
|
|
86
|
+
global.navigator = {
|
|
87
|
+
serviceWorker: {
|
|
88
|
+
controller: null
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
manager = new CacheManager();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
vi.clearAllMocks();
|
|
97
|
+
// Close any open DB connections to allow deleteDatabase in next beforeEach
|
|
98
|
+
if (manager && manager.db) {
|
|
99
|
+
manager.db.close();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('Initialization', () => {
|
|
104
|
+
it('should initialize cache and database', async () => {
|
|
105
|
+
await manager.init();
|
|
106
|
+
|
|
107
|
+
expect(manager.cache).toBeDefined();
|
|
108
|
+
expect(manager.db).toBeDefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('extractFilename()', () => {
|
|
113
|
+
it('should extract filename from URL query parameter', () => {
|
|
114
|
+
const filename = manager.extractFilename('https://test.com/xmds.php?file=image.jpg&key=123');
|
|
115
|
+
|
|
116
|
+
expect(filename).toBe('image.jpg');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should return "unknown" for invalid URL', () => {
|
|
120
|
+
const filename = manager.extractFilename('not-a-url');
|
|
121
|
+
|
|
122
|
+
expect(filename).toBe('unknown');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should return "unknown" when file parameter missing', () => {
|
|
126
|
+
const filename = manager.extractFilename('https://test.com/xmds.php?other=param');
|
|
127
|
+
|
|
128
|
+
expect(filename).toBe('unknown');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('rewriteUrl()', () => {
|
|
133
|
+
it('should rewrite URL to use configured CMS address', () => {
|
|
134
|
+
const rewritten = manager.rewriteUrl('https://different.com/path?file=test.jpg');
|
|
135
|
+
|
|
136
|
+
expect(rewritten).toContain('test.cms.com');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should not rewrite URL if same origin', () => {
|
|
140
|
+
const original = 'https://test.cms.com/path?file=test.jpg';
|
|
141
|
+
const rewritten = manager.rewriteUrl(original);
|
|
142
|
+
|
|
143
|
+
expect(rewritten).toBe(original);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should return URL as-is if invalid', () => {
|
|
147
|
+
const invalid = 'not-a-url';
|
|
148
|
+
const rewritten = manager.rewriteUrl(invalid);
|
|
149
|
+
|
|
150
|
+
expect(rewritten).toBe(invalid);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should handle null/undefined URLs', () => {
|
|
154
|
+
expect(manager.rewriteUrl(null)).toBeNull();
|
|
155
|
+
expect(manager.rewriteUrl(undefined)).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('getCacheKey()', () => {
|
|
160
|
+
it('should generate cache key with type and id', () => {
|
|
161
|
+
const key = manager.getCacheKey('media', '123');
|
|
162
|
+
|
|
163
|
+
expect(key).toBe('/player/pwa/cache/media/123');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should use filename if provided', () => {
|
|
167
|
+
const key = manager.getCacheKey('media', '123', 'image.jpg');
|
|
168
|
+
|
|
169
|
+
expect(key).toBe('/player/pwa/cache/media/image.jpg');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should handle layout type', () => {
|
|
173
|
+
const key = manager.getCacheKey('layout', '100');
|
|
174
|
+
|
|
175
|
+
expect(key).toBe('/player/pwa/cache/layout/100');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('File Record Management', () => {
|
|
180
|
+
beforeEach(async () => {
|
|
181
|
+
await manager.init();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should save file record', async () => {
|
|
185
|
+
const record = {
|
|
186
|
+
id: '1',
|
|
187
|
+
type: 'media',
|
|
188
|
+
path: 'http://test.com/file.mp4',
|
|
189
|
+
md5: 'abc123',
|
|
190
|
+
size: 1024,
|
|
191
|
+
cachedAt: Date.now()
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
await manager.saveFile(record);
|
|
195
|
+
const retrieved = await manager.getFile('1');
|
|
196
|
+
|
|
197
|
+
expect(retrieved).toEqual(record);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should get all files', async () => {
|
|
201
|
+
await manager.saveFile({ id: '1', type: 'media' });
|
|
202
|
+
await manager.saveFile({ id: '2', type: 'layout' });
|
|
203
|
+
|
|
204
|
+
const files = await manager.getAllFiles();
|
|
205
|
+
|
|
206
|
+
expect(files).toHaveLength(2);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should return undefined for non-existent file', async () => {
|
|
210
|
+
const file = await manager.getFile('non-existent');
|
|
211
|
+
|
|
212
|
+
expect(file).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('downloadFile() - Small Files', () => {
|
|
217
|
+
beforeEach(async () => {
|
|
218
|
+
await manager.init();
|
|
219
|
+
|
|
220
|
+
// Helper: create a mock blob with arrayBuffer() support
|
|
221
|
+
function createMockBlob(content, type) {
|
|
222
|
+
const blob = new Blob([content], { type });
|
|
223
|
+
// Polyfill arrayBuffer() for jsdom environments that lack it
|
|
224
|
+
if (!blob.arrayBuffer) {
|
|
225
|
+
blob.arrayBuffer = async () => {
|
|
226
|
+
const reader = new FileReader();
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
reader.onload = () => resolve(reader.result);
|
|
229
|
+
reader.readAsArrayBuffer(blob);
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return blob;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Mock successful download
|
|
237
|
+
global.fetch.mockImplementation(async (url, options) => {
|
|
238
|
+
if (options?.method === 'HEAD') {
|
|
239
|
+
return {
|
|
240
|
+
ok: true,
|
|
241
|
+
status: 200,
|
|
242
|
+
headers: {
|
|
243
|
+
get: (name) => name === 'Content-Length' ? '1024' : null
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const blob = createMockBlob('test data', 'image/jpeg');
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
status: 200,
|
|
252
|
+
headers: {
|
|
253
|
+
get: (name) => name === 'Content-Type' ? 'image/jpeg' : null
|
|
254
|
+
},
|
|
255
|
+
blob: async () => blob
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Mock SparkMD5
|
|
260
|
+
vi.mock('spark-md5', () => ({
|
|
261
|
+
default: {
|
|
262
|
+
ArrayBuffer: {
|
|
263
|
+
hash: () => 'abc123'
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should download and cache file', async () => {
|
|
270
|
+
const fileInfo = {
|
|
271
|
+
id: '1',
|
|
272
|
+
type: 'media',
|
|
273
|
+
path: 'http://test.com/file.jpg',
|
|
274
|
+
md5: 'abc123',
|
|
275
|
+
download: 'http'
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const result = await manager.downloadFile(fileInfo);
|
|
279
|
+
|
|
280
|
+
expect(result).toMatchObject({
|
|
281
|
+
id: '1',
|
|
282
|
+
type: 'media',
|
|
283
|
+
md5: 'abc123'
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should skip download if already cached with matching MD5', async () => {
|
|
288
|
+
// Pre-cache file
|
|
289
|
+
await manager.saveFile({
|
|
290
|
+
id: '1',
|
|
291
|
+
type: 'media',
|
|
292
|
+
md5: 'abc123',
|
|
293
|
+
path: 'http://test.com/file.jpg',
|
|
294
|
+
size: 1024,
|
|
295
|
+
cachedAt: Date.now()
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Use content >= 100 bytes to avoid "tiny file" corruption check
|
|
299
|
+
const largeContent = 'x'.repeat(200);
|
|
300
|
+
const cacheKey = manager.getCacheKey('media', '1');
|
|
301
|
+
await mockCache.put(cacheKey, new Response(largeContent, {
|
|
302
|
+
headers: { 'Content-Type': 'image/jpeg' }
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
const fileInfo = {
|
|
306
|
+
id: '1',
|
|
307
|
+
type: 'media',
|
|
308
|
+
path: 'http://test.com/file.jpg',
|
|
309
|
+
md5: 'abc123'
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const result = await manager.downloadFile(fileInfo);
|
|
313
|
+
|
|
314
|
+
expect(result.md5).toBe('abc123');
|
|
315
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should re-download if MD5 mismatch', async () => {
|
|
319
|
+
// Pre-cache file with different MD5
|
|
320
|
+
await manager.saveFile({
|
|
321
|
+
id: '1',
|
|
322
|
+
type: 'media',
|
|
323
|
+
md5: 'old-md5',
|
|
324
|
+
path: 'http://test.com/file.jpg',
|
|
325
|
+
size: 1024,
|
|
326
|
+
cachedAt: Date.now()
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const fileInfo = {
|
|
330
|
+
id: '1',
|
|
331
|
+
type: 'media',
|
|
332
|
+
path: 'http://test.com/file.jpg',
|
|
333
|
+
md5: 'new-md5'
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
await manager.downloadFile(fileInfo);
|
|
337
|
+
|
|
338
|
+
expect(global.fetch).toHaveBeenCalled();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should skip files with no download URL', async () => {
|
|
342
|
+
const fileInfo = {
|
|
343
|
+
id: '1',
|
|
344
|
+
type: 'resource',
|
|
345
|
+
path: null
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const result = await manager.downloadFile(fileInfo);
|
|
349
|
+
|
|
350
|
+
expect(result).toBeNull();
|
|
351
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should handle HTTP errors', async () => {
|
|
355
|
+
global.fetch.mockImplementation(async (url, options) => {
|
|
356
|
+
if (options?.method === 'HEAD') {
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
status: 200,
|
|
360
|
+
headers: { get: (name) => name === 'Content-Length' ? '1024' : null }
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
// GET download returns error
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
status: 404,
|
|
367
|
+
headers: { get: () => null }
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const fileInfo = {
|
|
372
|
+
id: '1',
|
|
373
|
+
type: 'media',
|
|
374
|
+
path: 'http://test.com/file.jpg'
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
await expect(manager.downloadFile(fileInfo)).rejects.toThrow('Failed to download');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should delete corrupted cache (text/plain responses)', async () => {
|
|
381
|
+
// Pre-cache corrupted file
|
|
382
|
+
await manager.saveFile({
|
|
383
|
+
id: '1',
|
|
384
|
+
type: 'media',
|
|
385
|
+
md5: 'abc123',
|
|
386
|
+
path: 'http://test.com/file.jpg',
|
|
387
|
+
size: 50,
|
|
388
|
+
cachedAt: Date.now()
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const cacheKey = manager.getCacheKey('media', '1');
|
|
392
|
+
await mockCache.put(cacheKey, new Response(new Blob(['error']), {
|
|
393
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
394
|
+
}));
|
|
395
|
+
|
|
396
|
+
const fileInfo = {
|
|
397
|
+
id: '1',
|
|
398
|
+
type: 'media',
|
|
399
|
+
path: 'http://test.com/file.jpg',
|
|
400
|
+
md5: 'abc123'
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
await manager.downloadFile(fileInfo);
|
|
404
|
+
|
|
405
|
+
// Should re-download
|
|
406
|
+
expect(global.fetch).toHaveBeenCalled();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should handle Service Worker active (skip download)', async () => {
|
|
410
|
+
global.navigator.serviceWorker.controller = {}; // SW active
|
|
411
|
+
|
|
412
|
+
const fileInfo = {
|
|
413
|
+
id: '1',
|
|
414
|
+
type: 'media',
|
|
415
|
+
path: 'http://test.com/file.jpg',
|
|
416
|
+
md5: 'abc123'
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const result = await manager.downloadFile(fileInfo);
|
|
420
|
+
|
|
421
|
+
expect(result.isServiceWorkerDownload).toBe(true);
|
|
422
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
423
|
+
|
|
424
|
+
global.navigator.serviceWorker.controller = null; // Reset
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should handle HTTP 202 (background download pending)', async () => {
|
|
428
|
+
// HEAD request returns 202 (background download in progress)
|
|
429
|
+
global.fetch.mockImplementation(async (url, options) => {
|
|
430
|
+
return {
|
|
431
|
+
ok: true,
|
|
432
|
+
status: 202,
|
|
433
|
+
headers: { get: () => null }
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const fileInfo = {
|
|
438
|
+
id: '1',
|
|
439
|
+
type: 'media',
|
|
440
|
+
path: 'http://test.com/file.jpg',
|
|
441
|
+
md5: 'abc123'
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const result = await manager.downloadFile(fileInfo);
|
|
445
|
+
|
|
446
|
+
expect(result.isPending).toBe(true);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe('downloadFile() - Large Files', () => {
|
|
451
|
+
beforeEach(async () => {
|
|
452
|
+
await manager.init();
|
|
453
|
+
|
|
454
|
+
// Mock large file (>100MB)
|
|
455
|
+
global.fetch.mockImplementation(async (url, options) => {
|
|
456
|
+
if (options?.method === 'HEAD') {
|
|
457
|
+
return {
|
|
458
|
+
ok: true,
|
|
459
|
+
headers: {
|
|
460
|
+
get: (name) => name === 'Content-Length' ? '200000000' : null // 200 MB
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
ok: true,
|
|
467
|
+
status: 200,
|
|
468
|
+
headers: {
|
|
469
|
+
get: (name) => name === 'Content-Type' ? 'video/mp4' : null
|
|
470
|
+
},
|
|
471
|
+
blob: async () => new Blob(['chunk data'])
|
|
472
|
+
};
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('should start background download for large files', async () => {
|
|
477
|
+
const fileInfo = {
|
|
478
|
+
id: '1',
|
|
479
|
+
type: 'media',
|
|
480
|
+
path: 'http://test.com/large-video.mp4',
|
|
481
|
+
md5: 'abc123'
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const result = await manager.downloadFile(fileInfo);
|
|
485
|
+
|
|
486
|
+
expect(result.isBackgroundDownload).toBe(true);
|
|
487
|
+
expect(result.size).toBe(200000000);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('should return immediately for large files (non-blocking)', async () => {
|
|
491
|
+
const fileInfo = {
|
|
492
|
+
id: '1',
|
|
493
|
+
type: 'media',
|
|
494
|
+
path: 'http://test.com/large-video.mp4',
|
|
495
|
+
md5: 'abc123'
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const startTime = Date.now();
|
|
499
|
+
await manager.downloadFile(fileInfo);
|
|
500
|
+
const duration = Date.now() - startTime;
|
|
501
|
+
|
|
502
|
+
// Should return in <100ms (not wait for download)
|
|
503
|
+
expect(duration).toBeLessThan(100);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe('getCachedFile()', () => {
|
|
508
|
+
beforeEach(async () => {
|
|
509
|
+
await manager.init();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('should retrieve cached file as blob', async () => {
|
|
513
|
+
const cacheKey = manager.getCacheKey('media', '1');
|
|
514
|
+
const blob = new Blob(['test data']);
|
|
515
|
+
await mockCache.put(cacheKey, new Response(blob));
|
|
516
|
+
|
|
517
|
+
const retrieved = await manager.getCachedFile('media', '1');
|
|
518
|
+
|
|
519
|
+
expect(retrieved).toBeInstanceOf(Blob);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('should return null for non-cached file', async () => {
|
|
523
|
+
const retrieved = await manager.getCachedFile('media', 'non-existent');
|
|
524
|
+
|
|
525
|
+
expect(retrieved).toBeNull();
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe('getCachedResponse()', () => {
|
|
530
|
+
beforeEach(async () => {
|
|
531
|
+
await manager.init();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('should retrieve cached response with headers', async () => {
|
|
535
|
+
const cacheKey = manager.getCacheKey('media', '1');
|
|
536
|
+
await mockCache.put(cacheKey, new Response(new Blob(['data']), {
|
|
537
|
+
headers: { 'Content-Type': 'image/jpeg' }
|
|
538
|
+
}));
|
|
539
|
+
|
|
540
|
+
const response = await manager.getCachedResponse('media', '1');
|
|
541
|
+
|
|
542
|
+
expect(response).toBeInstanceOf(Response);
|
|
543
|
+
expect(response.headers.get('Content-Type')).toBe('image/jpeg');
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
describe('getCachedFileText()', () => {
|
|
548
|
+
beforeEach(async () => {
|
|
549
|
+
await manager.init();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('should retrieve cached file as text', async () => {
|
|
553
|
+
const cacheKey = manager.getCacheKey('layout', '100');
|
|
554
|
+
await mockCache.put(cacheKey, new Response('layout XML'));
|
|
555
|
+
|
|
556
|
+
const text = await manager.getCachedFileText('layout', '100');
|
|
557
|
+
|
|
558
|
+
expect(text).toBe('layout XML');
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe('cacheWidgetHtml()', () => {
|
|
563
|
+
beforeEach(async () => {
|
|
564
|
+
await manager.init();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('should cache widget HTML with base tag injection', async () => {
|
|
568
|
+
const html = '<html><head><title>Widget</title></head><body>Content</body></html>';
|
|
569
|
+
|
|
570
|
+
const cacheKey = await manager.cacheWidgetHtml('100', '1', '2', html);
|
|
571
|
+
|
|
572
|
+
expect(cacheKey).toBe('/player/pwa/cache/widget/100/1/2');
|
|
573
|
+
|
|
574
|
+
// Verify base tag was injected
|
|
575
|
+
const cached = await mockCache.match(new URL(cacheKey, 'https://test.cms.com'));
|
|
576
|
+
const cachedHtml = await cached.text();
|
|
577
|
+
|
|
578
|
+
expect(cachedHtml).toContain('<base href="/player/cache/media/">');
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('should inject base tag after <head> tag', async () => {
|
|
582
|
+
const html = '<html><head><title>Test</title></head></html>';
|
|
583
|
+
|
|
584
|
+
await manager.cacheWidgetHtml('100', '1', '2', html);
|
|
585
|
+
|
|
586
|
+
const cacheKey = '/player/pwa/cache/widget/100/1/2';
|
|
587
|
+
const cached = await mockCache.match(new URL(cacheKey, 'https://test.cms.com'));
|
|
588
|
+
const cachedHtml = await cached.text();
|
|
589
|
+
|
|
590
|
+
expect(cachedHtml).toMatch(/<head><base href="\/player\/cache\/media\/">/);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('should prepend base tag if no <head> tag', async () => {
|
|
594
|
+
const html = '<div>Widget content</div>';
|
|
595
|
+
|
|
596
|
+
await manager.cacheWidgetHtml('100', '1', '2', html);
|
|
597
|
+
|
|
598
|
+
const cacheKey = '/player/pwa/cache/widget/100/1/2';
|
|
599
|
+
const cached = await mockCache.match(new URL(cacheKey, 'https://test.cms.com'));
|
|
600
|
+
const cachedHtml = await cached.text();
|
|
601
|
+
|
|
602
|
+
expect(cachedHtml).toMatch(/^<base href="\/player\/cache\/media\/">/);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe('clearAll()', () => {
|
|
607
|
+
beforeEach(async () => {
|
|
608
|
+
await manager.init();
|
|
609
|
+
|
|
610
|
+
// Add some cached data
|
|
611
|
+
await manager.saveFile({ id: '1', type: 'media' });
|
|
612
|
+
await manager.saveFile({ id: '2', type: 'layout' });
|
|
613
|
+
const cacheKey = manager.getCacheKey('media', '1');
|
|
614
|
+
await mockCache.put(cacheKey, new Response('data'));
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('should clear all caches and IndexedDB', async () => {
|
|
618
|
+
await manager.clearAll();
|
|
619
|
+
|
|
620
|
+
const files = await manager.getAllFiles();
|
|
621
|
+
expect(files).toHaveLength(0);
|
|
622
|
+
|
|
623
|
+
const cached = await manager.getCachedFile('media', '1');
|
|
624
|
+
expect(cached).toBeNull();
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
describe('Dependant Tracking', () => {
|
|
629
|
+
it('should add a dependant mapping from media to layout', () => {
|
|
630
|
+
manager.addDependant('media1', 'layout1');
|
|
631
|
+
|
|
632
|
+
expect(manager.isMediaReferenced('media1')).toBe(true);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('should track multiple layouts for same media', () => {
|
|
636
|
+
manager.addDependant('media1', 'layout1');
|
|
637
|
+
manager.addDependant('media1', 'layout2');
|
|
638
|
+
|
|
639
|
+
expect(manager.isMediaReferenced('media1')).toBe(true);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('should track multiple media for different layouts', () => {
|
|
643
|
+
manager.addDependant('media1', 'layout1');
|
|
644
|
+
manager.addDependant('media2', 'layout1');
|
|
645
|
+
manager.addDependant('media3', 'layout2');
|
|
646
|
+
|
|
647
|
+
expect(manager.isMediaReferenced('media1')).toBe(true);
|
|
648
|
+
expect(manager.isMediaReferenced('media2')).toBe(true);
|
|
649
|
+
expect(manager.isMediaReferenced('media3')).toBe(true);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('should return false for unreferenced media', () => {
|
|
653
|
+
expect(manager.isMediaReferenced('nonexistent')).toBe(false);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('should handle numeric IDs by converting to strings', () => {
|
|
657
|
+
manager.addDependant(42, 100);
|
|
658
|
+
|
|
659
|
+
expect(manager.isMediaReferenced(42)).toBe(true);
|
|
660
|
+
expect(manager.isMediaReferenced('42')).toBe(true);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('should remove layout dependants and return orphaned media', () => {
|
|
664
|
+
manager.addDependant('media1', 'layout1');
|
|
665
|
+
manager.addDependant('media2', 'layout1');
|
|
666
|
+
manager.addDependant('media3', 'layout1');
|
|
667
|
+
manager.addDependant('media3', 'layout2'); // media3 is shared
|
|
668
|
+
|
|
669
|
+
const orphaned = manager.removeLayoutDependants('layout1');
|
|
670
|
+
|
|
671
|
+
// media1 and media2 are orphaned (only referenced by layout1)
|
|
672
|
+
expect(orphaned).toContain('media1');
|
|
673
|
+
expect(orphaned).toContain('media2');
|
|
674
|
+
// media3 is NOT orphaned (still referenced by layout2)
|
|
675
|
+
expect(orphaned).not.toContain('media3');
|
|
676
|
+
expect(manager.isMediaReferenced('media3')).toBe(true);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('should return empty array when layout has no dependants', () => {
|
|
680
|
+
const orphaned = manager.removeLayoutDependants('nonexistent');
|
|
681
|
+
|
|
682
|
+
expect(orphaned).toEqual([]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('should remove media from dependants map when orphaned', () => {
|
|
686
|
+
manager.addDependant('media1', 'layout1');
|
|
687
|
+
|
|
688
|
+
const orphaned = manager.removeLayoutDependants('layout1');
|
|
689
|
+
|
|
690
|
+
expect(orphaned).toContain('media1');
|
|
691
|
+
expect(manager.isMediaReferenced('media1')).toBe(false);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should not affect other layouts when removing one', () => {
|
|
695
|
+
manager.addDependant('media1', 'layout1');
|
|
696
|
+
manager.addDependant('media2', 'layout2');
|
|
697
|
+
|
|
698
|
+
manager.removeLayoutDependants('layout1');
|
|
699
|
+
|
|
700
|
+
expect(manager.isMediaReferenced('media1')).toBe(false);
|
|
701
|
+
expect(manager.isMediaReferenced('media2')).toBe(true);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('should handle removing same layout twice', () => {
|
|
705
|
+
manager.addDependant('media1', 'layout1');
|
|
706
|
+
|
|
707
|
+
const orphaned1 = manager.removeLayoutDependants('layout1');
|
|
708
|
+
const orphaned2 = manager.removeLayoutDependants('layout1');
|
|
709
|
+
|
|
710
|
+
expect(orphaned1).toContain('media1');
|
|
711
|
+
expect(orphaned2).toEqual([]);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
describe('Download Progress Events', () => {
|
|
716
|
+
beforeEach(async () => {
|
|
717
|
+
await manager.init();
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('should dispatch download-progress events', () => {
|
|
721
|
+
manager.notifyDownloadProgress('test.mp4', 512, 1024);
|
|
722
|
+
|
|
723
|
+
expect(window.dispatchEvent).toHaveBeenCalledWith(
|
|
724
|
+
expect.objectContaining({
|
|
725
|
+
type: 'download-progress',
|
|
726
|
+
detail: expect.objectContaining({
|
|
727
|
+
filename: 'test.mp4',
|
|
728
|
+
loaded: 512,
|
|
729
|
+
total: 1024,
|
|
730
|
+
percent: 50
|
|
731
|
+
})
|
|
732
|
+
})
|
|
733
|
+
);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('should mark download as complete', () => {
|
|
737
|
+
manager.notifyDownloadProgress('test.mp4', 1024, 1024, true);
|
|
738
|
+
|
|
739
|
+
expect(window.dispatchEvent).toHaveBeenCalledWith(
|
|
740
|
+
expect.objectContaining({
|
|
741
|
+
detail: expect.objectContaining({
|
|
742
|
+
complete: true
|
|
743
|
+
})
|
|
744
|
+
})
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('should mark download as error', () => {
|
|
749
|
+
manager.notifyDownloadProgress('test.mp4', 512, 1024, false, true);
|
|
750
|
+
|
|
751
|
+
expect(window.dispatchEvent).toHaveBeenCalledWith(
|
|
752
|
+
expect.objectContaining({
|
|
753
|
+
detail: expect.objectContaining({
|
|
754
|
+
error: true
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
);
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
});
|