@carlonicora/nextjs-jsonapi 6.12.2 → 6.13.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.
- package/dist/{ApiData-DPKNfY-9.d.mts → ApiData-Be8yxCOX.d.mts} +2 -0
- package/dist/{ApiData-DPKNfY-9.d.ts → ApiData-Be8yxCOX.d.ts} +2 -0
- package/dist/{BlockNoteEditor-7M5QW2YQ.js → BlockNoteEditor-UBVEUFNR.js} +34 -22
- package/dist/BlockNoteEditor-UBVEUFNR.js.map +1 -0
- package/dist/{BlockNoteEditor-75ED2CN2.mjs → BlockNoteEditor-VXMDSQAA.mjs} +20 -8
- package/dist/BlockNoteEditor-VXMDSQAA.mjs.map +1 -0
- package/dist/{BlockNoteViewer-WGCDB7IJ.js → BlockNoteViewer-2XYASA5Y.js} +8 -5
- package/dist/BlockNoteViewer-2XYASA5Y.js.map +1 -0
- package/dist/{BlockNoteViewer-MTGS6YJV.mjs → BlockNoteViewer-NBW2H6LC.mjs} +7 -4
- package/dist/{BlockNoteViewer-MTGS6YJV.mjs.map → BlockNoteViewer-NBW2H6LC.mjs.map} +1 -1
- package/dist/{JsonApiRequest-A66XK5WT.mjs → JsonApiRequest-F6TMJ42G.mjs} +2 -2
- package/dist/JsonApiRequest-WISJZSDE.js +26 -0
- package/dist/{JsonApiRequest-FZGPLNUF.js.map → JsonApiRequest-WISJZSDE.js.map} +1 -1
- package/dist/billing/index.js +499 -494
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +13 -8
- package/dist/billing/index.mjs.map +1 -1
- package/dist/chunk-27LQDGNP.mjs +317 -0
- package/dist/chunk-27LQDGNP.mjs.map +1 -0
- package/dist/{chunk-KSYQEKJX.js → chunk-2SV4SS6N.js} +14 -14
- package/dist/{chunk-KSYQEKJX.js.map → chunk-2SV4SS6N.js.map} +1 -1
- package/dist/{chunk-4FNGWALA.mjs → chunk-47BBY5KA.mjs} +6 -4
- package/dist/{chunk-4FNGWALA.mjs.map → chunk-47BBY5KA.mjs.map} +1 -1
- package/dist/chunk-6JUCIWDC.mjs +570 -0
- package/dist/chunk-6JUCIWDC.mjs.map +1 -0
- package/dist/chunk-7DBJTNSU.mjs +1049 -0
- package/dist/chunk-7DBJTNSU.mjs.map +1 -0
- package/dist/chunk-7JZHPMGW.mjs +704 -0
- package/dist/chunk-7JZHPMGW.mjs.map +1 -0
- package/dist/{chunk-L6PYFQTO.js → chunk-7ZHTPFFT.js} +16 -14
- package/dist/chunk-7ZHTPFFT.js.map +1 -0
- package/dist/chunk-B6JGKDP2.js +214 -0
- package/dist/chunk-B6JGKDP2.js.map +1 -0
- package/dist/{chunk-3YW35YHQ.mjs → chunk-BH6JWIKI.mjs} +5 -5
- package/dist/chunk-BZ5J2FIV.js +570 -0
- package/dist/chunk-BZ5J2FIV.js.map +1 -0
- package/dist/{chunk-V5MI7RNB.mjs → chunk-DGWOVYEC.mjs} +2 -2
- package/dist/chunk-DOCIFE6A.mjs +212 -0
- package/dist/chunk-DOCIFE6A.mjs.map +1 -0
- package/dist/chunk-GB6DBFYY.js +25 -0
- package/dist/chunk-GB6DBFYY.js.map +1 -0
- package/dist/{chunk-EWCY6QJA.mjs → chunk-JJMMT24D.mjs} +37 -3
- package/dist/chunk-JJMMT24D.mjs.map +1 -0
- package/dist/{chunk-JTPRJNT2.js → chunk-KMW7UNL2.js} +38 -3
- package/dist/chunk-KMW7UNL2.js.map +1 -0
- package/dist/chunk-MHB36MYE.js +317 -0
- package/dist/chunk-MHB36MYE.js.map +1 -0
- package/dist/{chunk-FF4EIQ6H.js → chunk-MOGEGWXW.js} +1039 -1680
- package/dist/chunk-MOGEGWXW.js.map +1 -0
- package/dist/{chunk-RXQYEEKZ.mjs → chunk-N4RZZKL5.mjs} +58 -25
- package/dist/chunk-N4RZZKL5.mjs.map +1 -0
- package/dist/{chunk-AA52ORW2.mjs → chunk-P445QGOC.mjs} +61 -702
- package/dist/chunk-P445QGOC.mjs.map +1 -0
- package/dist/chunk-PV4Z5W6V.js +212 -0
- package/dist/chunk-PV4Z5W6V.js.map +1 -0
- package/dist/chunk-PXDUJ47N.js +1049 -0
- package/dist/chunk-PXDUJ47N.js.map +1 -0
- package/dist/{chunk-V3YDVJ63.js → chunk-PYRZCKC2.js} +1209 -1176
- package/dist/chunk-PYRZCKC2.js.map +1 -0
- package/dist/{chunk-GHE4VT2R.js → chunk-QKNGZDSQ.js} +2 -2
- package/dist/{chunk-GHE4VT2R.js.map → chunk-QKNGZDSQ.js.map} +1 -1
- package/dist/chunk-W62MHDWM.js +704 -0
- package/dist/chunk-W62MHDWM.js.map +1 -0
- package/dist/chunk-XXP3TQHF.mjs +25 -0
- package/dist/chunk-XXP3TQHF.mjs.map +1 -0
- package/dist/chunk-YRKIFYOE.mjs +214 -0
- package/dist/chunk-YRKIFYOE.mjs.map +1 -0
- package/dist/{chunk-O3PDL2CV.js → chunk-Z4POQWFG.js} +40 -6
- package/dist/chunk-Z4POQWFG.js.map +1 -0
- package/dist/{chunk-PI56A4DG.mjs → chunk-ZLTOUZN2.mjs} +37 -2
- package/dist/chunk-ZLTOUZN2.mjs.map +1 -0
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +14 -9
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +24 -19
- package/dist/components/index.d.mts +7 -1
- package/dist/components/index.d.ts +7 -1
- package/dist/components/index.js +11 -7
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +10 -6
- package/dist/contexts/index.d.mts +1 -1
- package/dist/contexts/index.d.ts +1 -1
- package/dist/contexts/index.js +11 -7
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +10 -6
- package/dist/core/index.d.mts +128 -2
- package/dist/core/index.d.ts +128 -2
- package/dist/core/index.js +17 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +22 -9
- package/dist/email-automations/index.js +101 -96
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +13 -8
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.js +128 -123
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +13 -8
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.js +11 -8
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +9 -6
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/blog/index.js +158 -153
- package/dist/features/blog/index.js.map +1 -1
- package/dist/features/blog/index.mjs +14 -9
- package/dist/features/blog/index.mjs.map +1 -1
- package/dist/features/blog/server.js +30 -26
- package/dist/features/blog/server.js.map +1 -1
- package/dist/features/blog/server.mjs +10 -6
- package/dist/features/blog/server.mjs.map +1 -1
- package/dist/features/help/index.js +44 -40
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +10 -6
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/features/tokenusage/index.js +134 -129
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +13 -8
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +18 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +24 -11
- package/dist/lifecycle-4GUOTB46.js +23 -0
- package/dist/lifecycle-4GUOTB46.js.map +1 -0
- package/dist/lifecycle-LKGPRBS7.mjs +23 -0
- package/dist/local-transport-7H3SPZSA.js +15 -0
- package/dist/local-transport-7H3SPZSA.js.map +1 -0
- package/dist/local-transport-QYFKA6AN.mjs +15 -0
- package/dist/local-transport-QYFKA6AN.mjs.map +1 -0
- package/dist/offline/index.d.mts +607 -0
- package/dist/offline/index.d.ts +607 -0
- package/dist/offline/index.js +1052 -0
- package/dist/offline/index.js.map +1 -0
- package/dist/offline/index.mjs +1052 -0
- package/dist/offline/index.mjs.map +1 -0
- package/dist/offline/sqlite/device-worker.mjs +62 -0
- package/dist/offline/sqlite/index.mjs +17392 -0
- package/dist/offline/sqlite/sqlite3-opfs-async-proxy.js +881 -0
- package/dist/offline/sqlite/sqlite3.wasm +0 -0
- package/dist/request-KTBV6HHH.js +10 -0
- package/dist/request-KTBV6HHH.js.map +1 -0
- package/dist/request-SQOUNWZ4.mjs +10 -0
- package/dist/request-SQOUNWZ4.mjs.map +1 -0
- package/dist/server/cachedGet.d.mts +1 -1
- package/dist/server/cachedGet.d.ts +1 -1
- package/dist/server/cachedGet.stub.d.mts +1 -1
- package/dist/server/cachedGet.stub.d.ts +1 -1
- package/dist/server/index.d.mts +28 -2
- package/dist/server/index.d.ts +28 -2
- package/dist/server/index.js +18 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +18 -8
- package/dist/{useSocket-IRHtk2xc.d.ts → useSocket-Bo9M0t0J.d.ts} +3 -1
- package/dist/{useSocket-C8fqU4od.d.mts → useSocket-J5qanQPN.d.mts} +3 -1
- package/dist/write-through-FDQ7N4YI.js +15 -0
- package/dist/write-through-FDQ7N4YI.js.map +1 -0
- package/dist/write-through-R25VQMH6.mjs +15 -0
- package/dist/write-through-R25VQMH6.mjs.map +1 -0
- package/package.json +7 -1
- package/pwa/sw.js +277 -26
- package/src/client/request.ts +37 -0
- package/src/components/editors/BlockNoteEditor.tsx +14 -0
- package/src/components/editors/__tests__/BlockNoteEditor.empty-change.spec.tsx +205 -0
- package/src/contexts/SocketContext.tsx +7 -1
- package/src/contexts/__tests__/SocketContext.session.spec.tsx +52 -5
- package/src/core/factories/RehydrationFactory.ts +28 -8
- package/src/core/factories/__tests__/RehydrationFactory.spec.ts +33 -0
- package/src/core/index.ts +3 -0
- package/src/core/interfaces/ApiData.ts +2 -0
- package/src/core/registry/ModuleRegistry.ts +3 -0
- package/src/features/auth/components/forms/Logout.tsx +8 -1
- package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +62 -0
- package/src/features/onboarding/contexts/OnboardingContext.spec.tsx +42 -1
- package/src/features/onboarding/contexts/OnboardingContext.tsx +11 -1
- package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
- package/src/hooks/useSocket.ts +20 -1
- package/src/offline/__tests__/answer-builders.spec.ts +176 -0
- package/src/offline/__tests__/device-switch.spec.ts +179 -0
- package/src/offline/__tests__/generic-answers.spec.ts +380 -0
- package/src/offline/__tests__/local-transport.spec.ts +150 -0
- package/src/offline/__tests__/logout.spec.ts +122 -0
- package/src/offline/__tests__/offline-fixtures.ts +89 -0
- package/src/offline/__tests__/router.spec.ts +56 -0
- package/src/offline/__tests__/state.spec.ts +62 -0
- package/src/offline/__tests__/write-through.spec.ts +137 -0
- package/src/offline/config.ts +229 -0
- package/src/offline/configure.ts +14 -0
- package/src/offline/device-switch.ts +75 -0
- package/src/offline/generic-answers.ts +706 -0
- package/src/offline/index.ts +67 -0
- package/src/offline/local-transport.ts +189 -0
- package/src/offline/logout.ts +41 -0
- package/src/offline/router.ts +41 -0
- package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
- package/src/offline/server/build-offline-manifest.ts +49 -0
- package/src/offline/state-hooks.ts +10 -0
- package/src/offline/state.ts +143 -0
- package/src/offline/store/DeviceStore.ts +112 -0
- package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
- package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
- package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
- package/src/offline/store/lifecycle.ts +315 -0
- package/src/offline/store/schema.ts +101 -0
- package/src/offline/store/shared-database.ts +321 -0
- package/src/offline/store/sql-device-store.ts +469 -0
- package/src/offline/store/worker-client.ts +71 -0
- package/src/offline/store/worker.ts +87 -0
- package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
- package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
- package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
- package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
- package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
- package/src/offline/sync/artwork-cache.ts +67 -0
- package/src/offline/sync/conflicts.ts +211 -0
- package/src/offline/sync/counts.ts +39 -0
- package/src/offline/sync/drain-queue.ts +246 -0
- package/src/offline/sync/known-roots.ts +37 -0
- package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
- package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
- package/src/offline/sync/models/RootSync.ts +68 -0
- package/src/offline/sync/models/RootSyncInterface.ts +20 -0
- package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
- package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
- package/src/offline/sync/models/index.ts +5 -0
- package/src/offline/sync/models/offline.modules.ts +14 -0
- package/src/offline/sync/runtime.ts +226 -0
- package/src/offline/sync/session-refresh.ts +32 -0
- package/src/offline/sync/sync-root.ts +336 -0
- package/src/offline/sync/waiting.ts +40 -0
- package/src/offline/types.ts +115 -0
- package/src/offline/write-through.ts +189 -0
- package/src/pwa/__tests__/sw.spec.ts +358 -1
- package/src/server/index.ts +5 -0
- package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
- package/dist/BlockNoteEditor-75ED2CN2.mjs.map +0 -1
- package/dist/BlockNoteEditor-7M5QW2YQ.js.map +0 -1
- package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
- package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
- package/dist/chunk-AA52ORW2.mjs.map +0 -1
- package/dist/chunk-EWCY6QJA.mjs.map +0 -1
- package/dist/chunk-FF4EIQ6H.js.map +0 -1
- package/dist/chunk-JTPRJNT2.js.map +0 -1
- package/dist/chunk-L6PYFQTO.js.map +0 -1
- package/dist/chunk-O3PDL2CV.js.map +0 -1
- package/dist/chunk-PI56A4DG.mjs.map +0 -1
- package/dist/chunk-RXQYEEKZ.mjs.map +0 -1
- package/dist/chunk-V3YDVJ63.js.map +0 -1
- package/dist/request-5RLK2AM3.mjs +0 -9
- package/dist/request-EUGTI3BE.js +0 -9
- package/dist/request-EUGTI3BE.js.map +0 -1
- /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
- /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
- /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
- /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { configureOffline, resetOfflineConfig, setDeviceStoreOpener } from "../config";
|
|
2
|
+
import { resetOfflineState } from "../state";
|
|
3
|
+
import type { DeviceStore } from "../store/DeviceStore";
|
|
4
|
+
import { openMemoryDeviceStore } from "../store/lifecycle";
|
|
5
|
+
import type { OfflineConfig, OfflineRoute } from "../types";
|
|
6
|
+
|
|
7
|
+
export class TestModel {
|
|
8
|
+
id = "";
|
|
9
|
+
name = "";
|
|
10
|
+
rehydrate(data: { jsonApi: any }) {
|
|
11
|
+
this.id = data.jsonApi.id;
|
|
12
|
+
this.name = data.jsonApi.attributes?.name ?? "";
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Roots = { name: "roots", model: TestModel };
|
|
18
|
+
export const Npcs = { name: "npcs", model: TestModel };
|
|
19
|
+
export const Factions = { name: "factions", model: TestModel };
|
|
20
|
+
export const Characters = { name: "characters", model: TestModel };
|
|
21
|
+
|
|
22
|
+
export const npc = (id: string, name: string, rootId: string) => ({
|
|
23
|
+
type: "npcs",
|
|
24
|
+
id,
|
|
25
|
+
attributes: { name, description: `${name} text` },
|
|
26
|
+
relationships: { root: { data: { type: "roots", id: rootId } } },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export function offlineConfig(routes: OfflineRoute[] = []): OfflineConfig {
|
|
30
|
+
return {
|
|
31
|
+
getUserId: () => "u1",
|
|
32
|
+
rootModule: Roots,
|
|
33
|
+
types: [
|
|
34
|
+
{ module: Roots, fetch: "by-id", readOnly: false, serverOwnedFields: [] },
|
|
35
|
+
{ module: Npcs, fetch: "root-list", readOnly: false, serverOwnedFields: ["tldr"] },
|
|
36
|
+
{ module: Factions, fetch: "root-list", readOnly: false, serverOwnedFields: [] },
|
|
37
|
+
{ module: Characters, fetch: "root-list", readOnly: true, serverOwnedFields: [] },
|
|
38
|
+
],
|
|
39
|
+
routes,
|
|
40
|
+
listSyncRoots: async () => ["C", "D"],
|
|
41
|
+
shellPath: () => "/offline-shell",
|
|
42
|
+
syncIntervalMs: 300000,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Two NPCs and a faction in root record C, one NPC in D; the faction is linked to n1. */
|
|
47
|
+
export async function seededStore(): Promise<DeviceStore> {
|
|
48
|
+
const store = await openMemoryDeviceStore();
|
|
49
|
+
await store.putRecord({
|
|
50
|
+
type: "roots",
|
|
51
|
+
id: "C",
|
|
52
|
+
rootId: "C",
|
|
53
|
+
resource: { type: "roots", id: "C", attributes: { name: "Camp C" } },
|
|
54
|
+
updatedAt: null,
|
|
55
|
+
});
|
|
56
|
+
await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1", "Alda", "C"), updatedAt: null });
|
|
57
|
+
await store.putRecord({ type: "npcs", id: "n2", rootId: "C", resource: npc("n2", "brom", "C"), updatedAt: null });
|
|
58
|
+
await store.putRecord({ type: "npcs", id: "n3", rootId: "D", resource: npc("n3", "Cora", "D"), updatedAt: null });
|
|
59
|
+
await store.putRecord({
|
|
60
|
+
type: "factions",
|
|
61
|
+
id: "f1",
|
|
62
|
+
rootId: "C",
|
|
63
|
+
resource: { type: "factions", id: "f1", attributes: { name: "Guild" } },
|
|
64
|
+
updatedAt: null,
|
|
65
|
+
});
|
|
66
|
+
await store.putRecord({
|
|
67
|
+
type: "characters",
|
|
68
|
+
id: "ch1",
|
|
69
|
+
rootId: "C",
|
|
70
|
+
resource: { type: "characters", id: "ch1", attributes: { name: "Hero" } },
|
|
71
|
+
updatedAt: null,
|
|
72
|
+
});
|
|
73
|
+
await store.putLink({ fromType: "factions", fromId: "f1", toType: "npcs", toId: "n1", kind: "MEMBER_OF", props: {} });
|
|
74
|
+
return store;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function setUpOffline(routes: OfflineRoute[] = []): Promise<DeviceStore> {
|
|
78
|
+
resetOfflineState();
|
|
79
|
+
resetOfflineConfig();
|
|
80
|
+
const store = await seededStore();
|
|
81
|
+
configureOffline(offlineConfig(routes));
|
|
82
|
+
setDeviceStoreOpener(async () => store);
|
|
83
|
+
return store;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function tearDownOffline(): void {
|
|
87
|
+
resetOfflineConfig();
|
|
88
|
+
resetOfflineState();
|
|
89
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { matchRoute } from "../router";
|
|
3
|
+
import type { OfflineRoute } from "../types";
|
|
4
|
+
import { patternToString } from "../types";
|
|
5
|
+
|
|
6
|
+
const Npcs = { name: "npcs", model: class {} };
|
|
7
|
+
const Roots = { name: "roots", model: class {} };
|
|
8
|
+
const answer = (label: string) => async () => ({ status: 200, body: { label } });
|
|
9
|
+
|
|
10
|
+
describe("matchRoute", () => {
|
|
11
|
+
it("custom routes win over generic ones", async () => {
|
|
12
|
+
const custom: OfflineRoute = {
|
|
13
|
+
method: "GET",
|
|
14
|
+
pattern: [Roots, ":id", "scenes", "playing"],
|
|
15
|
+
answer: answer("custom"),
|
|
16
|
+
queue: false,
|
|
17
|
+
};
|
|
18
|
+
const generic: OfflineRoute = {
|
|
19
|
+
method: "GET",
|
|
20
|
+
pattern: [":parentType", ":parentId", "scenes", ":x"],
|
|
21
|
+
answer: answer("generic"),
|
|
22
|
+
queue: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const match = matchRoute([custom, generic], "GET", "roots/C/scenes/playing");
|
|
26
|
+
|
|
27
|
+
expect(match?.route).toBe(custom);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("a Module segment matches its endpoint", () => {
|
|
31
|
+
const route: OfflineRoute = { method: "GET", pattern: [Npcs, ":id"], answer: answer("one"), queue: false };
|
|
32
|
+
|
|
33
|
+
expect(matchRoute([route], "GET", "npcs/n1")?.params).toEqual({ id: "n1" });
|
|
34
|
+
expect(matchRoute([route], "GET", "factions/n1")).toBeNull();
|
|
35
|
+
expect(patternToString([Roots, ":id", Npcs])).toBe("roots/:id/npcs");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it(":id captures one segment", () => {
|
|
39
|
+
const route: OfflineRoute = { method: "GET", pattern: [Npcs, ":id"], answer: answer("one"), queue: false };
|
|
40
|
+
|
|
41
|
+
expect(matchRoute([route], "GET", "npcs/n1/factions")).toBeNull();
|
|
42
|
+
expect(matchRoute([route], "GET", "npcs")).toBeNull();
|
|
43
|
+
expect(matchRoute([route], "PATCH", "npcs/n1")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("the query string is ignored for matching", () => {
|
|
47
|
+
const route: OfflineRoute = {
|
|
48
|
+
method: "GET",
|
|
49
|
+
pattern: [Roots, ":id", Npcs],
|
|
50
|
+
answer: answer("list"),
|
|
51
|
+
queue: false,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
expect(matchRoute([route], "GET", "roots/C/npcs?fetchAll=true&page[size]=5")?.params).toEqual({ id: "C" });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { configureOffline, resetOfflineConfig } from "../config";
|
|
3
|
+
import { getOfflineState, resetOfflineState, setOfflineState, subscribeOfflineState } from "../state";
|
|
4
|
+
import type { OfflineConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
const config: OfflineConfig = {
|
|
7
|
+
getUserId: () => "u1",
|
|
8
|
+
rootModule: { name: "roots", model: class {} },
|
|
9
|
+
types: [],
|
|
10
|
+
routes: [],
|
|
11
|
+
listSyncRoots: async () => [],
|
|
12
|
+
shellPath: () => "/offline-shell",
|
|
13
|
+
syncIntervalMs: 300000,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("offline state", () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
resetOfflineState();
|
|
19
|
+
resetOfflineConfig();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
resetOfflineState();
|
|
24
|
+
resetOfflineConfig();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("the offline event marks the session offline; the online event alone never brings it back (a probe or a sync must reach the API)", () => {
|
|
28
|
+
configureOffline(config);
|
|
29
|
+
expect(getOfflineState().online).toBe(true);
|
|
30
|
+
|
|
31
|
+
window.dispatchEvent(new Event("offline"));
|
|
32
|
+
expect(getOfflineState().online).toBe(false);
|
|
33
|
+
|
|
34
|
+
window.dispatchEvent(new Event("online"));
|
|
35
|
+
expect(getOfflineState().online).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not follow browser events when offline mode is not configured", () => {
|
|
39
|
+
window.dispatchEvent(new Event("offline"));
|
|
40
|
+
expect(getOfflineState().online).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("listeners are called once per change", () => {
|
|
44
|
+
const listener = vi.fn();
|
|
45
|
+
const unsubscribe = subscribeOfflineState(listener);
|
|
46
|
+
|
|
47
|
+
setOfflineState({ pending: 2 });
|
|
48
|
+
setOfflineState({ pending: 2 });
|
|
49
|
+
setOfflineState({ lockedRootIds: ["C"] });
|
|
50
|
+
setOfflineState({ lockedRootIds: ["C"] });
|
|
51
|
+
setOfflineState({ online: false, draining: false });
|
|
52
|
+
|
|
53
|
+
expect(listener).toHaveBeenCalledTimes(3);
|
|
54
|
+
expect(listener).toHaveBeenLastCalledWith(
|
|
55
|
+
expect.objectContaining({ online: false, pending: 2, lockedRootIds: ["C"] }),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
unsubscribe();
|
|
59
|
+
setOfflineState({ pending: 3 });
|
|
60
|
+
expect(listener).toHaveBeenCalledTimes(3);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { DeviceStore } from "../store/DeviceStore";
|
|
3
|
+
import { artwork, fakeCacheStorage, npc, setUpSync, tearDownSync } from "../sync/__tests__/sync-fixtures";
|
|
4
|
+
import { markOfflineUnavailable, OfflineUnavailableError } from "../config";
|
|
5
|
+
import { writeThrough } from "../write-through";
|
|
6
|
+
|
|
7
|
+
const ok = (data: any) => ({ ok: true, status: 200, statusText: "OK", data });
|
|
8
|
+
|
|
9
|
+
describe("writeThrough", () => {
|
|
10
|
+
let store: DeviceStore;
|
|
11
|
+
let cache: ReturnType<typeof fakeCacheStorage>;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
store = await setUpSync();
|
|
15
|
+
// The device keeps root record C (its root record is stored).
|
|
16
|
+
await store.putRecord({
|
|
17
|
+
type: "roots",
|
|
18
|
+
id: "C",
|
|
19
|
+
rootId: "C",
|
|
20
|
+
resource: { type: "roots", id: "C", attributes: { name: "C" } },
|
|
21
|
+
updatedAt: null,
|
|
22
|
+
});
|
|
23
|
+
cache = fakeCacheStorage(["wyrdli-offline-1", "wyrdli-images"]);
|
|
24
|
+
vi.stubGlobal("caches", cache.storage);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
tearDownSync();
|
|
29
|
+
vi.unstubAllGlobals();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("stores the answer of an online write", async () => {
|
|
33
|
+
await writeThrough({ method: "PATCH", url: "http://api.test/npcs/n1" }, ok({ data: npc("n1", "Saved") }));
|
|
34
|
+
|
|
35
|
+
expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Saved");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("in a browser that cannot keep the device copy, stores nothing and logs nothing per request", async () => {
|
|
39
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
40
|
+
markOfflineUnavailable(new OfflineUnavailableError("the browser offers no private file storage"));
|
|
41
|
+
warn.mockClear();
|
|
42
|
+
|
|
43
|
+
await writeThrough({ method: "PATCH", url: "http://api.test/npcs/n1" }, ok({ data: npc("n1", "Saved") }));
|
|
44
|
+
|
|
45
|
+
expect(await store.getRecord("npcs", "n1")).toBeUndefined();
|
|
46
|
+
expect(warn).not.toHaveBeenCalled();
|
|
47
|
+
warn.mockRestore();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("stores nothing for a write in a root record the device does not keep (a player's root record)", async () => {
|
|
51
|
+
await writeThrough(
|
|
52
|
+
{ method: "PATCH", url: "http://api.test/npcs/p1" },
|
|
53
|
+
ok({ data: npc("p1", "Player's", "P"), included: [npc("p2", "Other", "P")] }),
|
|
54
|
+
);
|
|
55
|
+
await writeThrough(
|
|
56
|
+
{ method: "POST", url: "http://api.test/roots/P/npcs" },
|
|
57
|
+
ok({ data: { type: "npcs", id: "p3", attributes: { name: "No relationship" } } }),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(await store.getRecord("npcs", "p1")).toBeUndefined();
|
|
61
|
+
expect(await store.getRecord("npcs", "p2")).toBeUndefined();
|
|
62
|
+
expect(await store.getRecord("npcs", "p3")).toBeUndefined();
|
|
63
|
+
expect(await store.linksFrom("p1")).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("never overwrites a record that has a waiting change (a queue entry targets it) or a base", async () => {
|
|
67
|
+
await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1", "Mine"), updatedAt: null });
|
|
68
|
+
await store.enqueue({
|
|
69
|
+
rootId: "C",
|
|
70
|
+
method: "POST",
|
|
71
|
+
path: "npcs",
|
|
72
|
+
body: { data: npc("n1", "Mine") },
|
|
73
|
+
base: null,
|
|
74
|
+
});
|
|
75
|
+
await store.putRecord({
|
|
76
|
+
type: "npcs",
|
|
77
|
+
id: "n2",
|
|
78
|
+
rootId: "C",
|
|
79
|
+
resource: npc("n2", "Mine"),
|
|
80
|
+
updatedAt: null,
|
|
81
|
+
base: npc("n2", "Base"),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await writeThrough(
|
|
85
|
+
{ method: "PATCH", url: "http://api.test/npcs/n1" },
|
|
86
|
+
ok({ data: npc("n1", "Server"), included: [npc("n2", "Server")] }),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Mine");
|
|
90
|
+
expect((await store.getRecord("npcs", "n2"))?.resource.attributes.name).toBe("Mine");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("updates the links of the written record from the answer's relationships", async () => {
|
|
94
|
+
await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1", "A"), updatedAt: null });
|
|
95
|
+
await store.putLink({ fromType: "npcs", fromId: "n1", toType: "npcs", toId: "old", kind: "friends", props: {} });
|
|
96
|
+
|
|
97
|
+
await writeThrough(
|
|
98
|
+
{ method: "PATCH", url: "http://api.test/npcs/n1" },
|
|
99
|
+
ok({
|
|
100
|
+
data: {
|
|
101
|
+
...npc("n1", "A"),
|
|
102
|
+
relationships: {
|
|
103
|
+
root: { data: { type: "roots", id: "C" } },
|
|
104
|
+
friends: { data: [{ type: "npcs", id: "new" }] },
|
|
105
|
+
portrait: { data: { type: "artworks", id: "a1" } },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const links = (await store.linksFrom("n1")).map((link) => `${link.kind}:${link.toId}`).sort();
|
|
112
|
+
expect(links).toEqual(["friends:new", "portrait:a1", "root:C"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("removes the old cached image when an online write changes an artwork's image URL", async () => {
|
|
116
|
+
const images = cache.caches.get("wyrdli-images")!;
|
|
117
|
+
images.set("http://api.test/artworks/a1/image?v=1", {});
|
|
118
|
+
await store.putRecord({
|
|
119
|
+
type: "artworks",
|
|
120
|
+
id: "a1",
|
|
121
|
+
rootId: "C",
|
|
122
|
+
resource: artwork("a1", "http://api.test/artworks/a1/image?v=1"),
|
|
123
|
+
updatedAt: null,
|
|
124
|
+
});
|
|
125
|
+
vi.stubGlobal(
|
|
126
|
+
"fetch",
|
|
127
|
+
vi.fn(async () => ({ ok: true, status: 200 })),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await writeThrough(
|
|
131
|
+
{ method: "PATCH", url: "http://api.test/artworks/a1" },
|
|
132
|
+
ok({ data: artwork("a1", "http://api.test/artworks/a1/image?v=2") }),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(images.has("http://api.test/artworks/a1/image?v=1")).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|