@carlonicora/nextjs-jsonapi 6.12.2 → 6.13.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.
Files changed (254) hide show
  1. package/dist/{ApiData-DPKNfY-9.d.mts → ApiData-Be8yxCOX.d.mts} +2 -0
  2. package/dist/{ApiData-DPKNfY-9.d.ts → ApiData-Be8yxCOX.d.ts} +2 -0
  3. package/dist/{BlockNoteEditor-75ED2CN2.mjs → BlockNoteEditor-22Q4DJBU.mjs} +20 -8
  4. package/dist/BlockNoteEditor-22Q4DJBU.mjs.map +1 -0
  5. package/dist/{BlockNoteEditor-7M5QW2YQ.js → BlockNoteEditor-2VLPSEKS.js} +34 -22
  6. package/dist/BlockNoteEditor-2VLPSEKS.js.map +1 -0
  7. package/dist/{BlockNoteViewer-WGCDB7IJ.js → BlockNoteViewer-2XYASA5Y.js} +8 -5
  8. package/dist/BlockNoteViewer-2XYASA5Y.js.map +1 -0
  9. package/dist/{BlockNoteViewer-MTGS6YJV.mjs → BlockNoteViewer-NBW2H6LC.mjs} +7 -4
  10. package/dist/{BlockNoteViewer-MTGS6YJV.mjs.map → BlockNoteViewer-NBW2H6LC.mjs.map} +1 -1
  11. package/dist/{JsonApiRequest-A66XK5WT.mjs → JsonApiRequest-F6TMJ42G.mjs} +2 -2
  12. package/dist/JsonApiRequest-WISJZSDE.js +26 -0
  13. package/dist/{JsonApiRequest-FZGPLNUF.js.map → JsonApiRequest-WISJZSDE.js.map} +1 -1
  14. package/dist/billing/index.js +499 -494
  15. package/dist/billing/index.js.map +1 -1
  16. package/dist/billing/index.mjs +13 -8
  17. package/dist/billing/index.mjs.map +1 -1
  18. package/dist/chunk-27LQDGNP.mjs +317 -0
  19. package/dist/chunk-27LQDGNP.mjs.map +1 -0
  20. package/dist/{chunk-V3YDVJ63.js → chunk-2H342HNN.js} +948 -921
  21. package/dist/chunk-2H342HNN.js.map +1 -0
  22. package/dist/{chunk-KSYQEKJX.js → chunk-2SV4SS6N.js} +14 -14
  23. package/dist/{chunk-KSYQEKJX.js.map → chunk-2SV4SS6N.js.map} +1 -1
  24. package/dist/{chunk-4FNGWALA.mjs → chunk-47BBY5KA.mjs} +6 -4
  25. package/dist/{chunk-4FNGWALA.mjs.map → chunk-47BBY5KA.mjs.map} +1 -1
  26. package/dist/chunk-6JUCIWDC.mjs +570 -0
  27. package/dist/chunk-6JUCIWDC.mjs.map +1 -0
  28. package/dist/chunk-7DBJTNSU.mjs +1049 -0
  29. package/dist/chunk-7DBJTNSU.mjs.map +1 -0
  30. package/dist/chunk-7JZHPMGW.mjs +704 -0
  31. package/dist/chunk-7JZHPMGW.mjs.map +1 -0
  32. package/dist/{chunk-L6PYFQTO.js → chunk-7ZHTPFFT.js} +16 -14
  33. package/dist/chunk-7ZHTPFFT.js.map +1 -0
  34. package/dist/chunk-B6JGKDP2.js +214 -0
  35. package/dist/chunk-B6JGKDP2.js.map +1 -0
  36. package/dist/{chunk-3YW35YHQ.mjs → chunk-BH6JWIKI.mjs} +5 -5
  37. package/dist/chunk-BZ5J2FIV.js +570 -0
  38. package/dist/chunk-BZ5J2FIV.js.map +1 -0
  39. package/dist/{chunk-V5MI7RNB.mjs → chunk-DGWOVYEC.mjs} +2 -2
  40. package/dist/chunk-DOCIFE6A.mjs +212 -0
  41. package/dist/chunk-DOCIFE6A.mjs.map +1 -0
  42. package/dist/chunk-GB6DBFYY.js +25 -0
  43. package/dist/chunk-GB6DBFYY.js.map +1 -0
  44. package/dist/{chunk-RXQYEEKZ.mjs → chunk-H4NLDX5G.mjs} +51 -24
  45. package/dist/chunk-H4NLDX5G.mjs.map +1 -0
  46. package/dist/{chunk-EWCY6QJA.mjs → chunk-JJMMT24D.mjs} +37 -3
  47. package/dist/chunk-JJMMT24D.mjs.map +1 -0
  48. package/dist/{chunk-JTPRJNT2.js → chunk-KMW7UNL2.js} +38 -3
  49. package/dist/chunk-KMW7UNL2.js.map +1 -0
  50. package/dist/chunk-MHB36MYE.js +317 -0
  51. package/dist/chunk-MHB36MYE.js.map +1 -0
  52. package/dist/{chunk-FF4EIQ6H.js → chunk-MOGEGWXW.js} +1039 -1680
  53. package/dist/chunk-MOGEGWXW.js.map +1 -0
  54. package/dist/{chunk-AA52ORW2.mjs → chunk-P445QGOC.mjs} +61 -702
  55. package/dist/chunk-P445QGOC.mjs.map +1 -0
  56. package/dist/chunk-PV4Z5W6V.js +212 -0
  57. package/dist/chunk-PV4Z5W6V.js.map +1 -0
  58. package/dist/chunk-PXDUJ47N.js +1049 -0
  59. package/dist/chunk-PXDUJ47N.js.map +1 -0
  60. package/dist/{chunk-GHE4VT2R.js → chunk-QKNGZDSQ.js} +2 -2
  61. package/dist/{chunk-GHE4VT2R.js.map → chunk-QKNGZDSQ.js.map} +1 -1
  62. package/dist/chunk-W62MHDWM.js +704 -0
  63. package/dist/chunk-W62MHDWM.js.map +1 -0
  64. package/dist/chunk-XXP3TQHF.mjs +25 -0
  65. package/dist/chunk-XXP3TQHF.mjs.map +1 -0
  66. package/dist/chunk-YRKIFYOE.mjs +214 -0
  67. package/dist/chunk-YRKIFYOE.mjs.map +1 -0
  68. package/dist/{chunk-O3PDL2CV.js → chunk-Z4POQWFG.js} +40 -6
  69. package/dist/chunk-Z4POQWFG.js.map +1 -0
  70. package/dist/{chunk-PI56A4DG.mjs → chunk-ZLTOUZN2.mjs} +37 -2
  71. package/dist/chunk-ZLTOUZN2.mjs.map +1 -0
  72. package/dist/client/index.d.mts +2 -2
  73. package/dist/client/index.d.ts +2 -2
  74. package/dist/client/index.js +14 -9
  75. package/dist/client/index.js.map +1 -1
  76. package/dist/client/index.mjs +24 -19
  77. package/dist/components/index.d.mts +7 -1
  78. package/dist/components/index.d.ts +7 -1
  79. package/dist/components/index.js +11 -7
  80. package/dist/components/index.js.map +1 -1
  81. package/dist/components/index.mjs +10 -6
  82. package/dist/contexts/index.d.mts +1 -1
  83. package/dist/contexts/index.d.ts +1 -1
  84. package/dist/contexts/index.js +11 -7
  85. package/dist/contexts/index.js.map +1 -1
  86. package/dist/contexts/index.mjs +10 -6
  87. package/dist/core/index.d.mts +128 -2
  88. package/dist/core/index.d.ts +128 -2
  89. package/dist/core/index.js +17 -4
  90. package/dist/core/index.js.map +1 -1
  91. package/dist/core/index.mjs +22 -9
  92. package/dist/email-automations/index.js +101 -96
  93. package/dist/email-automations/index.js.map +1 -1
  94. package/dist/email-automations/index.mjs +13 -8
  95. package/dist/email-automations/index.mjs.map +1 -1
  96. package/dist/features/analytics/index.js +128 -123
  97. package/dist/features/analytics/index.js.map +1 -1
  98. package/dist/features/analytics/index.mjs +13 -8
  99. package/dist/features/analytics/index.mjs.map +1 -1
  100. package/dist/features/analytics/server.js +11 -8
  101. package/dist/features/analytics/server.js.map +1 -1
  102. package/dist/features/analytics/server.mjs +9 -6
  103. package/dist/features/analytics/server.mjs.map +1 -1
  104. package/dist/features/blog/index.js +158 -153
  105. package/dist/features/blog/index.js.map +1 -1
  106. package/dist/features/blog/index.mjs +14 -9
  107. package/dist/features/blog/index.mjs.map +1 -1
  108. package/dist/features/blog/server.js +30 -26
  109. package/dist/features/blog/server.js.map +1 -1
  110. package/dist/features/blog/server.mjs +10 -6
  111. package/dist/features/blog/server.mjs.map +1 -1
  112. package/dist/features/help/index.js +44 -40
  113. package/dist/features/help/index.js.map +1 -1
  114. package/dist/features/help/index.mjs +10 -6
  115. package/dist/features/help/index.mjs.map +1 -1
  116. package/dist/features/tokenusage/index.js +134 -129
  117. package/dist/features/tokenusage/index.js.map +1 -1
  118. package/dist/features/tokenusage/index.mjs +13 -8
  119. package/dist/features/tokenusage/index.mjs.map +1 -1
  120. package/dist/index.d.mts +2 -2
  121. package/dist/index.d.ts +2 -2
  122. package/dist/index.js +18 -5
  123. package/dist/index.js.map +1 -1
  124. package/dist/index.mjs +24 -11
  125. package/dist/lifecycle-4GUOTB46.js +23 -0
  126. package/dist/lifecycle-4GUOTB46.js.map +1 -0
  127. package/dist/lifecycle-LKGPRBS7.mjs +23 -0
  128. package/dist/local-transport-7H3SPZSA.js +15 -0
  129. package/dist/local-transport-7H3SPZSA.js.map +1 -0
  130. package/dist/local-transport-QYFKA6AN.mjs +15 -0
  131. package/dist/local-transport-QYFKA6AN.mjs.map +1 -0
  132. package/dist/offline/index.d.mts +607 -0
  133. package/dist/offline/index.d.ts +607 -0
  134. package/dist/offline/index.js +1052 -0
  135. package/dist/offline/index.js.map +1 -0
  136. package/dist/offline/index.mjs +1052 -0
  137. package/dist/offline/index.mjs.map +1 -0
  138. package/dist/offline/sqlite/device-worker.mjs +62 -0
  139. package/dist/offline/sqlite/index.mjs +17392 -0
  140. package/dist/offline/sqlite/sqlite3-opfs-async-proxy.js +881 -0
  141. package/dist/offline/sqlite/sqlite3.wasm +0 -0
  142. package/dist/request-KTBV6HHH.js +10 -0
  143. package/dist/request-KTBV6HHH.js.map +1 -0
  144. package/dist/request-SQOUNWZ4.mjs +10 -0
  145. package/dist/request-SQOUNWZ4.mjs.map +1 -0
  146. package/dist/server/cachedGet.d.mts +1 -1
  147. package/dist/server/cachedGet.d.ts +1 -1
  148. package/dist/server/cachedGet.stub.d.mts +1 -1
  149. package/dist/server/cachedGet.stub.d.ts +1 -1
  150. package/dist/server/index.d.mts +28 -2
  151. package/dist/server/index.d.ts +28 -2
  152. package/dist/server/index.js +18 -8
  153. package/dist/server/index.js.map +1 -1
  154. package/dist/server/index.mjs +18 -8
  155. package/dist/{useSocket-IRHtk2xc.d.ts → useSocket-Bo9M0t0J.d.ts} +3 -1
  156. package/dist/{useSocket-C8fqU4od.d.mts → useSocket-J5qanQPN.d.mts} +3 -1
  157. package/dist/write-through-FDQ7N4YI.js +15 -0
  158. package/dist/write-through-FDQ7N4YI.js.map +1 -0
  159. package/dist/write-through-R25VQMH6.mjs +15 -0
  160. package/dist/write-through-R25VQMH6.mjs.map +1 -0
  161. package/package.json +7 -1
  162. package/pwa/sw.js +277 -26
  163. package/src/client/request.ts +37 -0
  164. package/src/components/editors/BlockNoteEditor.tsx +14 -0
  165. package/src/components/editors/__tests__/BlockNoteEditor.empty-change.spec.tsx +205 -0
  166. package/src/contexts/SocketContext.tsx +7 -1
  167. package/src/contexts/__tests__/SocketContext.session.spec.tsx +52 -5
  168. package/src/core/factories/RehydrationFactory.ts +28 -8
  169. package/src/core/factories/__tests__/RehydrationFactory.spec.ts +33 -0
  170. package/src/core/index.ts +3 -0
  171. package/src/core/interfaces/ApiData.ts +2 -0
  172. package/src/core/registry/ModuleRegistry.ts +3 -0
  173. package/src/features/auth/components/forms/Logout.tsx +8 -1
  174. package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +62 -0
  175. package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
  176. package/src/hooks/useSocket.ts +20 -1
  177. package/src/offline/__tests__/answer-builders.spec.ts +176 -0
  178. package/src/offline/__tests__/device-switch.spec.ts +179 -0
  179. package/src/offline/__tests__/generic-answers.spec.ts +380 -0
  180. package/src/offline/__tests__/local-transport.spec.ts +150 -0
  181. package/src/offline/__tests__/logout.spec.ts +122 -0
  182. package/src/offline/__tests__/offline-fixtures.ts +89 -0
  183. package/src/offline/__tests__/router.spec.ts +56 -0
  184. package/src/offline/__tests__/state.spec.ts +62 -0
  185. package/src/offline/__tests__/write-through.spec.ts +137 -0
  186. package/src/offline/config.ts +229 -0
  187. package/src/offline/configure.ts +14 -0
  188. package/src/offline/device-switch.ts +75 -0
  189. package/src/offline/generic-answers.ts +706 -0
  190. package/src/offline/index.ts +67 -0
  191. package/src/offline/local-transport.ts +189 -0
  192. package/src/offline/logout.ts +41 -0
  193. package/src/offline/router.ts +41 -0
  194. package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
  195. package/src/offline/server/build-offline-manifest.ts +49 -0
  196. package/src/offline/state-hooks.ts +10 -0
  197. package/src/offline/state.ts +143 -0
  198. package/src/offline/store/DeviceStore.ts +112 -0
  199. package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
  200. package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
  201. package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
  202. package/src/offline/store/lifecycle.ts +315 -0
  203. package/src/offline/store/schema.ts +101 -0
  204. package/src/offline/store/shared-database.ts +321 -0
  205. package/src/offline/store/sql-device-store.ts +469 -0
  206. package/src/offline/store/worker-client.ts +71 -0
  207. package/src/offline/store/worker.ts +87 -0
  208. package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
  209. package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
  210. package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
  211. package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
  212. package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
  213. package/src/offline/sync/artwork-cache.ts +67 -0
  214. package/src/offline/sync/conflicts.ts +211 -0
  215. package/src/offline/sync/counts.ts +39 -0
  216. package/src/offline/sync/drain-queue.ts +246 -0
  217. package/src/offline/sync/known-roots.ts +37 -0
  218. package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
  219. package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
  220. package/src/offline/sync/models/RootSync.ts +68 -0
  221. package/src/offline/sync/models/RootSyncInterface.ts +20 -0
  222. package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
  223. package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
  224. package/src/offline/sync/models/index.ts +5 -0
  225. package/src/offline/sync/models/offline.modules.ts +14 -0
  226. package/src/offline/sync/runtime.ts +226 -0
  227. package/src/offline/sync/session-refresh.ts +32 -0
  228. package/src/offline/sync/sync-root.ts +336 -0
  229. package/src/offline/sync/waiting.ts +40 -0
  230. package/src/offline/types.ts +115 -0
  231. package/src/offline/write-through.ts +189 -0
  232. package/src/pwa/__tests__/sw.spec.ts +358 -1
  233. package/src/server/index.ts +5 -0
  234. package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
  235. package/dist/BlockNoteEditor-75ED2CN2.mjs.map +0 -1
  236. package/dist/BlockNoteEditor-7M5QW2YQ.js.map +0 -1
  237. package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
  238. package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
  239. package/dist/chunk-AA52ORW2.mjs.map +0 -1
  240. package/dist/chunk-EWCY6QJA.mjs.map +0 -1
  241. package/dist/chunk-FF4EIQ6H.js.map +0 -1
  242. package/dist/chunk-JTPRJNT2.js.map +0 -1
  243. package/dist/chunk-L6PYFQTO.js.map +0 -1
  244. package/dist/chunk-O3PDL2CV.js.map +0 -1
  245. package/dist/chunk-PI56A4DG.mjs.map +0 -1
  246. package/dist/chunk-RXQYEEKZ.mjs.map +0 -1
  247. package/dist/chunk-V3YDVJ63.js.map +0 -1
  248. package/dist/request-5RLK2AM3.mjs +0 -9
  249. package/dist/request-EUGTI3BE.js +0 -9
  250. package/dist/request-EUGTI3BE.js.map +0 -1
  251. /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
  252. /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
  253. /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
  254. /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
