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:
claude_code
2026-06-21 18:22:18 +03:00
committed by claude code agent 227
parent 7ac7fcba2d
commit df02f2d672
8 changed files with 795 additions and 1 deletions

View File

@@ -1,9 +1,13 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
getPageId,
isEmptyParagraphDoc,
jsonToNode,
prosemirrorNodeToYElement,
buildTitleSeedYdoc,
jsonToText,
tiptapExtensions,
} from './collaboration.util';
import { Node } from '@tiptap/pm/model';
@@ -241,3 +245,49 @@ describe('prosemirrorNodeToYElement', () => {
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);
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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<string, string>) =>
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,
);
});
});
});