test(offline): add reviewer-requested coverage for offline-sync core logic
Adds the unit tests called out in the PR #120 review (test-coverage aspect). No production logic changes — the only non-test edit is exporting the already-injectable warmInfiniteAll helper so it can be unit tested. Server (Jest): - persistence.extension.spec.ts: onStoreDocument classification matrix (no-op / title-only / body+title / body-only), onLoadDocument seed + persist gating (early-return, page-null, ydoc seed, already-seeded no-persist, legacy content->ydoc), and seedTitleFragment 4-branch guard. - collaboration.util.spec.ts: buildTitleSeedYdoc round-trip. - environment.service.spec.ts: getCorsAllowedOrigins / isSwaggerEnabled. - auth.controller.spec.ts: login returnToken opt-in branch. Client (Vitest): - query-persister.test.ts: shouldDehydrateOfflineQuery status + allowlist gates and OFFLINE_PERSIST_ROOTS membership. - is-capacitor.test.ts: isCapacitorNativePlatform platform detection. - make-offline.test.ts: warmInfiniteAll cursor walk / maxPages / error swallow, and warmPageYdoc settle-once + timeout + teardown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
claude code agent 227
parent
7ac7fcba2d
commit
df02f2d672
172
apps/client/src/features/offline/make-offline.test.ts
Normal file
172
apps/client/src/features/offline/make-offline.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// vi.mock factories are hoisted above imports, so any spy they reference must be
|
||||
// declared with vi.hoisted (which is hoisted as well). These shared spies are
|
||||
// inspected by the assertions below.
|
||||
const h = vi.hoisted(() => ({
|
||||
ydocDestroy: vi.fn(),
|
||||
idbDestroy: vi.fn(),
|
||||
providerOn: vi.fn(),
|
||||
providerOff: vi.fn(),
|
||||
providerDestroy: vi.fn(),
|
||||
}));
|
||||
|
||||
// The module under test imports the app entry at load time — it must be mocked.
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { setQueryData: vi.fn(), prefetchQuery: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/features/page/services/page-service", () => ({
|
||||
getPageById: vi.fn(),
|
||||
getPageBreadcrumbs: vi.fn(),
|
||||
getSidebarPages: vi.fn(),
|
||||
getAllSidebarPages: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/space/services/space-service.ts", () => ({
|
||||
getSpaceById: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
getPageComments: vi.fn(),
|
||||
}));
|
||||
|
||||
// Use the `function` form (not an arrow) so Vitest binds the constructor return
|
||||
// value when the module under test calls `new Y.Doc()` etc.
|
||||
vi.mock("yjs", () => ({
|
||||
Doc: vi.fn(function () {
|
||||
return { destroy: h.ydocDestroy };
|
||||
}),
|
||||
}));
|
||||
vi.mock("y-indexeddb", () => ({
|
||||
IndexeddbPersistence: vi.fn(function () {
|
||||
return { destroy: h.idbDestroy };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@hocuspocus/provider", () => ({
|
||||
HocuspocusProvider: vi.fn(function () {
|
||||
return { on: h.providerOn, off: h.providerOff, destroy: h.providerDestroy };
|
||||
}),
|
||||
}));
|
||||
|
||||
import { warmInfiniteAll, warmPageYdoc } from "./make-offline";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
|
||||
const setQueryData = (queryClient as any).setQueryData as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear call history WITHOUT wiping the mock implementations the vi.mock
|
||||
// factories installed (vi.clearAllMocks would drop the constructor return
|
||||
// objects and break the provider/idb/yjs spies).
|
||||
setQueryData.mockClear();
|
||||
h.ydocDestroy.mockClear();
|
||||
h.idbDestroy.mockClear();
|
||||
h.providerOn.mockClear();
|
||||
h.providerOff.mockClear();
|
||||
h.providerDestroy.mockClear();
|
||||
});
|
||||
|
||||
describe("warmInfiniteAll", () => {
|
||||
it("warms a single page and writes the InfiniteData cache shape", async () => {
|
||||
const res = { items: [{ id: 1 }], meta: { nextCursor: null } };
|
||||
const fetchPage = vi.fn().mockResolvedValue(res);
|
||||
|
||||
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPage).toHaveBeenCalledWith(undefined);
|
||||
expect(setQueryData).toHaveBeenCalledTimes(1);
|
||||
expect(setQueryData).toHaveBeenCalledWith(["comments", "p1"], {
|
||||
pages: [res],
|
||||
pageParams: [undefined],
|
||||
});
|
||||
});
|
||||
|
||||
it("walks the cursor chain across multiple pages", async () => {
|
||||
const r0 = { items: [], meta: { nextCursor: "c1" } };
|
||||
const r1 = { items: [], meta: { nextCursor: "c2" } };
|
||||
const r2 = { items: [], meta: { nextCursor: null } };
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(r0)
|
||||
.mockResolvedValueOnce(r1)
|
||||
.mockResolvedValueOnce(r2);
|
||||
|
||||
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(3);
|
||||
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
|
||||
undefined,
|
||||
"c1",
|
||||
"c2",
|
||||
]);
|
||||
const payload = setQueryData.mock.calls[0][1];
|
||||
expect(payload.pages).toEqual([r0, r1, r2]);
|
||||
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
|
||||
});
|
||||
|
||||
it("caps pagination at maxPages", async () => {
|
||||
// Always returns a non-null cursor — the cap is the only thing that stops it.
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
|
||||
|
||||
await warmInfiniteAll(["comments", "p1"], fetchPage, 2);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||
const payload = setQueryData.mock.calls[0][1];
|
||||
expect(payload.pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("swallows errors and never writes the cache on failure", async () => {
|
||||
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
|
||||
|
||||
await expect(
|
||||
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||
).resolves.toBeUndefined();
|
||||
expect(setQueryData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("warmPageYdoc", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resolves on synced, detaches the listener once, and tears everything down (settle-once)", async () => {
|
||||
const promise = warmPageYdoc("p1", "ws://x");
|
||||
|
||||
// Grab the synced handler the provider registered.
|
||||
expect(h.providerOn).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||
const handler = h.providerOn.mock.calls.find(
|
||||
(c) => c[0] === "synced",
|
||||
)![1] as () => void;
|
||||
|
||||
handler();
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
|
||||
// Listener detached and everything cleaned up.
|
||||
expect(h.providerOff).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Firing the handler again must NOT re-run cleanup (settled guard).
|
||||
handler();
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves and cleans up after the timeout when synced never fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const promise = warmPageYdoc("p1", "ws://x");
|
||||
|
||||
// Do not fire "synced"; let the 8s safety timeout settle it.
|
||||
await vi.advanceTimersByTimeAsync(8000);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -24,8 +24,10 @@ import { IPagination } from "@/lib/types.ts";
|
||||
* cursor chain until it runs out (or hits maxPages) so the whole list is cached.
|
||||
*
|
||||
* Best-effort: any failure is swallowed so a partial/failed warm never throws.
|
||||
*
|
||||
* Exported for unit testing of the cursor-walk / cache-write behavior.
|
||||
*/
|
||||
async function warmInfiniteAll<T>(
|
||||
export async function warmInfiniteAll<T>(
|
||||
queryKey: unknown[],
|
||||
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
|
||||
maxPages = 50,
|
||||
|
||||
84
apps/client/src/features/offline/query-persister.test.ts
Normal file
84
apps/client/src/features/offline/query-persister.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
shouldDehydrateOfflineQuery,
|
||||
OFFLINE_PERSIST_ROOTS,
|
||||
} from "./query-persister";
|
||||
|
||||
// Small helper to build the structural query shape the predicate reads.
|
||||
const makeQuery = (status: string, queryKey: readonly unknown[]) =>
|
||||
({ state: { status }, queryKey }) as any;
|
||||
|
||||
describe("shouldDehydrateOfflineQuery", () => {
|
||||
it("returns true for a successful query whose root is in the allowlist", () => {
|
||||
expect(shouldDehydrateOfflineQuery(makeQuery("success", ["pages", "abc"]))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(
|
||||
makeQuery("success", ["sidebar-pages", { pageId: "p", spaceId: "s" }]),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["comments", "p1"])),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["space", "s"])),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the status is not success (status gate)", () => {
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("pending", ["pages", "abc"])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("error", ["pages", "abc"])),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a successful query whose root is NOT in the allowlist (privacy gate)", () => {
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["collab-token", "ws"])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["trash", "s"])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["unknown"])),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty/undefined queryKey", () => {
|
||||
// String(undefined) is not a member of the allowlist.
|
||||
expect(shouldDehydrateOfflineQuery(makeQuery("success", []))).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", undefined as any)),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OFFLINE_PERSIST_ROOTS", () => {
|
||||
it("contains exactly the expected 8 navigation/read roots", () => {
|
||||
const expected = [
|
||||
"pages",
|
||||
"sidebar-pages",
|
||||
"root-sidebar-pages",
|
||||
"breadcrumbs",
|
||||
"comments",
|
||||
"space",
|
||||
"spaces",
|
||||
"recent-changes",
|
||||
];
|
||||
expect(OFFLINE_PERSIST_ROOTS.size).toBe(8);
|
||||
for (const root of expected) {
|
||||
expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT contain volatile/auth keys", () => {
|
||||
expect(OFFLINE_PERSIST_ROOTS.has("collab-token")).toBe(false);
|
||||
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
|
||||
});
|
||||
});
|
||||
39
apps/client/src/pwa/is-capacitor.test.ts
Normal file
39
apps/client/src/pwa/is-capacitor.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { isCapacitorNativePlatform } from "./is-capacitor";
|
||||
|
||||
describe("isCapacitorNativePlatform", () => {
|
||||
afterEach(() => {
|
||||
// Keep tests isolated from each other and from the rest of the suite.
|
||||
delete (globalThis as any).Capacitor;
|
||||
});
|
||||
|
||||
it("returns false when Capacitor is undefined", () => {
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses isNativePlatform() when it is a function", () => {
|
||||
(globalThis as any).Capacitor = { isNativePlatform: () => true };
|
||||
expect(isCapacitorNativePlatform()).toBe(true);
|
||||
|
||||
(globalThis as any).Capacitor = { isNativePlatform: () => false };
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the boolean property when isNativePlatform is not a function", () => {
|
||||
(globalThis as any).Capacitor = { isNativePlatform: true };
|
||||
expect(isCapacitorNativePlatform()).toBe(true);
|
||||
|
||||
(globalThis as any).Capacitor = { isNativePlatform: false };
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when reading Capacitor throws (try/catch)", () => {
|
||||
Object.defineProperty(globalThis, "Capacitor", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user