@glivion/square-screen-js-sdk 0.1.0 → 1.0.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.
Files changed (49) hide show
  1. package/dist/index.cjs +874 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.cts +522 -0
  4. package/dist/index.d.mts +522 -0
  5. package/dist/index.mjs +870 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/package.json +8 -1
  8. package/.github/workflows/build-js-sdk.yml +0 -70
  9. package/eslint.config.js +0 -3
  10. package/examples/react-app/README.md +0 -73
  11. package/examples/react-app/eslint.config.js +0 -22
  12. package/examples/react-app/index.html +0 -13
  13. package/examples/react-app/package-lock.json +0 -2239
  14. package/examples/react-app/package.json +0 -31
  15. package/examples/react-app/public/favicon.svg +0 -1
  16. package/examples/react-app/public/icons.svg +0 -24
  17. package/examples/react-app/src/App.css +0 -184
  18. package/examples/react-app/src/App.tsx +0 -157
  19. package/examples/react-app/src/EmergencyTicker.tsx +0 -25
  20. package/examples/react-app/src/HeadlessExample.tsx +0 -66
  21. package/examples/react-app/src/RendererExample.tsx +0 -70
  22. package/examples/react-app/src/assets/hero.png +0 -0
  23. package/examples/react-app/src/assets/react.svg +0 -1
  24. package/examples/react-app/src/assets/vite.svg +0 -1
  25. package/examples/react-app/src/index.css +0 -183
  26. package/examples/react-app/src/main.tsx +0 -10
  27. package/examples/react-app/src/mockNetworkDataSource.ts +0 -116
  28. package/examples/react-app/src/usePlayer.ts +0 -71
  29. package/examples/react-app/tsconfig.app.json +0 -25
  30. package/examples/react-app/tsconfig.json +0 -7
  31. package/examples/react-app/tsconfig.node.json +0 -24
  32. package/examples/react-app/vite.config.ts +0 -7
  33. package/examples/react-app/yarn.lock +0 -1089
  34. package/src/__tests__/cache/SquareScreenCache.test.ts +0 -375
  35. package/src/__tests__/network/NetworkClient.test.ts +0 -217
  36. package/src/__tests__/network/mappers.test.ts +0 -163
  37. package/src/__tests__/player/SquareScreenPlayer.test.ts +0 -840
  38. package/src/cache/SquareScreenCache.ts +0 -154
  39. package/src/constants.ts +0 -9
  40. package/src/core/types.ts +0 -251
  41. package/src/env.d.ts +0 -4
  42. package/src/index.ts +0 -34
  43. package/src/network/NetworkClient.ts +0 -234
  44. package/src/network/apiTypes.ts +0 -89
  45. package/src/network/mappers.ts +0 -106
  46. package/src/player/SquareScreenPlayer.ts +0 -414
  47. package/src/renderer/SquareScreenRenderer.ts +0 -282
  48. package/tsconfig.json +0 -12
  49. package/tsdown.config.ts +0 -23
