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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
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', () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user