@carlonicora/nextjs-jsonapi 6.12.1 → 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 (255) 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-N2NBYCPC.mjs → BlockNoteEditor-22Q4DJBU.mjs} +20 -8
  4. package/dist/BlockNoteEditor-22Q4DJBU.mjs.map +1 -0
  5. package/dist/{BlockNoteEditor-CBYKEXAQ.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-6NZKWIOR.js → chunk-2H342HNN.js} +956 -929
  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-6XJOQFCP.mjs → chunk-H4NLDX5G.mjs} +59 -32
  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/containers/RoundPageContainer.tsx +8 -8
  165. package/src/components/editors/BlockNoteEditor.tsx +14 -0
  166. package/src/components/editors/__tests__/BlockNoteEditor.empty-change.spec.tsx +205 -0
  167. package/src/contexts/SocketContext.tsx +7 -1
  168. package/src/contexts/__tests__/SocketContext.session.spec.tsx +52 -5
  169. package/src/core/factories/RehydrationFactory.ts +28 -8
  170. package/src/core/factories/__tests__/RehydrationFactory.spec.ts +33 -0
  171. package/src/core/index.ts +3 -0
  172. package/src/core/interfaces/ApiData.ts +2 -0
  173. package/src/core/registry/ModuleRegistry.ts +3 -0
  174. package/src/features/auth/components/forms/Logout.tsx +8 -1
  175. package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +62 -0
  176. package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
  177. package/src/hooks/useSocket.ts +20 -1
  178. package/src/offline/__tests__/answer-builders.spec.ts +176 -0
  179. package/src/offline/__tests__/device-switch.spec.ts +179 -0
  180. package/src/offline/__tests__/generic-answers.spec.ts +380 -0
  181. package/src/offline/__tests__/local-transport.spec.ts +150 -0
  182. package/src/offline/__tests__/logout.spec.ts +122 -0
  183. package/src/offline/__tests__/offline-fixtures.ts +89 -0
  184. package/src/offline/__tests__/router.spec.ts +56 -0
  185. package/src/offline/__tests__/state.spec.ts +62 -0
  186. package/src/offline/__tests__/write-through.spec.ts +137 -0
  187. package/src/offline/config.ts +229 -0
  188. package/src/offline/configure.ts +14 -0
  189. package/src/offline/device-switch.ts +75 -0
  190. package/src/offline/generic-answers.ts +706 -0
  191. package/src/offline/index.ts +67 -0
  192. package/src/offline/local-transport.ts +189 -0
  193. package/src/offline/logout.ts +41 -0
  194. package/src/offline/router.ts +41 -0
  195. package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
  196. package/src/offline/server/build-offline-manifest.ts +49 -0
  197. package/src/offline/state-hooks.ts +10 -0
  198. package/src/offline/state.ts +143 -0
  199. package/src/offline/store/DeviceStore.ts +112 -0
  200. package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
  201. package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
  202. package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
  203. package/src/offline/store/lifecycle.ts +315 -0
  204. package/src/offline/store/schema.ts +101 -0
  205. package/src/offline/store/shared-database.ts +321 -0
  206. package/src/offline/store/sql-device-store.ts +469 -0
  207. package/src/offline/store/worker-client.ts +71 -0
  208. package/src/offline/store/worker.ts +87 -0
  209. package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
  210. package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
  211. package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
  212. package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
  213. package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
  214. package/src/offline/sync/artwork-cache.ts +67 -0
  215. package/src/offline/sync/conflicts.ts +211 -0
  216. package/src/offline/sync/counts.ts +39 -0
  217. package/src/offline/sync/drain-queue.ts +246 -0
  218. package/src/offline/sync/known-roots.ts +37 -0
  219. package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
  220. package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
  221. package/src/offline/sync/models/RootSync.ts +68 -0
  222. package/src/offline/sync/models/RootSyncInterface.ts +20 -0
  223. package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
  224. package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
  225. package/src/offline/sync/models/index.ts +5 -0
  226. package/src/offline/sync/models/offline.modules.ts +14 -0
  227. package/src/offline/sync/runtime.ts +226 -0
  228. package/src/offline/sync/session-refresh.ts +32 -0
  229. package/src/offline/sync/sync-root.ts +336 -0
  230. package/src/offline/sync/waiting.ts +40 -0
  231. package/src/offline/types.ts +115 -0
  232. package/src/offline/write-through.ts +189 -0
  233. package/src/pwa/__tests__/sw.spec.ts +358 -1
  234. package/src/server/index.ts +5 -0
  235. package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
  236. package/dist/BlockNoteEditor-CBYKEXAQ.js.map +0 -1
  237. package/dist/BlockNoteEditor-N2NBYCPC.mjs.map +0 -1
  238. package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
  239. package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
  240. package/dist/chunk-6NZKWIOR.js.map +0 -1
  241. package/dist/chunk-6XJOQFCP.mjs.map +0 -1
  242. package/dist/chunk-AA52ORW2.mjs.map +0 -1
  243. package/dist/chunk-EWCY6QJA.mjs.map +0 -1
  244. package/dist/chunk-FF4EIQ6H.js.map +0 -1
  245. package/dist/chunk-JTPRJNT2.js.map +0 -1
  246. package/dist/chunk-L6PYFQTO.js.map +0 -1
  247. package/dist/chunk-O3PDL2CV.js.map +0 -1
  248. package/dist/chunk-PI56A4DG.mjs.map +0 -1
  249. package/dist/request-5RLK2AM3.mjs +0 -9
  250. package/dist/request-EUGTI3BE.js +0 -9
  251. package/dist/request-EUGTI3BE.js.map +0 -1
  252. /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
  253. /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
  254. /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
  255. /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
