From 01eda06221ede323dc363915fba5741ee663154f Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 18:22:18 +0300 Subject: [PATCH] test(offline): add reviewer-requested coverage for offline-sync core logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/features/offline/make-offline.test.ts | 172 +++++++++ .../src/features/offline/make-offline.ts | 4 +- .../features/offline/query-persister.test.ts | 84 +++++ apps/client/src/pwa/is-capacitor.test.ts | 39 +++ .../collaboration/collaboration.util.spec.ts | 53 +++ .../extensions/persistence.extension.spec.ts | 331 ++++++++++++++++++ .../src/core/auth/auth.controller.spec.ts | 63 ++++ .../environment/environment.service.spec.ts | 53 +++ 8 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/features/offline/make-offline.test.ts create mode 100644 apps/client/src/features/offline/query-persister.test.ts create mode 100644 apps/client/src/pwa/is-capacitor.test.ts create mode 100644 apps/server/src/collaboration/collaboration.util.spec.ts create mode 100644 apps/server/src/collaboration/extensions/persistence.extension.spec.ts diff --git a/apps/client/src/features/offline/make-offline.test.ts b/apps/client/src/features/offline/make-offline.test.ts new file mode 100644 index 00000000..28989413 --- /dev/null +++ b/apps/client/src/features/offline/make-offline.test.ts @@ -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); + }); +}); diff --git a/apps/client/src/features/offline/make-offline.ts b/apps/client/src/features/offline/make-offline.ts index 5eabaaf7..86c77664 100644 --- a/apps/client/src/features/offline/make-offline.ts +++ b/apps/client/src/features/offline/make-offline.ts @@ -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( +export async function warmInfiniteAll( queryKey: unknown[], fetchPage: (cursor: string | undefined) => Promise>, maxPages = 50, diff --git a/apps/client/src/features/offline/query-persister.test.ts b/apps/client/src/features/offline/query-persister.test.ts new file mode 100644 index 00000000..db286702 --- /dev/null +++ b/apps/client/src/features/offline/query-persister.test.ts @@ -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); + }); +}); diff --git a/apps/client/src/pwa/is-capacitor.test.ts b/apps/client/src/pwa/is-capacitor.test.ts new file mode 100644 index 00000000..b747d718 --- /dev/null +++ b/apps/client/src/pwa/is-capacitor.test.ts @@ -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); + }); +}); diff --git a/apps/server/src/collaboration/collaboration.util.spec.ts b/apps/server/src/collaboration/collaboration.util.spec.ts new file mode 100644 index 00000000..524e4ba8 --- /dev/null +++ b/apps/server/src/collaboration/collaboration.util.spec.ts @@ -0,0 +1,53 @@ +import * as Y from 'yjs'; +import { TiptapTransformer } from '@hocuspocus/transformer'; +import { + buildTitleSeedYdoc, + jsonToText, + tiptapExtensions, +} from './collaboration.util'; + +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); + }); +}); diff --git a/apps/server/src/collaboration/extensions/persistence.extension.spec.ts b/apps/server/src/collaboration/extensions/persistence.extension.spec.ts new file mode 100644 index 00000000..832367ff --- /dev/null +++ b/apps/server/src/collaboration/extensions/persistence.extension.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/server/src/core/auth/auth.controller.spec.ts b/apps/server/src/core/auth/auth.controller.spec.ts index 4746f249..103d1ec9 100644 --- a/apps/server/src/core/auth/auth.controller.spec.ts +++ b/apps/server/src/core/auth/auth.controller.spec.ts @@ -19,4 +19,67 @@ describe('AuthController', () => { it('should be defined', () => { 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); + }); + }); }); diff --git a/apps/server/src/integrations/environment/environment.service.spec.ts b/apps/server/src/integrations/environment/environment.service.spec.ts index efef25b0..1af7522f 100644 --- a/apps/server/src/integrations/environment/environment.service.spec.ts +++ b/apps/server/src/integrations/environment/environment.service.spec.ts @@ -5,6 +5,13 @@ import { EnvironmentService } from './environment.service'; describe('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) => + new EnvironmentService({ + get: (k: string, d?: string) => (k in env ? env[k] : d), + } as any); + beforeEach(() => { service = new EnvironmentService( {} as any, // configService @@ -14,4 +21,50 @@ describe('EnvironmentService', () => { it('should be defined', () => { 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, + ); + }); + }); });