@@ -1,375 +0,0 @@
1
- // @vitest-environment jsdom
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import { SquareScreenCache } from "../../cache/SquareScreenCache";
4
- import type { Playlist } from "../../core/types";
5
-
6
- // jsdom's `Blob` has no `.stream()`, but Undici `Response(blob)` consumes the body
7
- // via `blob.stream()`. Patch once so {@link SquareScreenCache.saveMedia} works unchanged.
8
- const BlobCtor = globalThis.Blob;
9
- if (typeof BlobCtor.prototype.stream !== "function") {
10
- Object.defineProperty(BlobCtor.prototype, "stream", {
11
- value(this: Blob) {
12
- return new ReadableStream<Uint8Array>({
13
- start: (controller) => {
14
- void (async () => {
15
- controller.enqueue(new Uint8Array(await this.arrayBuffer()));
16
- controller.close();
17
- })();
18
- },
19
- });
20
- },
21
- configurable: true,
22
- enumerable: false,
23
- writable: true,
24
- });
25
- }
26
-
27
- // ---------------------------------------------------------------------------
28
- // IndexedDB mock (jsdom does not ship IndexedDB)
29
- // ---------------------------------------------------------------------------
30
-
31
- // Two-level map: dbName -> record key -> record value
32
- const idbStore = new Map<string, Map<string, unknown>>();
33
-
34
- vi.mock("idb", () => ({
35
- openDB: vi.fn(async (dbName: string) => {
36
- if (!idbStore.has(dbName)) idbStore.set(dbName, new Map());
37
- const store = idbStore.get(dbName)!;
38
- return {
39
- get: vi.fn(async (_: string, key: string) => store.get(key)),
40
- put: vi.fn(async (_: string, value: unknown) => {
41
- const record = value as { uuid: string };
42
- store.set(record.uuid, value);
43
- return record.uuid;
44
- }),
45
- close: vi.fn(),
46
- };
47
- }),
48
- deleteDB: vi.fn(async (dbName: string) => {
49
- idbStore.delete(dbName);
50
- }),
51
- }));
52
-
53
- // ---------------------------------------------------------------------------
54
- // Helpers
55
- // ---------------------------------------------------------------------------
56
-
57
- // Each test gets a unique DB name so state never bleeds between cases.
58
- let dbCounter = 0;
59
- function nextDb() {
60
- return { dbName: `test-db-${++dbCounter}`, cacheName: `test-cache-${dbCounter}` };
61
- }
62
-
63
- function makePlaylist(overrides: Partial<Playlist> = {}): Playlist {
64
- return {
65
- uuid: "playlist-1",
66
- cachedAt: Date.now(),
67
- items: [
68
- {
69
- uuid: "item-1",
70
- name: "Item 1",
71
- type: "image",
72
- url: "https://cdn.example.com/1.jpg",
73
- duration: 5,
74
- },
75
- ],
76
- ...overrides,
77
- };
78
- }
79
-
80
- // ---------------------------------------------------------------------------
81
- // Cache API stub (jsdom does not implement the Cache API)
82
- // ---------------------------------------------------------------------------
83
-
84
- let blobStore: Map<string, Blob>;
85
-
86
- function buildMockCache() {
87
- // Each call to caches.open() returns a handle backed by the same blobStore,
88
- // so put() and match() see the same data within a single test.
89
- const handle = {
90
- match: vi.fn(async (req: RequestInfo) => {
91
- const key = typeof req === "string" ? req : (req as Request).url;
92
- const blob = blobStore.get(key);
93
- // Return a fresh Response each time so the body is never "consumed".
94
- return blob ? new Response(blob) : undefined;
95
- }),
96
- put: vi.fn(async (req: RequestInfo, res: Response) => {
97
- const key = typeof req === "string" ? req : (req as Request).url;
98
- blobStore.set(key, await res.blob());
99
- }),
100
- delete: vi.fn(async (req: RequestInfo) => {
101
- const key = typeof req === "string" ? req : (req as Request).url;
102
- return blobStore.delete(key);
103
- }),
104
- keys: vi.fn(async () => [...blobStore.keys()].map((k) => new Request(k))),
105
- };
106
- return { handle, open: vi.fn(async () => handle) };
107
- }
108
-
109
- // ---------------------------------------------------------------------------
110
- // Setup / teardown
111
- // ---------------------------------------------------------------------------
112
-
113
- let blobUrlCounter = 0;
114
-
115
- beforeEach(() => {
116
- blobStore = new Map();
117
- blobUrlCounter = 0;
118
-
119
- const { open } = buildMockCache();
120
- vi.stubGlobal("caches", { open });
121
-
122
- vi.spyOn(URL, "createObjectURL").mockImplementation(
123
- () => `blob:mock-${++blobUrlCounter}`,
124
- );
125
- vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
126
- });
127
-
128
- afterEach(() => {
129
- vi.unstubAllGlobals();
130
- vi.restoreAllMocks();
131
- });
132
-
133
- // ---------------------------------------------------------------------------
134
- // getPlaylist
135
- // ---------------------------------------------------------------------------
136
-
137
- describe("getPlaylist", () => {
138
- it("returns null when nothing has been stored", async () => {
139
- const cache = new SquareScreenCache(nextDb());
140
- expect(await cache.getPlaylist("playlist-1")).toBeNull();
141
- });
142
-
143
- it("returns the playlist when the record is within its TTL", async () => {
144
- const cache = new SquareScreenCache(nextDb());
145
- const playlist = makePlaylist();
146
- await cache.savePlaylist(playlist, 60_000);
147
-
148
- const result = await cache.getPlaylist("playlist-1");
149
- expect(result).not.toBeNull();
150
- expect(result!.uuid).toBe("playlist-1");
151
- expect(result!.items).toHaveLength(1);
152
- });
153
-
154
- it("returns null when the TTL has expired and allowStale is false", async () => {
155
- vi.useFakeTimers();
156
- const cache = new SquareScreenCache(nextDb());
157
- await cache.savePlaylist(makePlaylist(), 1_000);
158
-
159
- vi.advanceTimersByTime(2_000); // skip past the 1 s TTL
160
- expect(await cache.getPlaylist("playlist-1")).toBeNull();
161
- vi.useRealTimers();
162
- });
163
-
164
- it("returns the stale playlist when allowStale is true", async () => {
165
- vi.useFakeTimers();
166
- const cache = new SquareScreenCache(nextDb());
167
- await cache.savePlaylist(makePlaylist(), 1_000);
168
-
169
- vi.advanceTimersByTime(2_000);
170
- const result = await cache.getPlaylist("playlist-1", true);
171
- expect(result).not.toBeNull();
172
- expect(result!.uuid).toBe("playlist-1");
173
- vi.useRealTimers();
174
- });
175
-
176
- it("does not expose the internal _ttl field to the caller", async () => {
177
- const cache = new SquareScreenCache(nextDb());
178
- await cache.savePlaylist(makePlaylist(), 60_000);
179
-
180
- const result = await cache.getPlaylist("playlist-1");
181
- expect(result).not.toHaveProperty("_ttl");
182
- });
183
-
184
- it("returns updated data after savePlaylist overwrites the same UUID", async () => {
185
- const cache = new SquareScreenCache(nextDb());
186
- await cache.savePlaylist(makePlaylist({ uuid: "playlist-1" }), 60_000);
187
- await cache.savePlaylist(
188
- makePlaylist({ uuid: "playlist-1", items: [] }),
189
- 60_000,
190
- );
191
-
192
- const result = await cache.getPlaylist("playlist-1");
193
- expect(result!.items).toHaveLength(0);
194
- });
195
- });
196
-
197
- // ---------------------------------------------------------------------------
198
- // savePlaylist
199
- // ---------------------------------------------------------------------------
200
-
201
- describe("savePlaylist", () => {
202
- it("persists items inline so getPlaylist returns them", async () => {
203
- const cache = new SquareScreenCache(nextDb());
204
- const playlist = makePlaylist();
205
- await cache.savePlaylist(playlist, 60_000);
206
-
207
- const result = await cache.getPlaylist("playlist-1");
208
- expect(result!.items[0].uuid).toBe("item-1");
209
- expect(result!.items[0].url).toBe("https://cdn.example.com/1.jpg");
210
- });
211
-
212
- it("stores optional strategy and schedule fields", async () => {
213
- const cache = new SquareScreenCache(nextDb());
214
- const playlist = makePlaylist({
215
- strategy: { loop: false, shuffle: true, preloadCount: 2 },
216
- schedule: { uuid: "sched-1", name: "Morning", priority: 1 },
217
- });
218
- await cache.savePlaylist(playlist, 60_000);
219
-
220
- const result = await cache.getPlaylist("playlist-1");
221
- expect(result!.strategy).toEqual({ loop: false, shuffle: true, preloadCount: 2 });
222
- expect(result!.schedule?.uuid).toBe("sched-1");
223
- });
224
-
225
- it("refreshes cachedAt so TTL resets on overwrite", async () => {
226
- vi.useFakeTimers();
227
- const cache = new SquareScreenCache(nextDb());
228
- await cache.savePlaylist(makePlaylist(), 5_000);
229
-
230
- vi.advanceTimersByTime(4_000); // within original TTL
231
- // Overwrite — resets the clock
232
- await cache.savePlaylist(makePlaylist(), 5_000);
233
- vi.advanceTimersByTime(4_000); // 8 s since first save, 4 s since overwrite
234
-
235
- // Should still be fresh because the overwrite reset cachedAt
236
- const result = await cache.getPlaylist("playlist-1");
237
- expect(result).not.toBeNull();
238
- vi.useRealTimers();
239
- });
240
- });
241
-
242
- // ---------------------------------------------------------------------------
243
- // getMediaUrl
244
- // ---------------------------------------------------------------------------
245
-
246
- describe("getMediaUrl", () => {
247
- it("returns null when the media has not been downloaded", async () => {
248
- const cache = new SquareScreenCache(nextDb());
249
- expect(await cache.getMediaUrl("https://cdn.example.com/1.jpg")).toBeNull();
250
- });
251
-
252
- it("creates and returns a blob URL when the media is in the Cache API", async () => {
253
- const cache = new SquareScreenCache(nextDb());
254
- const blob = new Blob(["video"], { type: "video/mp4" });
255
- await cache.saveMedia("https://cdn.example.com/1.mp4", blob);
256
-
257
- const url = await cache.getMediaUrl("https://cdn.example.com/1.mp4");
258
- expect(url).toMatch(/^blob:mock-/);
259
- });
260
-
261
- it("returns the same blob URL on repeated calls without calling createObjectURL again", async () => {
262
- const cache = new SquareScreenCache(nextDb());
263
- const blob = new Blob(["img"], { type: "image/jpeg" });
264
- await cache.saveMedia("https://cdn.example.com/1.jpg", blob);
265
-
266
- const first = await cache.getMediaUrl("https://cdn.example.com/1.jpg");
267
- const second = await cache.getMediaUrl("https://cdn.example.com/1.jpg");
268
-
269
- expect(first).toBe(second);
270
- // createObjectURL called exactly once: during saveMedia. getMediaUrl reuses it.
271
- expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
272
- });
273
-
274
- it("creates separate blob URLs for different URLs", async () => {
275
- const cache = new SquareScreenCache(nextDb());
276
- await cache.saveMedia("https://cdn.example.com/a.jpg", new Blob(["a"]));
277
- await cache.saveMedia("https://cdn.example.com/b.jpg", new Blob(["b"]));
278
-
279
- const urlA = await cache.getMediaUrl("https://cdn.example.com/a.jpg");
280
- const urlB = await cache.getMediaUrl("https://cdn.example.com/b.jpg");
281
- expect(urlA).not.toBe(urlB);
282
- });
283
- });
284
-
285
- // ---------------------------------------------------------------------------
286
- // saveMedia
287
- // ---------------------------------------------------------------------------
288
-
289
- describe("saveMedia", () => {
290
- it("returns a blob URL immediately", async () => {
291
- const cache = new SquareScreenCache(nextDb());
292
- const result = await cache.saveMedia(
293
- "https://cdn.example.com/1.jpg",
294
- new Blob(["data"], { type: "image/jpeg" }),
295
- );
296
- expect(result).toMatch(/^blob:mock-/);
297
- });
298
-
299
- it("persists to the Cache API so getMediaUrl returns the same URL", async () => {
300
- const cache = new SquareScreenCache(nextDb());
301
- const saved = await cache.saveMedia(
302
- "https://cdn.example.com/1.jpg",
303
- new Blob(["data"]),
304
- );
305
- const fetched = await cache.getMediaUrl("https://cdn.example.com/1.jpg");
306
-
307
- // Both should be the same handle — no second createObjectURL call.
308
- expect(fetched).toBe(saved);
309
- expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
310
- });
311
-
312
- it("overwrites an existing entry when called twice for the same URL", async () => {
313
- const cache = new SquareScreenCache(nextDb());
314
- await cache.saveMedia("https://cdn.example.com/1.jpg", new Blob(["v1"]));
315
- const second = await cache.saveMedia(
316
- "https://cdn.example.com/1.jpg",
317
- new Blob(["v2"]),
318
- );
319
-
320
- // The in-memory map is updated; subsequent getMediaUrl returns the latest handle.
321
- const fetched = await cache.getMediaUrl("https://cdn.example.com/1.jpg");
322
- expect(fetched).toBe(second);
323
- });
324
- });
325
-
326
- // ---------------------------------------------------------------------------
327
- // clear
328
- // ---------------------------------------------------------------------------
329
-
330
- describe("clear", () => {
331
- it("revokes all outstanding blob URLs", async () => {
332
- const cache = new SquareScreenCache(nextDb());
333
- await cache.saveMedia("https://cdn.example.com/a.jpg", new Blob(["a"]));
334
- await cache.saveMedia("https://cdn.example.com/b.jpg", new Blob(["b"]));
335
-
336
- await cache.clear();
337
-
338
- expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
339
- });
340
-
341
- it("removes all entries from the Cache API", async () => {
342
- const opts = nextDb();
343
- const cache = new SquareScreenCache(opts);
344
- await cache.saveMedia("https://cdn.example.com/1.jpg", new Blob(["data"]));
345
-
346
- await cache.clear();
347
-
348
- // After clear, getMediaUrl must return null (Cache API entries gone and
349
- // the in-memory objectUrls map is cleared too).
350
- const fresh = new SquareScreenCache(opts);
351
- expect(await fresh.getMediaUrl("https://cdn.example.com/1.jpg")).toBeNull();
352
- });
353
-
354
- it("makes getPlaylist return null after clearing IndexedDB", async () => {
355
- const opts = nextDb();
356
- const cache = new SquareScreenCache(opts);
357
- await cache.savePlaylist(makePlaylist(), 60_000);
358
-
359
- await cache.clear();
360
-
361
- const fresh = new SquareScreenCache(opts);
362
- expect(await fresh.getPlaylist("playlist-1")).toBeNull();
363
- });
364
-
365
- it("resets the in-memory blob URL map so getMediaUrl fetches fresh", async () => {
366
- const cache = new SquareScreenCache(nextDb());
367
- await cache.saveMedia("https://cdn.example.com/1.jpg", new Blob(["data"]));
368
-
369
- await cache.clear();
370
-
371
- // The blob store was also cleared by the mock, so getMediaUrl should return null.
372
- const result = await cache.getMediaUrl("https://cdn.example.com/1.jpg");
373
- expect(result).toBeNull();
374
- });
375
- });
@@ -1,217 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { NetworkClient } from "../../network/NetworkClient";
3
- import type { ApiPlaylistResponse } from "../../network/apiTypes";
4
-
5
- const BASE_URL = "https://api.example.com/v1";
6
- const DEVICE_ID = "device-123";
7
- const DEVICE_TOKEN = "token-abc";
8
-
9
- function mockJsonResponse(body: unknown, init: ResponseInit = {}): Response {
10
- return new Response(JSON.stringify(body), {
11
- status: 200,
12
- headers: { "Content-Type": "application/json" },
13
- ...init,
14
- });
15
- }
16
-
17
- describe("NetworkClient", () => {
18
- beforeEach(() => {
19
- vi.stubGlobal("fetch", vi.fn());
20
- });
21
-
22
- afterEach(() => {
23
- vi.unstubAllGlobals();
24
- vi.restoreAllMocks();
25
- });
26
-
27
- it("fetchPlaylist sends auth headers and maps response", async () => {
28
- const payload: ApiPlaylistResponse = {
29
- playlist: { uuid: "playlist-1", name: "Default" },
30
- items: [
31
- {
32
- uuid: "item-1",
33
- name: "Image 1",
34
- type: "image",
35
- url: "https://cdn.example.com/1.jpg",
36
- duration_seconds: 7,
37
- },
38
- ],
39
- strategy: { loop: true, shuffle: false, preload_count: 1 },
40
- schedule: { uuid: "s1", name: "schedule", priority: 1 },
41
- };
42
- vi.mocked(fetch).mockResolvedValueOnce(mockJsonResponse(payload));
43
-
44
- const client = new NetworkClient(BASE_URL, DEVICE_ID, DEVICE_TOKEN);
45
- const result = await client.fetchPlaylist();
46
-
47
- expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/screen/now-playing`, {
48
- method: "GET",
49
- headers: {
50
- "Content-Type": "application/json",
51
- "X-Device-Id": DEVICE_ID,
52
- "X-Device-Token": DEVICE_TOKEN,
53
- },
54
- });
55
- expect(result.success).toBe(true);
56
- if (!result.success) return;
57
- expect(result.data.uuid).toBe("playlist-1");
58
- expect(result.data.items[0]).toMatchObject({
59
- uuid: "item-1",
60
- duration: 7,
61
- });
62
- expect(result.data.strategy).toEqual({
63
- loop: true,
64
- shuffle: false,
65
- preloadCount: 1,
66
- });
67
- });
68
-
69
- it("builds filtered query for fetchVideoPlaylist", async () => {
70
- vi.mocked(fetch).mockResolvedValueOnce(
71
- mockJsonResponse({
72
- playlist: { uuid: "playlist-2", name: "Video" },
73
- items: [],
74
- strategy: [],
75
- }),
76
- );
77
-
78
- const client = new NetworkClient(BASE_URL, DEVICE_ID, DEVICE_TOKEN);
79
- await client.fetchVideoPlaylist({
80
- category: "sports",
81
- quality: "hd",
82
- limit: 3,
83
- tags: ["foo", "bar"],
84
- });
85
-
86
- expect(fetch).toHaveBeenCalledWith(
87
- `${BASE_URL}/screen/now-playing?type=video&category=sports&quality=hd&limit=3&tags=foo%2Cbar`,
88
- expect.any(Object),
89
- );
90
- });
91
-
92
- it("builds filtered query for fetchImagePlaylist", async () => {
93
- vi.mocked(fetch).mockResolvedValueOnce(
94
- mockJsonResponse({
95
- playlist: { uuid: "playlist-3", name: "Image" },
96
- items: [],
97
- strategy: [],
98
- }),
99
- );
100
-
101
- const client = new NetworkClient(BASE_URL, DEVICE_ID, DEVICE_TOKEN);
102
- await client.fetchImagePlaylist({
103
- category: "news",
104
- limit: 2,
105
- tags: ["frontpage"],
106
- });
107
-
108
- expect(fetch).toHaveBeenCalledWith(
109
- `${BASE_URL}/screen/now-playing?type=image&category=news&limit=2&tags=frontpage`,
110
- expect.any(Object),
111
- );
112
- });
113
-
114
- it("returns auth errors for 401 and 403", async () => {
115
- vi.mocked(fetch)
116
- .mockResolvedValueOnce(new Response("{}", { status: 401 }))
117
- .mockResolvedValueOnce(new Response("{}", { status: 403 }));
118
- const client = new NetworkClient(BASE_URL, DEVICE_ID, DEVICE_TOKEN);
119
-
120
- const unauthorized = await client.fetchPlaylist();
121
- const forbidden = await client.fetchPlaylist();
122
-
123
- expect(unauthorized).toEqual({
124
- success: false,
125
- error: { kind: "auth", message: "Unauthorized" },
126
- });
127
- expect(forbidden).toEqual({
128
- success: false,
129
- error: { kind: "auth", message: "Forbidden" },
130
- });
131
- });
132
-
133
- it("returns network errors for non-auth HTTP failures", async () => {
134
- vi.mocked(fetch).mockResolvedValueOnce(
135
- new Response("{}", { status: 500, statusText: "Server Error" }),
136
- );
137
- const client = new NetworkClient(BASE_URL, DEVICE_ID, DEVICE_TOKEN);
138
-
139
- const result = await client.fetchPlaylist();
140
- expect(result).toEqual({
141
- success: false,
142
- error: { kind: "network", code: 500, message: "Server Error" },
143
- });
144
- });
145
-
146
- it("returns parse error when JSON parsing fails", async () => {
147
- vi.mocked(fetch).mockResolvedValueOnce(
148
- new Response("not-json", { status: 200 }),
149
- );
150
- const client = new NetworkClient(BASE_URL, DEVICE_ID, DEVICE_TOKEN);
151
-
152
- const result = await client.fetchPlaylist();
153
- expect(result).toEqual({
154
- success: false,
155
- error: {
156
- kind: "parse",
157
- message: "Response could not be parsed as JSON",
158
- },
159
- });
160
- });
161
-
162
- it("returns network error when fetch throws", async () => {
163
- vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
164
- const client = new NetworkClient(BASE_URL, DEVICE_ID, DEVICE_TOKEN);
165
-
166
- const result = await client.fetchPlaylist();
167
- expect(result).toEqual({
168
- success: false,
169
- error: {
170
- kind: "network",
171
- code: 0,
172
- message: "offline",
173
- },
174
- });
175
- });
176
-
177
- it("maps heartbeat request body and response", async () => {
178
- vi.mocked(fetch).mockResolvedValueOnce(mockJsonResponse({ received: true }));
179
- const client = new NetworkClient(BASE_URL, DEVICE_ID, DEVICE_TOKEN);
180
-
181
- const result = await client.healthCheck({
182
- cpuUsage: 1,
183
- playerVersion: "2.0.0",
184
- });
185
-
186
- expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/screen/heartbeat`, {
187
- method: "POST",
188
- body: JSON.stringify({
189
- cpu_usage: 1,
190
- app_version: "2.0.0",
191
- }),
192
- headers: {
193
- "Content-Type": "application/json",
194
- "X-Device-Id": DEVICE_ID,
195
- "X-Device-Token": DEVICE_TOKEN,
196
- },
197
- });
198
- expect(result).toEqual({ success: true, data: { received: true } });
199
- });
200
-
201
- it("maps reportPlaybackEvent success payload", async () => {
202
- vi.mocked(fetch).mockResolvedValueOnce(mockJsonResponse({ recorded: true }));
203
- const client = new NetworkClient(BASE_URL, DEVICE_ID, DEVICE_TOKEN);
204
- const event = {
205
- media_uuid: "m1",
206
- playlist_uuid: "p1",
207
- schedule_uuid: "s1",
208
- started_at: "2026-01-01T00:00:00.000Z",
209
- ended_at: "2026-01-01T00:00:05.000Z",
210
- duration_seconds: 5,
211
- completed: true,
212
- };
213
-
214
- const result = await client.reportPlaybackEvent(event);
215
- expect(result).toEqual({ success: true, data: { recorded: true } });
216
- });
217
- });