@@ -0,0 +1,380 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { buildGenericRoutes, linksOfKind, listAnswer } from "../generic-answers";
3
+ import { matchRoute } from "../router";
4
+ import { setOfflineState } from "../state";
5
+ import type { DeviceStore } from "../store/DeviceStore";
6
+ import type { OfflineAnswer, OfflineConfig, OfflineMethod } from "../types";
7
+ import { npc, offlineConfig, seededStore } from "./offline-fixtures";
8
+
9
+ const API = "http://api.test/";
10
+
11
+ async function answer(
12
+ store: DeviceStore,
13
+ method: OfflineMethod,
14
+ relative: string,
15
+ body?: any,
16
+ config: OfflineConfig = offlineConfig(),
17
+ ): Promise<OfflineAnswer> {
18
+ const [path, query = ""] = relative.split("?");
19
+ const match = matchRoute(buildGenericRoutes(config), method, path);
20
+ if (!match) throw new Error(`no generic route for ${method} ${path}`);
21
+ return match.route.answer({
22
+ request: { method, path, query: new URLSearchParams(query), body, url: `${API}${relative}` },
23
+ params: match.params,
24
+ store,
25
+ });
26
+ }
27
+
28
+ describe("generic answers", () => {
29
+ let store: DeviceStore;
30
+
31
+ beforeEach(async () => {
32
+ store = await seededStore();
33
+ });
34
+
35
+ afterEach(() => {
36
+ setOfflineState({ lockedRootIds: [] });
37
+ });
38
+
39
+ it("GET npcs/:id answers the resource with the linked resources as included", async () => {
40
+ const result = await answer(store, "GET", "npcs/n1");
41
+
42
+ expect(result.status).toBe(200);
43
+ expect(result.body.data).toEqual(npc("n1", "Alda", "C"));
44
+ expect(result.body.included).toEqual([
45
+ { type: "factions", id: "f1", attributes: { name: "Guild" } },
46
+ { type: "roots", id: "C", attributes: { name: "Camp C" } },
47
+ ]);
48
+ });
49
+
50
+ it("included also follows the relationships of the record and of its included records to stored records (the root record, then its owner)", async () => {
51
+ await store.putRecord({
52
+ type: "roots",
53
+ id: "C",
54
+ rootId: "C",
55
+ resource: {
56
+ type: "roots",
57
+ id: "C",
58
+ attributes: { name: "Camp C" },
59
+ relationships: { owner: { data: { type: "users", id: "u1" } } },
60
+ },
61
+ updatedAt: null,
62
+ });
63
+ await store.putRecord({
64
+ type: "users",
65
+ id: "u1",
66
+ rootId: "C",
67
+ resource: { type: "users", id: "u1", attributes: { name: "The GM" } },
68
+ updatedAt: null,
69
+ });
70
+
71
+ const result = await answer(store, "GET", "npcs/n1");
72
+
73
+ expect(
74
+ result.body.included.map((resource: { type: string; id: string }) => `${resource.type}:${resource.id}`),
75
+ ).toEqual(["factions:f1", "roots:C", "users:u1"]);
76
+ });
77
+
78
+ it("GET npcs/:id of a record not on the device (or a literal endpoint with no answer) needs a connection", async () => {
79
+ for (const relative of ["npcs/missing", "npcs/playing"]) {
80
+ const result = await answer(store, "GET", relative);
81
+ expect(result.status).toBe(503);
82
+ expect(result.body.errors[0].code).toBe("needs-connection");
83
+ }
84
+ });
85
+
86
+ it("a list of a root record or parent that is not on the device needs a connection", async () => {
87
+ for (const relative of ["roots/X/npcs?fetchAll=true", "factions/missing/npcs?fetchAll=true"]) {
88
+ const result = await answer(store, "GET", relative);
89
+ expect(result.status).toBe(503);
90
+ expect(result.body.errors[0].code).toBe("needs-connection");
91
+ }
92
+ });
93
+
94
+ it("a stored parent with no linked records answers 200 with an empty list", async () => {
95
+ const result = await answer(store, "GET", "npcs/n2/factions?fetchAll=true");
96
+
97
+ expect(result.status).toBe(200);
98
+ expect(result.body.data).toEqual([]);
99
+ });
100
+
101
+ it("GET roots/C/npcs lists only C's NPCs", async () => {
102
+ const result = await answer(store, "GET", "roots/C/npcs?fetchAll=true");
103
+
104
+ expect(result.body.data.map((r: any) => r.id).sort()).toEqual(["n1", "n2"]);
105
+ expect(result.body.links).toEqual({ self: `${API}roots/C/npcs?fetchAll=true` });
106
+ });
107
+
108
+ it("honours search like the server's fulltext index: every token, case-insensitive, in any string attribute", async () => {
109
+ const byName = await answer(store, "GET", "roots/C/npcs?fetchAll=true&search=BRO");
110
+ const acrossFields = await answer(store, "GET", "roots/C/npcs?fetchAll=true&search=brom%20TEXT");
111
+ const noMatch = await answer(store, "GET", "roots/C/npcs?fetchAll=true&search=brom%20guild");
112
+
113
+ expect(byName.body.data.map((r: any) => r.id)).toEqual(["n2"]);
114
+ expect(acrossFields.body.data.map((r: any) => r.id)).toEqual(["n2"]);
115
+ expect(noMatch.body.data).toEqual([]);
116
+ });
117
+
118
+ it("honours orderBy as the server reads it: `field` or `field ASC|DESC`", async () => {
119
+ const ascending = await answer(store, "GET", "roots/C/npcs?fetchAll=true&orderBy=name");
120
+ const descending = await answer(store, "GET", "roots/C/npcs?fetchAll=true&orderBy=name%20DESC");
121
+ const explicitAsc = await answer(store, "GET", "roots/C/npcs?fetchAll=true&orderBy=name%20asc");
122
+
123
+ expect(ascending.body.data.map((r: any) => r.id)).toEqual(["n1", "n2"]);
124
+ expect(descending.body.data.map((r: any) => r.id)).toEqual(["n2", "n1"]);
125
+ expect(explicitAsc.body.data.map((r: any) => r.id)).toEqual(["n1", "n2"]);
126
+ });
127
+
128
+ it("an orderBy the server refuses (`-name`) answers 400 like the server", async () => {
129
+ expect((await answer(store, "GET", "roots/C/npcs?fetchAll=true&orderBy=-name")).status).toBe(400);
130
+ });
131
+
132
+ it("with no orderBy, lists in the server's default order (updatedAt DESC)", async () => {
133
+ const older = { ...npc("n1", "Alda", "C"), attributes: { name: "Alda", updatedAt: "2026-01-01T00:00:00.000Z" } };
134
+ const newer = { ...npc("n2", "brom", "C"), attributes: { name: "brom", updatedAt: "2026-02-01T00:00:00.000Z" } };
135
+ await store.putRecord({
136
+ type: "npcs",
137
+ id: "n1",
138
+ rootId: "C",
139
+ resource: older,
140
+ updatedAt: new Date("2026-01-01"),
141
+ });
142
+ await store.putRecord({
143
+ type: "npcs",
144
+ id: "n2",
145
+ rootId: "C",
146
+ resource: newer,
147
+ updatedAt: new Date("2026-02-01"),
148
+ });
149
+
150
+ const result = await answer(store, "GET", "roots/C/npcs?fetchAll=true");
151
+
152
+ expect(result.body.data.map((r: any) => r.id)).toEqual(["n2", "n1"]);
153
+ });
154
+
155
+ it("listAnswer pages, searches and orders any set of stored records for app answers", async () => {
156
+ const records = await store.listRecords({ type: "npcs", rootId: "C" });
157
+
158
+ const result = await listAnswer(
159
+ {
160
+ request: { method: "GET", path: "x", query: new URLSearchParams("orderBy=name%20DESC&page[size]=1") },
161
+ params: {},
162
+ store,
163
+ },
164
+ records,
165
+ );
166
+
167
+ expect(result.body.data.map((r: any) => r.id)).toEqual(["n2"]);
168
+ expect(result.body.meta.total).toBe(2);
169
+ });
170
+
171
+ it("pages with page[size]/page[offset] and links shaped like the server's", async () => {
172
+ const first = await answer(store, "GET", "roots/C/npcs?orderBy=name&page[size]=1");
173
+
174
+ expect(first.body.data.map((r: any) => r.id)).toEqual(["n1"]);
175
+ expect(first.body.links.self).toBe(`${API}roots/C/npcs?orderBy=name&page[size]=1`);
176
+ expect(first.body.links.next).toBe(`${API}roots/C/npcs?orderBy=name&page[size]=1&page[offset]=1`);
177
+ expect(first.body.links.prev).toBeUndefined();
178
+
179
+ const second = await answer(store, "GET", "roots/C/npcs?orderBy=name&page[size]=1&page[offset]=1");
180
+
181
+ expect(second.body.data.map((r: any) => r.id)).toEqual(["n2"]);
182
+ expect(second.body.links.next).toBeUndefined();
183
+ expect(second.body.links.prev).toBe(`${API}roots/C/npcs?orderBy=name&page[size]=1&page[offset]=0`);
184
+ });
185
+
186
+ it("GET factions/:id/npcs and npcs/:id/factions answer records linked in either direction", async () => {
187
+ const npcs = await answer(store, "GET", "factions/f1/npcs?fetchAll=true");
188
+ const factions = await answer(store, "GET", "npcs/n1/factions?fetchAll=true");
189
+
190
+ expect(npcs.body.data.map((r: any) => r.id)).toEqual(["n1"]);
191
+ expect(factions.body.data.map((r: any) => r.id)).toEqual(["f1"]);
192
+ });
193
+
194
+ it("POST npcs stores a local-only record, queues the body with base null and answers 201", async () => {
195
+ const body = { data: npc("n9", "New", "C") };
196
+
197
+ const result = await answer(store, "POST", "npcs", body);
198
+
199
+ expect(result.status).toBe(201);
200
+ expect(result.body.data).toEqual(npc("n9", "New", "C"));
201
+ expect(result.body.included).toEqual([{ type: "roots", id: "C", attributes: { name: "Camp C" } }]);
202
+ const stored = await store.getRecord("npcs", "n9");
203
+ expect(stored).toMatchObject({ rootId: "C", localOnly: true, base: null });
204
+ const [entry] = await store.queue();
205
+ expect(entry).toMatchObject({ rootId: "C", method: "POST", path: "npcs", body, base: null, status: "pending" });
206
+ });
207
+
208
+ it.each(["PATCH", "PUT"] as const)(
209
+ "%s npcs/:id applies the body, sets base once and queues the body untouched",
210
+ async (method) => {
211
+ const body = { data: { type: "npcs", id: "n1", attributes: { name: "Alda the Bold" } } };
212
+
213
+ const result = await answer(store, method, "npcs/n1", body);
214
+ await answer(store, method, "npcs/n1", { data: { type: "npcs", id: "n1", attributes: { name: "Alda II" } } });
215
+
216
+ expect(result.status).toBe(200);
217
+ expect(result.body.data.attributes).toEqual({ name: "Alda the Bold", description: "Alda text" });
218
+ expect(result.body.included.map((r: any) => `${r.type}:${r.id}`)).toEqual(["factions:f1", "roots:C"]);
219
+ const stored = await store.getRecord("npcs", "n1");
220
+ expect(stored?.resource.attributes.name).toBe("Alda II");
221
+ expect(stored?.base).toEqual(npc("n1", "Alda", "C"));
222
+ const entries = await store.queue();
223
+ expect(entries).toHaveLength(2);
224
+ expect(entries[0]).toMatchObject({
225
+ method,
226
+ path: "npcs/n1",
227
+ body,
228
+ recordType: "npcs",
229
+ recordId: "n1",
230
+ base: npc("n1", "Alda", "C"),
231
+ });
232
+ expect(entries[1].base).toEqual(npc("n1", "Alda", "C"));
233
+ },
234
+ );
235
+
236
+ it("DELETE npcs/:id removes the record and its links, queues it with base and answers 204", async () => {
237
+ const result = await answer(store, "DELETE", "npcs/n1");
238
+
239
+ expect(result).toEqual({ status: 204, body: undefined });
240
+ expect(await store.getRecord("npcs", "n1")).toBeUndefined();
241
+ expect(await store.linksFrom("f1")).toEqual([]);
242
+ const [entry] = await store.queue();
243
+ expect(entry).toMatchObject({
244
+ method: "DELETE",
245
+ path: "npcs/n1",
246
+ recordType: "npcs",
247
+ recordId: "n1",
248
+ base: npc("n1", "Alda", "C"),
249
+ });
250
+ });
251
+
252
+ describe("link kinds", () => {
253
+ const withKinds = (): OfflineConfig => {
254
+ const config = offlineConfig();
255
+ config.types = config.types.map((typeConfig) =>
256
+ typeConfig.module.name === "npcs" ? { ...typeConfig, relationshipKinds: { faction: "MEMBER_OF" } } : typeConfig,
257
+ );
258
+ return config;
259
+ };
260
+ const withFaction = (id: string, name: string, factionIds: string[]) => ({
261
+ ...npc(id, name, "C"),
262
+ relationships: {
263
+ root: { data: { type: "roots", id: "C" } },
264
+ faction: { data: factionIds.map((factionId) => ({ type: "factions", id: factionId })) },
265
+ },
266
+ });
267
+
268
+ it("a link written on the device carries the Neo4j type when the type config maps the relationship name", async () => {
269
+ await answer(store, "PATCH", "npcs/n2", { data: withFaction("n2", "brom", ["f1"]) }, withKinds());
270
+
271
+ expect((await store.linksFrom("n2")).map((link) => link.kind)).toEqual(["MEMBER_OF"]);
272
+ });
273
+
274
+ it("without a mapping the link carries the relationship name", async () => {
275
+ await answer(store, "PATCH", "npcs/n2", { data: withFaction("n2", "brom", ["f1"]) });
276
+
277
+ expect((await store.linksFrom("n2")).map((link) => link.kind)).toEqual(["faction"]);
278
+ });
279
+
280
+ it("removing a relationship target removes only links of that relationship (name or Neo4j type)", async () => {
281
+ await store.putRecord({
282
+ type: "npcs",
283
+ id: "n1",
284
+ rootId: "C",
285
+ resource: withFaction("n1", "Alda", ["f1"]),
286
+ updatedAt: null,
287
+ });
288
+ await store.putLink({
289
+ fromType: "factions",
290
+ fromId: "f1",
291
+ toType: "npcs",
292
+ toId: "n1",
293
+ kind: "ENEMY_OF",
294
+ props: {},
295
+ });
296
+
297
+ await answer(store, "PATCH", "npcs/n1", { data: withFaction("n1", "Alda", []) }, withKinds());
298
+
299
+ expect((await store.linksTo("n1")).map((link) => link.kind)).toEqual(["ENEMY_OF"]);
300
+ });
301
+
302
+ it("a related list follows only the links of the relationship toward the parent, by name or by Neo4j type", async () => {
303
+ await store.putRecord({
304
+ type: "npcs",
305
+ id: "n1",
306
+ rootId: "C",
307
+ resource: withFaction("n1", "Alda", ["f1"]),
308
+ updatedAt: null,
309
+ });
310
+ await store.putLink({
311
+ fromType: "factions",
312
+ fromId: "f1",
313
+ toType: "npcs",
314
+ toId: "n2",
315
+ kind: "ENEMY_OF",
316
+ props: {},
317
+ });
318
+ await store.putLink({
319
+ fromType: "npcs",
320
+ fromId: "n3",
321
+ toType: "factions",
322
+ toId: "f1",
323
+ kind: "faction",
324
+ props: {},
325
+ });
326
+ await store.putRecord({
327
+ type: "npcs",
328
+ id: "n3",
329
+ rootId: "C",
330
+ resource: npc("n3", "Cora", "C"),
331
+ updatedAt: null,
332
+ });
333
+
334
+ const result = await answer(store, "GET", "factions/f1/npcs?fetchAll=true&orderBy=name", undefined, withKinds());
335
+
336
+ expect(result.body.data.map((r: any) => r.id)).toEqual(["n1", "n3"]);
337
+ });
338
+
339
+ it("linksOfKind answers the links of any of the kinds, in the direction asked", async () => {
340
+ await store.putLink({
341
+ fromType: "npcs",
342
+ fromId: "n1",
343
+ toType: "factions",
344
+ toId: "f1",
345
+ kind: "faction",
346
+ props: {},
347
+ });
348
+
349
+ const both = await linksOfKind(store, "n1", ["MEMBER_OF", "faction"], "both");
350
+ const out = await linksOfKind(store, "n1", ["MEMBER_OF", "faction"], "out");
351
+
352
+ expect(both.map((link) => link.kind).sort()).toEqual(["MEMBER_OF", "faction"]);
353
+ expect(out.map((link) => link.kind)).toEqual(["faction"]);
354
+ });
355
+ });
356
+
357
+ it("a write to a readOnly type answers 503 needs-connection", async () => {
358
+ const result = await answer(store, "PATCH", "characters/ch1", {
359
+ data: { type: "characters", id: "ch1", attributes: { name: "X" } },
360
+ });
361
+
362
+ expect(result.status).toBe(503);
363
+ expect(result.body.errors[0].code).toBe("needs-connection");
364
+ expect(await store.queue()).toEqual([]);
365
+ });
366
+
367
+ it("a write to a locked root record answers 503 needs-connection", async () => {
368
+ setOfflineState({ lockedRootIds: ["C"] });
369
+
370
+ const patch = await answer(store, "PATCH", "npcs/n1", {
371
+ data: { type: "npcs", id: "n1", attributes: { name: "X" } },
372
+ });
373
+ const post = await answer(store, "POST", "npcs", { data: npc("n9", "New", "C") });
374
+
375
+ expect(patch.status).toBe(503);
376
+ expect(patch.body.errors[0].code).toBe("needs-connection");
377
+ expect(post.status).toBe(503);
378
+ expect(await store.queue()).toEqual([]);
379
+ });
380
+ });
@@ -0,0 +1,150 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { configureJsonApi } from "../../unified/JsonApiRequest";
3
+ import { localTransport } from "../local-transport";
4
+ import { getOfflineState, setOfflineState } from "../state";
5
+ import type { DeviceStore } from "../store/DeviceStore";
6
+ import { NeedsConnectionError, type OfflineRoute } from "../types";
7
+ import { setDeviceStoreOpener } from "../config";
8
+ import { Roots, Npcs, setUpOffline, tearDownOffline } from "./offline-fixtures";
9
+
10
+ const API = "http://api.test/";
11
+
12
+ const mergeRoute = (answer = vi.fn()): OfflineRoute => ({
13
+ method: "POST",
14
+ pattern: [Npcs, ":id", "merge"],
15
+ answer: answer.mockImplementation(async ({ store, params }) => {
16
+ await store.deleteRecord("npcs", params.id);
17
+ return { status: 200, body: { data: { type: "npcs", id: "n2", attributes: { name: "merged" } } } };
18
+ }),
19
+ queue: true,
20
+ });
21
+
22
+ describe("localTransport", () => {
23
+ let store: DeviceStore;
24
+
25
+ beforeEach(() => {
26
+ configureJsonApi({ apiUrl: API });
27
+ });
28
+
29
+ afterEach(() => {
30
+ tearDownOffline();
31
+ });
32
+
33
+ it("answers a generic GET from the device in the ApiData shape", async () => {
34
+ store = await setUpOffline();
35
+
36
+ const response = await localTransport({ method: "GET", url: `${API}npcs/n1` });
37
+
38
+ expect(response).toMatchObject({ ok: true, status: 200 });
39
+ expect(response.data.data.id).toBe("n1");
40
+ });
41
+
42
+ it("an unknown path answers 503 with errors[0].code needs-connection", async () => {
43
+ store = await setUpOffline();
44
+
45
+ const response = await localTransport({ method: "POST", url: `${API}sessions/s1/recap` });
46
+
47
+ expect(response).toMatchObject({ ok: false, status: 503 });
48
+ expect(response.data.errors[0].code).toBe("needs-connection");
49
+ });
50
+
51
+ it("a device copy that cannot be opened (another tab holds it) answers 503 needs-connection, never 500", async () => {
52
+ store = await setUpOffline();
53
+ setDeviceStoreOpener(async () => {
54
+ throw new Error("Access Handles cannot be created if there is another open Access Handle");
55
+ });
56
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
57
+
58
+ const response = await localTransport({ method: "GET", url: `${API}npcs/n1` });
59
+
60
+ expect(response).toMatchObject({ ok: false, status: 503 });
61
+ expect(response.data.errors[0].code).toBe("needs-connection");
62
+ warn.mockRestore();
63
+ });
64
+
65
+ it("custom routes win over the generic ones", async () => {
66
+ const custom: OfflineRoute = {
67
+ method: "GET",
68
+ pattern: [Npcs, ":id"],
69
+ answer: async () => ({ status: 200, body: { data: { type: "npcs", id: "custom" } } }),
70
+ queue: false,
71
+ };
72
+ store = await setUpOffline([custom]);
73
+
74
+ const response = await localTransport({ method: "GET", url: `${API}npcs/n1` });
75
+
76
+ expect(response.data.data.id).toBe("custom");
77
+ });
78
+
79
+ it("a queue:true custom route is enqueued after its answer updated the store, with the original method, path and body", async () => {
80
+ const answer = vi.fn();
81
+ store = await setUpOffline([mergeRoute(answer)]);
82
+ const body = { data: { type: "npcs", id: "n2" } };
83
+
84
+ const response = await localTransport({ method: "POST", url: `${API}npcs/n1/merge?keep=true`, body });
85
+
86
+ expect(response.status).toBe(200);
87
+ expect(answer).toHaveBeenCalledTimes(1);
88
+ expect(await store.getRecord("npcs", "n1")).toBeUndefined();
89
+ const [entry] = await store.queue();
90
+ expect(entry).toMatchObject({ rootId: "C", method: "POST", path: "npcs/n1/merge?keep=true", body });
91
+ expect(getOfflineState().pending).toBe(1);
92
+ });
93
+
94
+ it("a queue:true custom route on a locked root record answers needs-connection and runs nothing", async () => {
95
+ const answer = vi.fn();
96
+ store = await setUpOffline([mergeRoute(answer)]);
97
+ setOfflineState({ lockedRootIds: ["C"] });
98
+
99
+ const response = await localTransport({ method: "POST", url: `${API}npcs/n1/merge`, body: {} });
100
+
101
+ expect(response.status).toBe(503);
102
+ expect(answer).not.toHaveBeenCalled();
103
+ expect(await store.queue()).toEqual([]);
104
+ });
105
+
106
+ it("a custom write that is not queued is refused on a locked root record; its GET answers stay allowed", async () => {
107
+ const write = vi.fn(async () => ({ status: 200, body: { data: { type: "npcs", id: "n1" } } }));
108
+ const read = vi.fn(async () => ({ status: 200, body: { data: { type: "npcs", id: "n1" } } }));
109
+ store = await setUpOffline([
110
+ { method: "POST", pattern: [Npcs, ":id", "pin"], answer: write, queue: false },
111
+ { method: "GET", pattern: [Npcs, ":id", "pin"], answer: read, queue: false },
112
+ ]);
113
+ setOfflineState({ lockedRootIds: ["C"] });
114
+
115
+ const refused = await localTransport({ method: "POST", url: `${API}npcs/n1/pin`, body: {} });
116
+ const allowed = await localTransport({ method: "GET", url: `${API}npcs/n1/pin` });
117
+
118
+ expect(refused.status).toBe(503);
119
+ expect(refused.data.errors[0].code).toBe("needs-connection");
120
+ expect(write).not.toHaveBeenCalled();
121
+ expect(allowed.status).toBe(200);
122
+ expect(read).toHaveBeenCalledTimes(1);
123
+ });
124
+
125
+ it("a custom answer that throws NeedsConnectionError answers 503 needs-connection", async () => {
126
+ store = await setUpOffline([
127
+ {
128
+ method: "POST",
129
+ pattern: [Roots, ":id", "sessions", "end"],
130
+ answer: async () => {
131
+ throw new NeedsConnectionError();
132
+ },
133
+ queue: false,
134
+ },
135
+ ]);
136
+
137
+ const response = await localTransport({ method: "POST", url: `${API}roots/C/sessions/end` });
138
+
139
+ expect(response.status).toBe(503);
140
+ expect(response.data.errors[0].code).toBe("needs-connection");
141
+ });
142
+
143
+ it("a file upload needs a connection", async () => {
144
+ store = await setUpOffline();
145
+
146
+ const response = await localTransport({ method: "POST", url: `${API}npcs`, body: {}, files: new Blob(["x"]) });
147
+
148
+ expect(response.data.errors[0].code).toBe("needs-connection");
149
+ });
150
+ });
@@ -0,0 +1,122 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ configureOffline,
4
+ DeviceStoreUnavailableError,
5
+ getOfflineStore,
6
+ resetOfflineConfig,
7
+ setDeviceStoreOpener,
8
+ } from "../config";
9
+ import { finishOfflineLogout, prepareOfflineLogout } from "../logout";
10
+ import { getOfflineState, resetOfflineState, setOfflineState } from "../state";
11
+ import type { DeviceStore, QueueEntry } from "../store/DeviceStore";
12
+ import type { OfflineConfig } from "../types";
13
+
14
+ const lifecycle = vi.hoisted(() => ({ deleteDeviceStore: vi.fn() }));
15
+ vi.mock("../store/lifecycle", () => lifecycle);
16
+ const runtime = vi.hoisted(() => ({ stopOfflineRuntime: vi.fn() }));
17
+ vi.mock("../sync/runtime", () => runtime);
18
+
19
+ function config(userId: string | undefined, requireOptIn?: boolean): OfflineConfig {
20
+ return {
21
+ getUserId: () => userId,
22
+ requireOptIn,
23
+ rootModule: { name: "roots" } as any,
24
+ types: [],
25
+ routes: [],
26
+ listSyncRoots: async () => [],
27
+ shellPath: () => "/offline-shell",
28
+ syncIntervalMs: 300000,
29
+ };
30
+ }
31
+
32
+ function storeWithQueue(entries: Partial<QueueEntry>[]): DeviceStore {
33
+ return { queue: vi.fn(async () => entries as QueueEntry[]) } as unknown as DeviceStore;
34
+ }
35
+
36
+ describe("offline logout", () => {
37
+ beforeEach(() => {
38
+ lifecycle.deleteDeviceStore.mockReset().mockResolvedValue(undefined);
39
+ });
40
+
41
+ afterEach(() => {
42
+ resetOfflineConfig();
43
+ });
44
+
45
+ it("prepareOfflineLogout counts every unsent queue entry of the signed-in user", async () => {
46
+ configureOffline(config("u1"));
47
+ const opener = vi.fn(async () =>
48
+ storeWithQueue([{ status: "pending" }, { status: "conflict" }, { status: "failed" }]),
49
+ );
50
+ setDeviceStoreOpener(opener);
51
+
52
+ expect(await prepareOfflineLogout()).toEqual({ pending: 3 });
53
+ expect(opener).toHaveBeenCalledWith("u1");
54
+ });
55
+
56
+ it("prepareOfflineLogout answers 0 when offline mode is not configured or nobody is signed in", async () => {
57
+ expect(await prepareOfflineLogout()).toEqual({ pending: 0 });
58
+
59
+ configureOffline(config(undefined));
60
+ const opener = vi.fn();
61
+ setDeviceStoreOpener(opener);
62
+ expect(await prepareOfflineLogout()).toEqual({ pending: 0 });
63
+ expect(opener).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("finishOfflineLogout deletes the user's database and the images cache", async () => {
67
+ const caches = {
68
+ keys: vi.fn(async () => ["wyrdli-offline-abc", "wyrdli-shell-b1", "wyrdli-images"]),
69
+ delete: vi.fn(async () => true),
70
+ };
71
+ vi.stubGlobal("caches", caches);
72
+
73
+ await finishOfflineLogout("u1");
74
+
75
+ expect(lifecycle.deleteDeviceStore).toHaveBeenCalledWith("u1");
76
+ expect(caches.delete).toHaveBeenCalledTimes(1);
77
+ expect(caches.delete).toHaveBeenCalledWith("wyrdli-images");
78
+ vi.unstubAllGlobals();
79
+ });
80
+
81
+ it("finishOfflineLogout stops the runtime first and blocks any reopen of the store, so a sync still running cannot recreate the deleted file", async () => {
82
+ vi.stubGlobal("caches", { keys: vi.fn(async () => []), delete: vi.fn() });
83
+ configureOffline(config("u1"));
84
+ const opener = vi.fn(async () => storeWithQueue([]));
85
+ setDeviceStoreOpener(opener);
86
+ runtime.stopOfflineRuntime.mockReset();
87
+ const order: string[] = [];
88
+ runtime.stopOfflineRuntime.mockImplementation(() => void order.push("stop"));
89
+ lifecycle.deleteDeviceStore.mockImplementation(async () => {
90
+ order.push("delete");
91
+ // A sync that was still running asks for the store while the file is deleted.
92
+ await expect(getOfflineStore()).rejects.toBeInstanceOf(DeviceStoreUnavailableError);
93
+ });
94
+
95
+ await finishOfflineLogout("u1");
96
+
97
+ expect(order).toEqual(["stop", "delete"]);
98
+ await expect(getOfflineStore()).rejects.toBeInstanceOf(DeviceStoreUnavailableError);
99
+ expect(opener).not.toHaveBeenCalled();
100
+ vi.unstubAllGlobals();
101
+ });
102
+
103
+ it("finishOfflineLogout turns the device switch off and clears the sync state and the last sync time", async () => {
104
+ vi.stubGlobal("caches", { keys: vi.fn(async () => []), delete: vi.fn() });
105
+ localStorage.setItem("nja-offline-enabled:u1", "1");
106
+ localStorage.setItem("nja-offline-last-sync:u1", "1761990000000");
107
+ configureOffline(config("u1", true));
108
+ setOfflineState({ rootCount: 2, syncProgress: { done: 1, total: 2 } });
109
+ expect(getOfflineState().lastSyncedAt).toBe(1761990000000);
110
+
111
+ await finishOfflineLogout("u1");
112
+
113
+ expect(localStorage.getItem("nja-offline-enabled:u1")).toBeNull();
114
+ expect(localStorage.getItem("nja-offline-last-sync:u1")).toBeNull();
115
+ expect(getOfflineState()).toEqual(
116
+ expect.objectContaining({ enabled: false, syncProgress: null, lastSyncedAt: null, rootCount: null }),
117
+ );
118
+ localStorage.clear();
119
+ resetOfflineState();
120
+ vi.unstubAllGlobals();
121
+ });
122
+ });