@@ -12,7 +12,13 @@ const SW_SOURCE = readFileSync(SW_PATH, "utf8");
12
12
 
13
13
  const ORIGIN = "https://app.test";
14
14
 
15
- type SwConfig = { appId: string; offlineRevision: string; notificationIcon: string };
15
+ type SwConfig = {
16
+ appId: string;
17
+ offlineRevision: string;
18
+ notificationIcon: string;
19
+ offlineShell?: { default: string; locales?: Record<string, string> };
20
+ offlineImageHosts?: string[];
21
+ };
16
22
  type Listener = (event: any) => void;
17
23
 
18
24
  // A worker resolves relative Request URLs against its own location; Node's
@@ -43,6 +49,7 @@ function loadWorker(config: SwConfig = { appId: "test", offlineRevision: "abc123
43
49
  fetch: vi.fn(),
44
50
  Response: globalThis.Response,
45
51
  Request: WorkerRequest,
52
+ URL: globalThis.URL,
46
53
  location: { origin: ORIGIN },
47
54
  console,
48
55
  };
@@ -81,6 +88,23 @@ function makeCache(overrides: Record<string, any> = {}) {
81
88
  };
82
89
  }
83
90
 
91
+ const SHELL_CONFIG: SwConfig = {
92
+ appId: "test",
93
+ offlineRevision: "abc12345",
94
+ notificationIcon: "/icon.png",
95
+ offlineShell: { default: "/offline-shell", locales: { it: "/it/offline-shell" } },
96
+ offlineImageHosts: ["api.app.test"],
97
+ };
98
+
99
+ /** The entry the worker writes last into a shell cache; a cache without it is incomplete. */
100
+ const SHELL_COMPLETE_URL = "/__nja-offline-shell-complete__";
101
+
102
+ const MANIFEST = { buildId: "build-2", assets: ["/_next/static/chunks/a.js", "/_next/static/css/b.css"] };
103
+
104
+ function manifestResponse(manifest = MANIFEST) {
105
+ return new Response(JSON.stringify(manifest), { status: 200, headers: { "content-type": "application/json" } });
106
+ }
107
+
84
108
  function navigation(init: Record<string, any> = {}) {
85
109
  return {
86
110
  request: { mode: "navigate", destination: "document", url: `${ORIGIN}/campaigns` },
@@ -306,4 +330,337 @@ describe("pwa/sw.js", () => {
306
330
  expect(sandbox.clients.matchAll).not.toHaveBeenCalled();
307
331
  expect(client.postMessage).not.toHaveBeenCalled();
308
332
  });
333
+
334
+ describe("offline shell (opt-in)", () => {
335
+ it("without offlineShell, install caches only /offline.html (unchanged)", async () => {
336
+ const { sandbox, dispatch } = loadWorker();
337
+ const cache = makeCache();
338
+ sandbox.caches.open.mockResolvedValue(cache);
339
+
340
+ const event = dispatch("install");
341
+ await event.waitUntilPromise;
342
+
343
+ expect(sandbox.fetch).not.toHaveBeenCalled();
344
+ expect(sandbox.caches.open).toHaveBeenCalledTimes(1);
345
+ expect(sandbox.caches.open).toHaveBeenCalledWith("test-offline-abc12345");
346
+ expect(cache.addAll).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it("with offlineShell, install reads /offline-manifest.json and caches every asset plus each shell URL in <appId>-shell-<buildId>", async () => {
350
+ const { sandbox, dispatch } = loadWorker(SHELL_CONFIG);
351
+ const offlineCache = makeCache();
352
+ const shellCache = makeCache();
353
+ sandbox.caches.open.mockImplementation(async (name: string) =>
354
+ name === "test-shell-build-2" ? shellCache : offlineCache,
355
+ );
356
+ sandbox.fetch.mockResolvedValue(manifestResponse());
357
+
358
+ const event = dispatch("install");
359
+ await event.waitUntilPromise;
360
+
361
+ expect(sandbox.fetch).toHaveBeenCalledTimes(1);
362
+ const manifestRequest = sandbox.fetch.mock.calls[0][0];
363
+ expect(new URL(manifestRequest.url).pathname).toBe("/offline-manifest.json");
364
+ expect(manifestRequest.cache).toBe("no-store");
365
+ expect(offlineCache.add).toHaveBeenCalledTimes(1);
366
+ expect(sandbox.caches.open).toHaveBeenCalledWith("test-shell-build-2");
367
+ expect(shellCache.addAll).toHaveBeenCalledTimes(1);
368
+ const cached = shellCache.addAll.mock.calls[0][0].map((request: Request) => new URL(request.url).pathname);
369
+ expect(cached.sort()).toEqual(
370
+ ["/_next/static/chunks/a.js", "/_next/static/css/b.css", "/offline-shell", "/it/offline-shell"].sort(),
371
+ );
372
+ for (const request of shellCache.addAll.mock.calls[0][0]) expect(request.cache).toBe("reload");
373
+ // The completion marker is written last, after every file is in.
374
+ expect(shellCache.put).toHaveBeenCalledTimes(1);
375
+ expect(shellCache.put.mock.calls[0][0]).toBe(SHELL_COMPLETE_URL);
376
+ expect(shellCache.put.mock.invocationCallOrder[0]).toBeGreaterThan(shellCache.addAll.mock.invocationCallOrder[0]);
377
+ });
378
+
379
+ it("install fails, and the old worker stays, when any asset fails", async () => {
380
+ const { sandbox, dispatch } = loadWorker(SHELL_CONFIG);
381
+ const offlineCache = makeCache();
382
+ const shellCache = makeCache({ addAll: vi.fn().mockRejectedValue(new Error("Request failed")) });
383
+ sandbox.caches.open.mockImplementation(async (name: string) =>
384
+ name === "test-shell-build-2" ? shellCache : offlineCache,
385
+ );
386
+ sandbox.caches.delete.mockResolvedValue(true);
387
+ sandbox.fetch.mockResolvedValue(manifestResponse());
388
+
389
+ const event = dispatch("install");
390
+
391
+ await expect(event.waitUntilPromise).rejects.toThrow("Request failed");
392
+ // The half-filled shell cache is deleted, so no worker ever picks it.
393
+ expect(sandbox.caches.delete).toHaveBeenCalledWith("test-shell-build-2");
394
+ expect(shellCache.put).not.toHaveBeenCalled();
395
+ });
396
+
397
+ it("never answers from a shell cache that is not complete (no completion marker)", async () => {
398
+ const complete = new Response("build-2 shell");
399
+ const partial = new Response("build-3 shell");
400
+ const { sandbox, dispatch } = loadWorker(SHELL_CONFIG);
401
+ const caches: Record<string, ReturnType<typeof makeCache>> = {
402
+ "test-offline-abc12345": makeCache(),
403
+ "test-shell-build-2": makeCache({
404
+ match: vi.fn(async (url: string) =>
405
+ url === SHELL_COMPLETE_URL ? new Response("") : url === "/offline-shell" ? complete : undefined,
406
+ ),
407
+ }),
408
+ "test-shell-build-3": makeCache({
409
+ match: vi.fn(async (url: string) => (url === "/offline-shell" ? partial : undefined)),
410
+ }),
411
+ };
412
+ sandbox.caches.keys.mockResolvedValue(Object.keys(caches));
413
+ sandbox.caches.open.mockImplementation(async (name: string) => caches[name]);
414
+ sandbox.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
415
+
416
+ const event = dispatch("fetch", navigation());
417
+
418
+ expect(await event.respondWithPromise).toBe(complete);
419
+ });
420
+
421
+ it("install fails when the manifest cannot be read", async () => {
422
+ const { sandbox, dispatch } = loadWorker(SHELL_CONFIG);
423
+ sandbox.caches.open.mockResolvedValue(makeCache());
424
+ sandbox.fetch.mockResolvedValue(new Response("nope", { status: 404 }));
425
+
426
+ const event = dispatch("install");
427
+
428
+ await expect(event.waitUntilPromise).rejects.toThrow();
429
+ });
430
+
431
+ it("activate deletes shell caches of other build ids and keeps <appId>-images", async () => {
432
+ const { sandbox, dispatch } = loadWorker(SHELL_CONFIG);
433
+ sandbox.caches.open.mockResolvedValue(makeCache());
434
+ sandbox.fetch.mockResolvedValue(manifestResponse());
435
+ await dispatch("install").waitUntilPromise;
436
+
437
+ sandbox.caches.keys.mockResolvedValue([
438
+ "test-offline-abc12345",
439
+ "test-shell-build-1",
440
+ "test-shell-build-2",
441
+ "test-images",
442
+ "serwist-precache",
443
+ ]);
444
+ sandbox.caches.delete.mockResolvedValue(true);
445
+ sandbox.registration.navigationPreload.enable.mockResolvedValue(undefined);
446
+ sandbox.clients.claim.mockResolvedValue(undefined);
447
+
448
+ await dispatch("activate").waitUntilPromise;
449
+
450
+ const deleted = sandbox.caches.delete.mock.calls.map((call: any[]) => call[0]).sort();
451
+ expect(deleted).toEqual(["serwist-precache", "test-shell-build-1"]);
452
+ expect(sandbox.clients.claim).toHaveBeenCalledTimes(1);
453
+ });
454
+
455
+ it("activate without offlineShell still deletes the images and shell caches (unchanged)", async () => {
456
+ const { sandbox, dispatch } = loadWorker();
457
+ sandbox.caches.keys.mockResolvedValue(["test-offline-abc12345", "test-shell-build-1", "test-images"]);
458
+ sandbox.caches.delete.mockResolvedValue(true);
459
+
460
+ await dispatch("activate").waitUntilPromise;
461
+
462
+ const deleted = sandbox.caches.delete.mock.calls.map((call: any[]) => call[0]).sort();
463
+ expect(deleted).toEqual(["test-images", "test-shell-build-1"]);
464
+ });
465
+
466
+ function shellWorker(shellEntries: Record<string, Response | undefined>, offlinePage?: Response) {
467
+ const worker = loadWorker(SHELL_CONFIG);
468
+ const entries: Record<string, Response | undefined> = { [SHELL_COMPLETE_URL]: new Response(""), ...shellEntries };
469
+ const shellCache = makeCache({ match: vi.fn(async (url: string) => entries[url]) });
470
+ const offlineCache = makeCache({ match: vi.fn().mockResolvedValue(offlinePage) });
471
+ worker.sandbox.caches.keys.mockResolvedValue(["test-offline-abc12345", "test-shell-build-2"]);
472
+ worker.sandbox.caches.open.mockImplementation(async (name: string) =>
473
+ name === "test-shell-build-2" ? shellCache : offlineCache,
474
+ );
475
+ worker.sandbox.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
476
+ return { ...worker, shellCache, offlineCache };
477
+ }
478
+
479
+ it("a failed navigation to /it/... answers the it shell, any other path the default shell, and offline.html when no shell is cached", async () => {
480
+ const itShell = new Response("it shell");
481
+ const defaultShell = new Response("default shell");
482
+ const offline = new Response("offline page");
483
+
484
+ const withShell = shellWorker({ "/offline-shell": defaultShell, "/it/offline-shell": itShell });
485
+ const itNav = withShell.dispatch(
486
+ "fetch",
487
+ navigation({ request: { mode: "navigate", destination: "document", url: `${ORIGIN}/it/campaigns/c1/run` } }),
488
+ );
489
+ expect(await itNav.respondWithPromise).toBe(itShell);
490
+ const defaultNav = withShell.dispatch(
491
+ "fetch",
492
+ navigation({ request: { mode: "navigate", destination: "document", url: `${ORIGIN}/npcs/n1` } }),
493
+ );
494
+ expect(await defaultNav.respondWithPromise).toBe(defaultShell);
495
+
496
+ const noShell = shellWorker({}, offline);
497
+ const fallback = noShell.dispatch("fetch", navigation());
498
+ expect(await fallback.respondWithPromise).toBe(offline);
499
+ expect(noShell.offlineCache.match).toHaveBeenCalledWith("/offline.html");
500
+ });
501
+
502
+ it("after an online navigation, a new build in the manifest refreshes the shell in the background and drops the old one; the same build does nothing", async () => {
503
+ const { sandbox, dispatch } = loadWorker(SHELL_CONFIG);
504
+ // The worker runs on build-1; the server now serves build-2.
505
+ const caches = new Map<string, ReturnType<typeof makeCache>>();
506
+ const complete = makeCache({
507
+ match: vi.fn(async (url: string) => (url === SHELL_COMPLETE_URL ? new Response("") : undefined)),
508
+ });
509
+ caches.set("test-shell-build-1", complete);
510
+ sandbox.caches.keys.mockImplementation(async () => [...caches.keys()]);
511
+ sandbox.caches.open.mockImplementation(async (name: string) => {
512
+ if (!caches.has(name)) caches.set(name, makeCache());
513
+ return caches.get(name);
514
+ });
515
+ sandbox.caches.delete.mockImplementation(async (name: string) => caches.delete(name));
516
+ const page = new Response("page");
517
+ sandbox.fetch.mockImplementation(async (input: any) => {
518
+ const url = typeof input === "string" ? input : input.url;
519
+ if (url.endsWith("/offline-manifest.json")) return manifestResponse();
520
+ return page;
521
+ });
522
+
523
+ const event = dispatch("fetch", navigation());
524
+ expect(await event.respondWithPromise).toBe(page);
525
+ await event.waitUntilPromise;
526
+
527
+ const fresh = caches.get("test-shell-build-2");
528
+ expect(fresh?.addAll).toHaveBeenCalledTimes(1);
529
+ expect(fresh?.put).toHaveBeenCalledWith(SHELL_COMPLETE_URL, expect.anything());
530
+ expect(caches.has("test-shell-build-1")).toBe(false);
531
+
532
+ // Same build: no second precache.
533
+ fresh?.match.mockImplementation(async (url: string) =>
534
+ url === SHELL_COMPLETE_URL ? new Response("") : undefined,
535
+ );
536
+ const again = dispatch("fetch", navigation());
537
+ await again.respondWithPromise;
538
+ await again.waitUntilPromise;
539
+ expect(fresh?.addAll).toHaveBeenCalledTimes(1);
540
+ });
541
+
542
+ it("a GET under /_next/static/ is served cache-first from the shell cache", async () => {
543
+ const cachedChunk = new Response("cached chunk");
544
+ const { sandbox, dispatch } = shellWorker({ [`${ORIGIN}/_next/static/chunks/a.js`]: cachedChunk });
545
+
546
+ const hit = dispatch("fetch", {
547
+ request: { method: "GET", mode: "no-cors", destination: "script", url: `${ORIGIN}/_next/static/chunks/a.js` },
548
+ });
549
+ expect(hit.respondWith).toHaveBeenCalledTimes(1);
550
+ expect(await hit.respondWithPromise).toBe(cachedChunk);
551
+ expect(sandbox.fetch).not.toHaveBeenCalled();
552
+
553
+ const network = new Response("network chunk");
554
+ sandbox.fetch.mockResolvedValue(network);
555
+ const missRequest = {
556
+ method: "GET",
557
+ mode: "no-cors",
558
+ destination: "script",
559
+ url: `${ORIGIN}/_next/static/chunks/missing.js`,
560
+ };
561
+ const miss = dispatch("fetch", { request: missRequest });
562
+ expect(await miss.respondWithPromise).toBe(network);
563
+ expect(sandbox.fetch).toHaveBeenCalledWith(missRequest);
564
+ });
565
+
566
+ it("another same-origin GET in the shell cache (the device worker, SQLite) goes to the network first and is answered from the cache when the network fails", async () => {
567
+ const workerUrl = `${ORIGIN}/offline-sqlite/device-worker.mjs`;
568
+ const cachedWorker = new Response("cached worker");
569
+ const { sandbox, dispatch } = shellWorker({ [workerUrl]: cachedWorker });
570
+ const request = { method: "GET", mode: "same-origin", destination: "worker", url: workerUrl };
571
+
572
+ const online = new Response("network worker");
573
+ sandbox.fetch.mockResolvedValue(online);
574
+ const onlineEvent = dispatch("fetch", { request });
575
+ expect(onlineEvent.respondWith).toHaveBeenCalledTimes(1);
576
+ expect(await onlineEvent.respondWithPromise).toBe(online);
577
+
578
+ sandbox.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
579
+ const offlineEvent = dispatch("fetch", { request });
580
+ expect(await offlineEvent.respondWithPromise).toBe(cachedWorker);
581
+
582
+ const notCached = dispatch("fetch", {
583
+ request: { method: "GET", mode: "cors", destination: "", url: `${ORIGIN}/campaigns?_rsc=1` },
584
+ });
585
+ // Not in the shell: a plain network error for the page, never an uncaught rejection in the worker.
586
+ expect(((await notCached.respondWithPromise) as Response).type).toBe("error");
587
+ });
588
+
589
+ it("a no-store GET (the session refresh of the shell) is never answered from the cache: it goes to the network untouched", async () => {
590
+ const cachedShell = new Response("cached shell");
591
+ const { sandbox, dispatch } = shellWorker({
592
+ "/offline-shell": cachedShell,
593
+ [`${ORIGIN}/offline-shell`]: cachedShell,
594
+ });
595
+
596
+ const event = dispatch("fetch", {
597
+ request: { method: "GET", mode: "cors", cache: "no-store", destination: "", url: `${ORIGIN}/offline-shell` },
598
+ });
599
+
600
+ expect(event.respondWith).not.toHaveBeenCalled();
601
+ expect(sandbox.fetch).not.toHaveBeenCalled();
602
+ });
603
+
604
+ it("a GET to an offlineImageHosts host for a URL in the images cache is answered from the cache when the network fails", async () => {
605
+ const { sandbox, dispatch } = loadWorker(SHELL_CONFIG);
606
+ const imageUrl = "https://api.app.test/s3/object?key=art.png&sig=1";
607
+ const cachedImage = new Response("image bytes");
608
+ const imagesCache = makeCache({
609
+ match: vi.fn(async (url: string) => (url === imageUrl ? cachedImage : undefined)),
610
+ });
611
+ sandbox.caches.open.mockImplementation(async (name: string) =>
612
+ name === "test-images" ? imagesCache : makeCache(),
613
+ );
614
+ sandbox.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
615
+
616
+ const event = dispatch("fetch", {
617
+ request: { method: "GET", mode: "no-cors", destination: "image", url: imageUrl },
618
+ });
619
+
620
+ expect(event.respondWith).toHaveBeenCalledTimes(1);
621
+ expect(await event.respondWithPromise).toBe(cachedImage);
622
+ expect(sandbox.caches.open).toHaveBeenCalledWith("test-images");
623
+
624
+ const online = new Response("fresh bytes");
625
+ sandbox.fetch.mockResolvedValue(online);
626
+ const onlineEvent = dispatch("fetch", {
627
+ request: { method: "GET", mode: "no-cors", destination: "image", url: imageUrl },
628
+ });
629
+ expect(await onlineEvent.respondWithPromise).toBe(online);
630
+
631
+ const otherHost = dispatch("fetch", {
632
+ request: { method: "GET", mode: "no-cors", destination: "image", url: "https://cdn.other.test/a.png" },
633
+ });
634
+ expect(otherHost.respondWith).not.toHaveBeenCalled();
635
+ });
636
+
637
+ it("an offline image host's image through Next's optimizer (/_next/image) falls back to the downloaded original", async () => {
638
+ const { sandbox, dispatch } = loadWorker(SHELL_CONFIG);
639
+ const imageUrl = "https://api.app.test/s3/object?key=art.png&sig=1";
640
+ const cachedImage = new Response("image bytes");
641
+ const imagesCache = makeCache({
642
+ match: vi.fn(async (url: string) => (url === imageUrl ? cachedImage : undefined)),
643
+ });
644
+ sandbox.caches.open.mockImplementation(async (name: string) =>
645
+ name === "test-images" ? imagesCache : makeCache(),
646
+ );
647
+ sandbox.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
648
+ const optimized = `${ORIGIN}/_next/image?url=${encodeURIComponent(imageUrl)}&w=256&q=75`;
649
+
650
+ const event = dispatch("fetch", {
651
+ request: { method: "GET", mode: "no-cors", destination: "image", url: optimized },
652
+ });
653
+ expect(await event.respondWithPromise).toBe(cachedImage);
654
+
655
+ const missing = dispatch("fetch", {
656
+ request: {
657
+ method: "GET",
658
+ mode: "no-cors",
659
+ destination: "image",
660
+ url: `${ORIGIN}/_next/image?url=${encodeURIComponent("https://api.app.test/s3/other")}&w=256&q=75`,
661
+ },
662
+ });
663
+ expect(((await missing.respondWithPromise) as Response).type).toBe("error");
664
+ });
665
+ });
309
666
  });
@@ -15,3 +15,8 @@ export { NotificationService as ServerNotificationService } from "../features/no
15
15
  export { FeatureService as ServerFeatureService } from "../features/feature/data/feature.service";
16
16
  export { PushService as ServerPushService } from "../features/push/data/push.service";
17
17
  export { S3Service as ServerS3Service } from "../features/s3/data/s3.service";
18
+
19
+ // Offline mode: the build manifest the service worker precaches (/offline-manifest.json).
20
+ export { buildOfflineManifest } from "../offline/server/build-offline-manifest";
21
+ export type { OfflineManifest } from "../offline/server/build-offline-manifest";
22
+ export { DEFAULT_SQLITE_MODULE_URL, OFFLINE_SQLITE_FILES } from "../offline/types";
@@ -0,0 +1,243 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ClientJsonApiGet, configureClientJsonApi } from "../../client/JsonApiClient";
3
+ import { ClientAbstractService } from "../../core/abstracts/ClientAbstractService";
4
+ import { DataClassRegistry } from "../../core/registry/DataClassRegistry";
5
+ import { resetOfflineConfig, setDeviceStoreOpener } from "../../offline/config";
6
+ import {
7
+ allowWhileReconnecting,
8
+ getOfflineState,
9
+ isReconnecting,
10
+ resetOfflineState,
11
+ setOfflineState,
12
+ setReconnecting,
13
+ } from "../../offline/state";
14
+ import type { DeviceStore } from "../../offline/store/DeviceStore";
15
+ import { OFFLINE_REPLAY_HEADER } from "../../offline/types";
16
+ import {
17
+ Factions,
18
+ Npcs,
19
+ TestModel,
20
+ npc,
21
+ setUpOffline,
22
+ tearDownOffline,
23
+ } from "../../offline/__tests__/offline-fixtures";
24
+ import { configureJsonApi, JsonApiDelete, JsonApiGet, JsonApiPatch } from "../JsonApiRequest";
25
+
26
+ const API = "http://api.test/";
27
+
28
+ function jsonResponse(status: number, body: unknown) {
29
+ return { ok: status >= 200 && status < 300, status, statusText: "", json: async () => body };
30
+ }
31
+
32
+ class TestService extends ClientAbstractService {}
33
+
34
+ describe("the offline layer in the request path", () => {
35
+ let fetchMock: ReturnType<typeof vi.fn>;
36
+ let store: DeviceStore;
37
+
38
+ beforeEach(() => {
39
+ DataClassRegistry.registerObjectClass(Npcs, TestModel as any);
40
+ DataClassRegistry.registerObjectClass(Factions, TestModel as any);
41
+ configureJsonApi({ apiUrl: API });
42
+ configureClientJsonApi({ apiUrl: API });
43
+ fetchMock = vi.fn();
44
+ vi.stubGlobal("fetch", fetchMock);
45
+ });
46
+
47
+ afterEach(() => {
48
+ tearDownOffline();
49
+ vi.unstubAllGlobals();
50
+ });
51
+
52
+ it("not configured → directFetch exactly as today, no store opened", async () => {
53
+ resetOfflineConfig();
54
+ resetOfflineState();
55
+ const opener = vi.fn();
56
+ setDeviceStoreOpener(opener);
57
+ fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: npc("n1", "Server", "C") }));
58
+ fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
59
+ fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: npc("n1", "Server", "C") }));
60
+
61
+ const read = await JsonApiGet({ classKey: Npcs, endpoint: "npcs/n1", language: "en" });
62
+ const unreachable = await JsonApiGet({ classKey: Npcs, endpoint: "npcs/n1", language: "en" });
63
+ await JsonApiPatch({
64
+ classKey: Npcs,
65
+ endpoint: "npcs/n1",
66
+ body: { data: {} },
67
+ overridesJsonApiCreation: true,
68
+ language: "en",
69
+ });
70
+
71
+ expect((read.data as any).name).toBe("Server");
72
+ expect(unreachable.response).toBe(503);
73
+ expect(fetchMock).toHaveBeenCalledTimes(3);
74
+ expect(opener).not.toHaveBeenCalled();
75
+ expect(getOfflineState().online).toBe(true);
76
+ });
77
+
78
+ it("configured and online, GET → server answer, nothing stored", async () => {
79
+ store = await setUpOffline();
80
+ fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: npc("n1", "Server", "C") }));
81
+
82
+ const response = await JsonApiGet({ classKey: Npcs, endpoint: "npcs/n1", language: "en" });
83
+
84
+ expect((response.data as any).name).toBe("Server");
85
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Alda");
86
+ });
87
+
88
+ it("configured and online, a successful PATCH writes the response resource and included to the store", async () => {
89
+ store = await setUpOffline();
90
+ fetchMock.mockResolvedValueOnce(
91
+ jsonResponse(200, {
92
+ data: {
93
+ ...npc("n1", "Alda online", "C"),
94
+ attributes: { name: "Alda online", updatedAt: "2026-11-01T10:00:00.000Z" },
95
+ },
96
+ included: [
97
+ {
98
+ type: "factions",
99
+ id: "f2",
100
+ attributes: { name: "New guild" },
101
+ relationships: { root: { data: { type: "roots", id: "C" } } },
102
+ },
103
+ ],
104
+ }),
105
+ );
106
+
107
+ await JsonApiPatch({
108
+ classKey: Npcs,
109
+ endpoint: "npcs/n1",
110
+ body: { data: {} },
111
+ overridesJsonApiCreation: true,
112
+ language: "en",
113
+ });
114
+
115
+ const stored = await store.getRecord("npcs", "n1");
116
+ expect(stored?.resource.attributes).toEqual({
117
+ name: "Alda online",
118
+ description: "Alda text",
119
+ updatedAt: "2026-11-01T10:00:00.000Z",
120
+ });
121
+ expect(stored?.updatedAt).toEqual(new Date("2026-11-01T10:00:00.000Z"));
122
+ expect((await store.getRecord("factions", "f2"))?.rootId).toBe("C");
123
+ expect(await store.queue()).toEqual([]);
124
+ });
125
+
126
+ it("configured and online, a successful POST stores the new record; a DELETE removes it", async () => {
127
+ store = await setUpOffline();
128
+ fetchMock.mockResolvedValueOnce(jsonResponse(201, { data: npc("n7", "Created", "C") }));
129
+ fetchMock.mockResolvedValueOnce({
130
+ ok: true,
131
+ status: 204,
132
+ statusText: "",
133
+ json: async () => {
134
+ throw new Error("empty");
135
+ },
136
+ });
137
+
138
+ await TestService["callApi"]({
139
+ type: Npcs,
140
+ method: "POST" as any,
141
+ endpoint: "npcs",
142
+ input: { data: {} },
143
+ overridesJsonApiCreation: true,
144
+ });
145
+ expect((await store.getRecord("npcs", "n7"))?.rootId).toBe("C");
146
+
147
+ await JsonApiDelete({ classKey: Npcs, endpoint: "npcs/n7", language: "en" });
148
+ expect(await store.getRecord("npcs", "n7")).toBeUndefined();
149
+ });
150
+
151
+ it("configured, network error → state.online becomes false and the same request is answered locally", async () => {
152
+ store = await setUpOffline();
153
+ fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
154
+
155
+ const response = await JsonApiGet({ classKey: Npcs, endpoint: "npcs/n1", language: "en" });
156
+
157
+ expect(getOfflineState().online).toBe(false);
158
+ expect(response.ok).toBe(true);
159
+ expect((response.data as any).name).toBe("Alda");
160
+
161
+ const next = await JsonApiGet({ classKey: Npcs, endpoint: "npcs/n2", language: "en" });
162
+ expect((next.data as any).name).toBe("brom");
163
+ expect(fetchMock).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it("during a reconnect attempt (offline), GETs and the let-through write go to the network, any other write stays local; a network error ends the attempt", async () => {
167
+ store = await setUpOffline();
168
+ setOfflineState({ online: false });
169
+ setReconnecting(true);
170
+ fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: npc("n1", "Server", "C") }));
171
+ fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: npc("n1", "Sent", "C") }));
172
+
173
+ const read = await JsonApiGet({ classKey: Npcs, endpoint: "npcs/n1", language: "en" });
174
+ expect((read.data as any).name).toBe("Server");
175
+
176
+ const release = allowWhileReconnecting(`${API}npcs/n1`);
177
+ await JsonApiPatch({
178
+ classKey: Npcs,
179
+ endpoint: "npcs/n1",
180
+ body: { data: npc("n1", "Sent", "C") },
181
+ overridesJsonApiCreation: true,
182
+ language: "en",
183
+ });
184
+ release();
185
+ expect(fetchMock).toHaveBeenCalledTimes(2);
186
+
187
+ await JsonApiPatch({
188
+ classKey: Npcs,
189
+ endpoint: "npcs/n2",
190
+ body: { data: npc("n2", "Local", "C") },
191
+ overridesJsonApiCreation: true,
192
+ language: "en",
193
+ });
194
+ expect(fetchMock).toHaveBeenCalledTimes(2);
195
+ expect(getOfflineState().online).toBe(false);
196
+
197
+ fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
198
+ const failed = await JsonApiGet({ classKey: Npcs, endpoint: "npcs/n1", language: "en" });
199
+ expect(failed.ok).toBe(true);
200
+ expect(isReconnecting()).toBe(false);
201
+ });
202
+
203
+ it("a request carrying x-offline-replay is never answered locally", async () => {
204
+ store = await setUpOffline();
205
+ setOfflineState({ online: false });
206
+ configureJsonApi({ apiUrl: API, additionalHeaders: { [OFFLINE_REPLAY_HEADER]: "1" } });
207
+ fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
208
+
209
+ const response = await JsonApiGet({ classKey: Npcs, endpoint: "npcs/n1", language: "en" });
210
+
211
+ expect(fetchMock).toHaveBeenCalledTimes(1);
212
+ expect(response.response).toBe(503);
213
+ expect(response.ok).toBe(false);
214
+ });
215
+
216
+ it("ClientAbstractService.next/previous and ClientJsonApiGet are answered locally when offline", async () => {
217
+ store = await setUpOffline();
218
+ setOfflineState({ online: false });
219
+
220
+ const first = await ClientJsonApiGet({
221
+ classKey: Npcs,
222
+ endpoint: "roots/C/npcs?orderBy=name&page[size]=1",
223
+ language: "en",
224
+ });
225
+ expect((first.data as any[]).map((item) => item.name)).toEqual(["Alda"]);
226
+ expect(first.next).toBe(`${API}roots/C/npcs?orderBy=name&page[size]=1&page[offset]=1`);
227
+
228
+ const nextRef: { next?: string } = {};
229
+ const previousRef: { previous?: string } = {};
230
+ const page2 = await TestService.next<any[]>({
231
+ type: Npcs,
232
+ endpoint: first.next!,
233
+ next: nextRef,
234
+ previous: previousRef,
235
+ });
236
+ expect(page2.map((item) => item.name)).toEqual(["brom"]);
237
+ expect(previousRef.previous).toBe(`${API}roots/C/npcs?orderBy=name&page[size]=1&page[offset]=0`);
238
+
239
+ const page1 = await TestService.previous<any[]>({ type: Npcs, endpoint: previousRef.previous! });
240
+ expect(page1.map((item) => item.name)).toEqual(["Alda"]);
241
+ expect(fetchMock).not.toHaveBeenCalled();
242
+ });
243
+ });