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
f17e052148
commit
ada7e3f689
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.
|
* 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.
|
* 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[],
|
queryKey: unknown[],
|
||||||
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
|
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
|
||||||
maxPages = 50,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import {
|
import {
|
||||||
getPageId,
|
getPageId,
|
||||||
isEmptyParagraphDoc,
|
isEmptyParagraphDoc,
|
||||||
jsonToNode,
|
jsonToNode,
|
||||||
prosemirrorNodeToYElement,
|
prosemirrorNodeToYElement,
|
||||||
|
buildTitleSeedYdoc,
|
||||||
|
jsonToText,
|
||||||
|
tiptapExtensions,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
|
||||||
@@ -241,3 +245,49 @@ describe('prosemirrorNodeToYElement', () => {
|
|||||||
expect(element.get(1).get(0).toString()).toBe('two');
|
expect(element.get(1).get(0).toString()).toBe('two');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildTitleSeedYdoc', () => {
|
||||||
|
it('builds a level-1 heading carrying the title text', () => {
|
||||||
|
const doc = buildTitleSeedYdoc('Hello World');
|
||||||
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
|
||||||
|
const first = json.content?.[0];
|
||||||
|
expect(first.type).toBe('heading');
|
||||||
|
expect(first.attrs.level).toBe(1);
|
||||||
|
expect(jsonToText(json).trim()).toBe('Hello World');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a non-empty title fragment for a non-empty title', () => {
|
||||||
|
const doc = buildTitleSeedYdoc('Some Title');
|
||||||
|
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a heading with no text child for an empty title', () => {
|
||||||
|
const doc = buildTitleSeedYdoc('');
|
||||||
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
|
||||||
|
const first = json.content?.[0];
|
||||||
|
expect(first.type).toBe('heading');
|
||||||
|
// No text content for an empty title.
|
||||||
|
expect(first.content ?? []).toHaveLength(0);
|
||||||
|
expect(jsonToText(json).trim()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a title through build -> extract -> build -> extract', () => {
|
||||||
|
const title = 'Round Trip Title';
|
||||||
|
const doc1 = buildTitleSeedYdoc(title);
|
||||||
|
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
|
||||||
|
|
||||||
|
const doc2 = buildTitleSeedYdoc(text1);
|
||||||
|
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
|
||||||
|
|
||||||
|
expect(text1).toBe(title);
|
||||||
|
expect(text2).toBe(text1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch tiptapExtensions so the import is exercised (mirrors the brief's import
|
||||||
|
// list and guards against accidental tree-shaking of the schema dependency).
|
||||||
|
it('uses the shared tiptap extensions schema', () => {
|
||||||
|
expect(Array.isArray(tiptapExtensions)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import { PersistenceExtension } from './persistence.extension';
|
||||||
|
import { buildTitleSeedYdoc, tiptapExtensions } from '../collaboration.util';
|
||||||
|
|
||||||
|
// Direct instantiation with stub deps, mirroring the auth/env unit specs.
|
||||||
|
const bodyJson = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a body Y.Doc with a known JSON, plus a monkey-patched broadcastStateless
|
||||||
|
// (the real Hocuspocus Document supplies it; a bare Y.Doc does not).
|
||||||
|
const buildDoc = () => {
|
||||||
|
const d: any = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||||
|
d.broadcastStateless = jest.fn();
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneOut = (doc: any) =>
|
||||||
|
JSON.parse(JSON.stringify(TiptapTransformer.fromYdoc(doc, 'default')));
|
||||||
|
|
||||||
|
const addTitleFragment = (doc: any, title: string) =>
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||||
|
|
||||||
|
describe('PersistenceExtension', () => {
|
||||||
|
let pageRepo: any;
|
||||||
|
let pageHistoryRepo: any;
|
||||||
|
let trx: any;
|
||||||
|
let db: any;
|
||||||
|
let aiQueue: any;
|
||||||
|
let historyQueue: any;
|
||||||
|
let notificationQueue: any;
|
||||||
|
let collabHistory: any;
|
||||||
|
let transclusionService: any;
|
||||||
|
let ext: PersistenceExtension;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pageRepo = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
pageHistoryRepo = {
|
||||||
|
findPageLastHistory: jest.fn(),
|
||||||
|
saveHistory: jest.fn(),
|
||||||
|
};
|
||||||
|
trx = {};
|
||||||
|
db = { transaction: () => ({ execute: (fn: any) => fn(trx) }) };
|
||||||
|
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
transclusionService = {
|
||||||
|
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||||
|
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||||
|
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
ext = new PersistenceExtension(
|
||||||
|
pageRepo as any,
|
||||||
|
pageHistoryRepo as any,
|
||||||
|
db as any,
|
||||||
|
aiQueue as any,
|
||||||
|
historyQueue as any,
|
||||||
|
notificationQueue as any,
|
||||||
|
collabHistory as any,
|
||||||
|
transclusionService as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seedTitleFragment', () => {
|
||||||
|
it('returns false for empty/whitespace/null titles', () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
expect((ext as any).seedTitleFragment(doc, '')).toBe(false);
|
||||||
|
expect((ext as any).seedTitleFragment(doc, ' ')).toBe(false);
|
||||||
|
expect((ext as any).seedTitleFragment(doc, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT re-seed an existing non-empty title fragment', () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
addTitleFragment(doc, 'Existing');
|
||||||
|
|
||||||
|
expect((ext as any).seedTitleFragment(doc, 'Other')).toBe(false);
|
||||||
|
|
||||||
|
const text = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
expect(JSON.stringify(text)).toContain('Existing');
|
||||||
|
expect(JSON.stringify(text)).not.toContain('Other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds an empty fragment from a non-empty title and returns true', () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
expect((ext as any).seedTitleFragment(doc, 'Hello')).toBe(true);
|
||||||
|
|
||||||
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
expect(JSON.stringify(json)).toContain('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false (defensive) when reading the fragment throws', () => {
|
||||||
|
const fakeDoc = {
|
||||||
|
get: () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect((ext as any).seedTitleFragment(fakeDoc as any, 'X')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onStoreDocument', () => {
|
||||||
|
const basePage = (overrides: any) => ({
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
slugId: 'slug',
|
||||||
|
spaceId: 'space',
|
||||||
|
parentPageId: null,
|
||||||
|
creatorId: 'creator',
|
||||||
|
contributorIds: ['creator'],
|
||||||
|
workspaceId: 'ws',
|
||||||
|
title: 'whatever',
|
||||||
|
content: null,
|
||||||
|
lastUpdatedSource: 'user',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = { user: { id: 'u1', name: 'U', avatarUrl: null } };
|
||||||
|
|
||||||
|
it('no-op when neither body nor title changed', async () => {
|
||||||
|
const document = buildDoc();
|
||||||
|
const page = basePage({
|
||||||
|
content: cloneOut(document),
|
||||||
|
title: 'hello title',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
expect(document.broadcastStateless).not.toHaveBeenCalled();
|
||||||
|
expect(collabHistory.addContributors).not.toHaveBeenCalled();
|
||||||
|
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||||
|
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||||
|
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title-only change persists the title without body side-effects', async () => {
|
||||||
|
const document = buildDoc();
|
||||||
|
addTitleFragment(document, 'New Title');
|
||||||
|
const page = basePage({
|
||||||
|
content: cloneOut(document),
|
||||||
|
title: 'Old Title',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
expect(call[0].title).toBe('New Title');
|
||||||
|
expect(call[0].ydoc).toBeDefined();
|
||||||
|
expect(call[0].contributorIds).toBeDefined();
|
||||||
|
expect('content' in call[0]).toBe(false);
|
||||||
|
// Title-only must not touch the body-authorship provenance.
|
||||||
|
expect('lastUpdatedSource' in call[0]).toBe(false);
|
||||||
|
expect(call[1]).toBe('PAGE_ID');
|
||||||
|
expect(call[3].treeUpdate.title).toBe('New Title');
|
||||||
|
|
||||||
|
expect(collabHistory.addContributors).toHaveBeenCalledTimes(1);
|
||||||
|
expect(collabHistory.addContributors).toHaveBeenCalledWith(
|
||||||
|
'PAGE_ID',
|
||||||
|
expect.any(Array),
|
||||||
|
);
|
||||||
|
expect(document.broadcastStateless).toHaveBeenCalledTimes(1);
|
||||||
|
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||||
|
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||||
|
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('body + title change persists both with full body side-effects', async () => {
|
||||||
|
const document = buildDoc();
|
||||||
|
addTitleFragment(document, 'New Title');
|
||||||
|
const page = basePage({
|
||||||
|
content: { type: 'doc', content: [] },
|
||||||
|
title: 'Old Title',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
expect(call[0].content).toBeTruthy();
|
||||||
|
expect(call[0].title).toBe('New Title');
|
||||||
|
expect(call[0].ydoc).toBeDefined();
|
||||||
|
expect(call[0].lastUpdatedSource).toBe('user');
|
||||||
|
expect(call[3].treeUpdate).toBeDefined();
|
||||||
|
|
||||||
|
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||||
|
expect(aiQueue.add).toHaveBeenCalled();
|
||||||
|
expect(historyQueue.add).toHaveBeenCalled();
|
||||||
|
expect(collabHistory.addContributors).toHaveBeenCalled();
|
||||||
|
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('body-only change persists the body without a tree update', async () => {
|
||||||
|
const document = buildDoc();
|
||||||
|
const page = basePage({
|
||||||
|
content: { type: 'doc', content: [] },
|
||||||
|
title: 'whatever',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
expect(call[0].content).toBeTruthy();
|
||||||
|
expect('title' in call[0]).toBe(false);
|
||||||
|
// No treeUpdate for a body-only save.
|
||||||
|
expect(call[3]).toBeUndefined();
|
||||||
|
|
||||||
|
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||||
|
expect(aiQueue.add).toHaveBeenCalled();
|
||||||
|
expect(historyQueue.add).toHaveBeenCalled();
|
||||||
|
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onLoadDocument', () => {
|
||||||
|
it('returns early (no DB read) when the document is not empty', async () => {
|
||||||
|
const document = { isEmpty: () => false };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined and does not persist when the page is null', async () => {
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
pageRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds + persists when the persisted ydoc lacks a title fragment', async () => {
|
||||||
|
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||||
|
const page = {
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
title: 'Legacy Title',
|
||||||
|
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||||
|
content: null,
|
||||||
|
};
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
|
||||||
|
expect(call[1]).toBe('PAGE_ID');
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT persist when the ydoc already has a title fragment', async () => {
|
||||||
|
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||||
|
Y.applyUpdate(src, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Has Title')));
|
||||||
|
const page = {
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
title: 'Has Title',
|
||||||
|
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||||
|
content: null,
|
||||||
|
};
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts legacy content -> ydoc and persists the built doc', async () => {
|
||||||
|
const page = {
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
title: 'T',
|
||||||
|
ydoc: null,
|
||||||
|
content: bodyJson,
|
||||||
|
};
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,4 +19,67 @@ describe('AuthController', () => {
|
|||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
expect(controller).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The EE MFA module is absent in this repo, so require() throws and is caught;
|
||||||
|
// login falls through to authService.login -> setAuthCookie -> returnToken.
|
||||||
|
describe('login returnToken branch', () => {
|
||||||
|
const workspace = { id: 'ws1', enforceSso: false };
|
||||||
|
|
||||||
|
const makeController = () => {
|
||||||
|
const authService = {
|
||||||
|
login: jest.fn().mockResolvedValue('jwt-token-123'),
|
||||||
|
};
|
||||||
|
const environmentService = {
|
||||||
|
getCookieExpiresIn: jest.fn().mockReturnValue(new Date()),
|
||||||
|
isHttps: jest.fn().mockReturnValue(false),
|
||||||
|
};
|
||||||
|
const ctrl = new AuthController(
|
||||||
|
authService as any,
|
||||||
|
{} as any,
|
||||||
|
environmentService as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
const res = { setCookie: jest.fn() };
|
||||||
|
return { ctrl, authService, res };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns the body token and sets the cookie when returnToken is true', async () => {
|
||||||
|
const { ctrl, authService, res } = makeController();
|
||||||
|
const loginInput = {
|
||||||
|
email: 'a@b.com',
|
||||||
|
password: 'pw',
|
||||||
|
returnToken: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ctrl.login(
|
||||||
|
workspace as any,
|
||||||
|
res as any,
|
||||||
|
loginInput as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ authToken: 'jwt-token-123' });
|
||||||
|
expect(res.setCookie).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.setCookie).toHaveBeenCalledWith(
|
||||||
|
'authToken',
|
||||||
|
'jwt-token-123',
|
||||||
|
expect.objectContaining({ httpOnly: true }),
|
||||||
|
);
|
||||||
|
expect(authService.login).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no body token but still sets the cookie when returnToken is omitted', async () => {
|
||||||
|
const { ctrl, res } = makeController();
|
||||||
|
const loginInput = { email: 'a@b.com', password: 'pw' };
|
||||||
|
|
||||||
|
const result = await ctrl.login(
|
||||||
|
workspace as any,
|
||||||
|
res as any,
|
||||||
|
loginInput as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(res.setCookie).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import { EnvironmentService } from './environment.service';
|
|||||||
describe('EnvironmentService', () => {
|
describe('EnvironmentService', () => {
|
||||||
let service: EnvironmentService;
|
let service: EnvironmentService;
|
||||||
|
|
||||||
|
// Build a service over a stub ConfigService whose get(key, def) returns
|
||||||
|
// values from the supplied env map (falling back to the provided default).
|
||||||
|
const makeService = (env: Record<string, string>) =>
|
||||||
|
new EnvironmentService({
|
||||||
|
get: (k: string, d?: string) => (k in env ? env[k] : d),
|
||||||
|
} as any);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new EnvironmentService(
|
service = new EnvironmentService(
|
||||||
{} as any, // configService
|
{} as any, // configService
|
||||||
@@ -14,4 +21,50 @@ describe('EnvironmentService', () => {
|
|||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getCorsAllowedOrigins', () => {
|
||||||
|
it('splits, trims, and drops empty entries', () => {
|
||||||
|
const svc = makeService({
|
||||||
|
CORS_ALLOWED_ORIGINS:
|
||||||
|
'https://a.com, https://b.com ,, https://c.com',
|
||||||
|
});
|
||||||
|
expect(svc.getCorsAllowedOrigins()).toEqual([
|
||||||
|
'https://a.com',
|
||||||
|
'https://b.com',
|
||||||
|
'https://c.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when the var is absent', () => {
|
||||||
|
const svc = makeService({});
|
||||||
|
expect(svc.getCorsAllowedOrigins()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSwaggerEnabled', () => {
|
||||||
|
it('is true for "true"', () => {
|
||||||
|
expect(makeService({ SWAGGER_ENABLED: 'true' }).isSwaggerEnabled()).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true case-insensitively for "TRUE"', () => {
|
||||||
|
expect(makeService({ SWAGGER_ENABLED: 'TRUE' }).isSwaggerEnabled()).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to false when absent', () => {
|
||||||
|
expect(makeService({}).isSwaggerEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for non-"true" values', () => {
|
||||||
|
expect(makeService({ SWAGGER_ENABLED: '0' }).isSwaggerEnabled()).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(makeService({ SWAGGER_ENABLED: 'yes' }).isSwaggerEnabled()).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user