Files
gitmost/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.spec.ts
T
agent_qa e6a861bdaf fix(git-sync): a valid-but-nonexistent gitmost_id no longer wedges a space's sync (N1-D1)
A vault file whose `gitmost_id` is a WELL-FORMED UUID that matches no page (a stale
id from a restored-from-backup file, or a copied/foreign id) fell through
importPageMarkdown to writeBody() on a non-existent page, throwing "Page … not
found". The push apply recorded that as a per-cycle failure that never cleared —
refs never advance, so the whole space's sync looped on the failure indefinitely
(observed live: a leftover orphan file kept a space stuck at "1 failure" every ~5s).
Same user-visible impact as C9-D1, but the id is a valid uuid so the 22P02 guard
does not catch it.

Add the missing `currentPage == null` branch in importPageMarkdown: skip the
unknown id as an inert no-op so the cycle succeeds and the rest of the space keeps
syncing. Verified on the stand: pushing a valid-but-nonexistent gitmost_id now stays
at 0 failures (was 1/cycle forever), logs a skip warn, and a concurrent legit edit
still syncs. Unit test added; server suite green (2146).

NOTE (separate design follow-up, not this commit): the reconcile still cleans the
orphan file (it maps to no live page). ADOPTING such a file as a fresh page (the
restore-from-backup use case, preserving the git-authored content) needs the title
from the filename, which lives in the engine classifier, not this method.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 06:49:12 +03:00

923 lines
37 KiB
TypeScript

// Stub the collab util so importing the service does not drag in the
// editor-ext -> @tiptap/react -> react-dom graph (unloadable under jest's node
// env, same coupling noted in mcp.service.spec.ts). The captured transact
// callback is never executed in these unit tests, so the stub extensions array
// is sufficient; the real collab write path is exercised by integration tests.
jest.mock('../../../collaboration/collaboration.util', () => ({
tiptapExtensions: [],
getPageId: (name: string) => name.replace(/^page\./, ''),
}));
// writeBody now builds the replacement Yjs state eagerly (before clearing the
// live doc), so TiptapTransformer.toYdoc runs in these unit tests. Real Tiptap
// extensions are stubbed to [] above (they drag in the React graph), which can't
// build a schema — so stub the transformer to return a small non-empty Y.Doc.
// The real conversion is exercised by the @docmost/git-sync converter tests and
// the integration tests.
jest.mock('@hocuspocus/transformer', () => {
const Yjs = require('yjs');
return {
TiptapTransformer: {
toYdoc: jest.fn(() => {
const d = new Yjs.Doc();
d.getXmlFragment('default').insert(0, [new Yjs.XmlElement('paragraph')]);
return d;
}),
},
};
});
// PageService is only ever a mocked dependency here; stub the editor-ext entry
// it imports so loading its module does not pull in the React graph either.
jest.mock('@docmost/editor-ext', () => ({
markdownToHtml: jest.fn(),
}));
// The service loads `parseDocmostMarkdown` / `markdownToProseMirror` at runtime
// via the `loadGitSync()` bridge (the ESM `@docmost/git-sync` package cannot be
// `require()`d under jest). Stub the loader: the real conversion is exercised by
// the @docmost/git-sync converter tests and the converter gate; here the mocked
// TiptapTransformer.toYdoc ignores the converted doc anyway, so a passthrough
// body + a minimal ProseMirror doc is sufficient.
jest.mock('../git-sync.loader', () => ({
loadGitSync: jest.fn(async () => ({
parseDocmostMarkdown: (md: string) => ({ meta: {}, body: md }),
markdownToProseMirror: async () => ({
type: 'doc',
content: [{ type: 'paragraph' }],
}),
// renamePage funnels the current title through sanitizeTitle to detect the
// sanitized-stem echo; identity is the correct default here (none of the
// rename fixtures use filename-hostile chars, so the sanitized form equals
// the input and the guard never fires).
sanitizeTitle: (title: string) => title,
// importPageMarkdown guard #2 uses docsCanonicallyEqual to skip a no-op
// re-ingest. A key-order-insensitive JSON compare is a sufficient stand-in
// for the unit tests (the real semantic equality is covered by the
// @docmost/git-sync converter tests); returning false for genuinely
// different docs lets the write paths under test proceed.
docsCanonicallyEqual: (a: unknown, b: unknown) =>
JSON.stringify(a) === JSON.stringify(b),
})),
}));
import { GitmostDataSourceService } from './gitmost-datasource.service';
// The loader is mocked above; this binding is the hoisted jest.fn, so a single
// test can swap the runtime bridge (e.g. a smarter `docsCanonicallyEqual`) via
// `mockResolvedValueOnce` without perturbing the default used by every other test.
import { loadGitSync } from '../git-sync.loader';
// Focused unit/contract test for the native GitSyncClient adapter.
// No DB, no real collab server: the repos/services/gateway are mocked and we
// assert the mapping logic + the provenance/soft-delete/position contracts.
type AnyMock = jest.Mock;
interface Mocks {
pageRepo: {
findById: AnyMock;
getSpaceDescendants: AnyMock;
restorePage: AnyMock;
};
spaceRepo: { findById: AnyMock };
pageService: {
create: AnyMock;
update: AnyMock;
movePage: AnyMock;
removePage: AnyMock;
};
collabGateway: { writePageBody: AnyMock };
// Minimal Kysely-ish chainable mock for the direct-query paths.
db: any;
}
function makeQueryBuilder(rows: any[]) {
const qb: any = {};
for (const m of ['select', 'where', 'orderBy', 'limit']) {
qb[m] = jest.fn(() => qb);
}
qb.execute = jest.fn(async () => rows);
qb.executeTakeFirst = jest.fn(async () => rows[0]);
return qb;
}
function build(rows: any[] = []): {
service: GitmostDataSourceService;
mocks: Mocks;
} {
const mocks: Mocks = {
pageRepo: {
findById: jest.fn(),
getSpaceDescendants: jest.fn(),
restorePage: jest.fn(async () => undefined),
},
spaceRepo: { findById: jest.fn(async () => ({ id: 'space-1' })) },
pageService: {
create: jest.fn(),
update: jest.fn(async () => undefined),
movePage: jest.fn(async () => undefined),
removePage: jest.fn(async () => undefined),
},
collabGateway: {
writePageBody: jest.fn(async () => undefined),
},
db: {
selectFrom: jest.fn(() => makeQueryBuilder(rows)),
},
};
const service = new GitmostDataSourceService(
mocks.pageRepo as any,
mocks.spaceRepo as any,
mocks.pageService as any,
mocks.collabGateway as any,
mocks.db as any,
);
return { service, mocks };
}
const CTX = { workspaceId: 'ws-1', userId: 'svc-user' };
// A bound context that carries the reconciling spaceId, enabling deletePage's
// cross-space MOVE guard (the `if (ctx.spaceId)` branch).
const CTX_SPACE = { ...CTX, spaceId: 'space-1' };
// A syntactically VALID parent uuid. createPage/movePage now coerce a malformed
// (non-UUID) parentPageId to root (F1), so tests exercising the normal
// pass-through parent path must use a real uuid, not an arbitrary token.
const PARENT_UUID = '11111111-1111-4111-8111-111111111111';
describe('GitmostDataSourceService', () => {
describe('listSpaceTree', () => {
it('maps descendants to PageNode and is always complete:true', async () => {
const { service, mocks } = build();
mocks.spaceRepo.findById.mockResolvedValue({ id: 'space-1' });
mocks.pageRepo.getSpaceDescendants.mockResolvedValue([
{
id: 'p1',
slugId: 's1',
title: 'Root',
parentPageId: null,
position: 'a0',
},
{
id: 'p2',
slugId: 's2',
title: 'Child',
parentPageId: 'p1',
position: 'a1',
},
]);
const client = service.bind(CTX);
const res = await client.listSpaceTree('space-1');
expect(res.complete).toBe(true);
expect(mocks.pageRepo.getSpaceDescendants).toHaveBeenCalledWith(
'space-1',
{ includeContent: false },
);
expect(res.pages).toEqual([
{
id: 'p1',
slugId: 's1',
title: 'Root',
parentPageId: null,
hasChildren: true, // p2's parent is p1
position: 'a0',
},
{
id: 'p2',
slugId: 's2',
title: 'Child',
parentPageId: 'p1',
hasChildren: false,
position: 'a1',
},
]);
});
it('throws when the space is not found', async () => {
const { service, mocks } = build();
mocks.spaceRepo.findById.mockResolvedValue(undefined);
await expect(service.bind(CTX).listSpaceTree('nope')).rejects.toThrow();
});
});
describe('getPageJson', () => {
it('returns the engine page shape with ISO updatedAt + content', async () => {
const { service, mocks } = build();
const updatedAt = new Date('2026-06-20T10:00:00.000Z');
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
slugId: 's1',
title: 'Doc',
parentPageId: null,
spaceId: 'space-1',
updatedAt,
content: { type: 'doc', content: [] },
});
const res = await service.bind(CTX).getPageJson('p1');
expect(mocks.pageRepo.findById).toHaveBeenCalledWith('p1', {
includeContent: true,
});
expect(res).toEqual({
id: 'p1',
slugId: 's1',
title: 'Doc',
parentPageId: null,
spaceId: 'space-1',
updatedAt: '2026-06-20T10:00:00.000Z',
content: { type: 'doc', content: [] },
});
});
it('throws NotFound when the page does not exist', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue(undefined);
await expect(service.bind(CTX).getPageJson('gone')).rejects.toThrow(
/not found/i,
);
});
});
describe('importPageMarkdown', () => {
it('parses md, converts to ProseMirror, and routes the body write to the owning instance', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
updatedAt: new Date('2026-06-20T11:00:00.000Z'),
});
const res = await service
.bind(CTX)
.importPageMarkdown('p1', '# Hello\n\nworld');
// writeBody routes through writePageBody (NOT openDirectConnection): the
// merge must run on the instance that owns the live doc so a connected
// editor converges instead of silently reverting the change. The service
// user rides on the payload as the responsible author.
expect(mocks.collabGateway.writePageBody).toHaveBeenCalledTimes(1);
const [docName, payload] = mocks.collabGateway.writePageBody.mock.calls[0];
expect(docName).toBe('page.p1');
expect(payload.userId).toBe('svc-user');
// A converted ProseMirror doc was passed; no base on a plain import.
expect(payload.prosemirrorJson).toEqual(
expect.objectContaining({ type: 'doc' }),
);
expect(payload.baseProsemirrorJson).toBeUndefined();
expect(res.updatedAt).toBe('2026-06-20T11:00:00.000Z');
});
it('returns updatedAt:undefined when the page row is gone after the write (stale-read branch)', async () => {
// The page EXISTS at import time (so the unknown-page guard N1-D1 does not
// fire), writeBody succeeds, but the POST-write findById returns nothing (e.g.
// the page was concurrently hard-deleted) -> the optional updatedAt is omitted.
const { service, mocks } = build();
mocks.pageRepo.findById
.mockResolvedValueOnce({
id: 'p1',
updatedAt: new Date('2026-06-20T11:00:00.000Z'),
}) // currentPage (import-time read) exists
.mockResolvedValue(undefined); // post-write read: page is gone
const res = await service
.bind(CTX)
.importPageMarkdown('p1', '# Hello\n\nworld');
expect(mocks.collabGateway.writePageBody).toHaveBeenCalledTimes(1);
expect(res.updatedAt).toBeUndefined();
});
it('skips (no writeBody) when the gitmost_id is a valid UUID matching NO page (bug N1-D1)', async () => {
// A well-formed but stale/foreign id (restore-from-backup, copied file) must
// NOT fall through to writeBody on a non-existent page (which throws "Page not
// found" and wedges the space's sync loop). It is skipped as an inert no-op.
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue(undefined);
const res = await service
.bind(CTX)
.importPageMarkdown('019f2500-face-7000-8000-000000000002', '# orphan');
expect(res).toEqual({});
expect(mocks.collabGateway.writePageBody).not.toHaveBeenCalled();
});
// F5 acceptance, criterion (b): guard #2 (docsCanonicallyEqual) must SKIP the
// re-ingest when the freshly-parsed doc is canonically equal to the page's
// current DB content even though the two are NOT byte-identical — the DB copy
// carries the real per-block uuids (comments anchor to them) while a fresh
// parse has none. Skipping is what protects a concurrent, not-yet-flushed
// human edit from being clobbered by an idempotent poll re-ingest.
//
// NON-VACUITY: `currentContent` here differs from `doc` in raw JSON (it has an
// `attrs.id` uuid `doc` lacks). The default mock `docsCanonicallyEqual` is a
// plain `JSON.stringify` compare — it would return FALSE for these inputs, so
// if guard #2 were removed (or reverted to a canonicalJsonEqual that does not
// strip ids) writePageBody WOULD be called and this test would fail. We model
// the package's authoritative id-stripping equality (returns TRUE here) by
// swapping in a `docsCanonicallyEqual` that normalizes away block ids, proving
// it is the SEMANTIC equality — not a byte compare — that suppresses the write.
it('does NOT call writePageBody (guard #2) when content is canonically equal despite differing block ids (F5 criterion b)', async () => {
const { service, mocks } = build();
const realUuid = '11111111-1111-4111-8111-111111111111';
// The DB row's content carries a REAL per-block uuid; a fresh parse does not.
// These two docs are canonically equal (id-only difference) but NOT
// byte-identical, so a naive JSON compare treats the page as "changed".
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
updatedAt: new Date('2026-06-20T11:00:00.000Z'),
content: {
type: 'doc',
content: [{ type: 'paragraph', attrs: { id: realUuid } }],
},
});
// Swap the runtime bridge for THIS call only: same passthrough parse/convert
// as the default mock (so `doc` is the id-less `{type:'doc',[paragraph]}`),
// but `docsCanonicallyEqual` strips block ids before comparing — the real
// converter's semantics. It returns TRUE for (doc, currentContent) here.
const stripIds = (node: any): any => {
if (Array.isArray(node)) return node.map(stripIds);
if (node && typeof node === 'object') {
const out: any = {};
for (const [k, v] of Object.entries(node)) {
if (k === 'attrs' && v && typeof v === 'object') {
const { id: _id, ...rest } = v as Record<string, unknown>;
if (Object.keys(rest).length) out.attrs = stripIds(rest);
} else {
out[k] = stripIds(v);
}
}
return out;
}
return node;
};
(loadGitSync as jest.Mock).mockResolvedValueOnce({
parseDocmostMarkdown: (md: string) => ({ meta: {}, body: md }),
markdownToProseMirror: async () => ({
type: 'doc',
content: [{ type: 'paragraph' }],
}),
sanitizeTitle: (title: string) => title,
docsCanonicallyEqual: (a: unknown, b: unknown) =>
JSON.stringify(stripIds(a)) === JSON.stringify(stripIds(b)),
});
// No baseMarkdown -> guard #1 (fullMarkdown === baseMarkdown) cannot fire, so
// the outcome is decided purely by guard #2.
const res = await service
.bind(CTX)
.importPageMarkdown('p1', '# Hello\n\nworld');
// Guard #2 fired: the redundant re-ingest is skipped, so the concurrent
// human edit in the live doc is NOT clobbered.
expect(mocks.collabGateway.writePageBody).not.toHaveBeenCalled();
// The unchanged page's updatedAt is still surfaced from the DB row.
expect(res.updatedAt).toBe('2026-06-20T11:00:00.000Z');
});
// The 2-way path (no base) is covered above; this exercises the THREE-WAY
// branch that only fires when a `baseMarkdown` is supplied (review #5). The
// merge dispatch itself now lives in the collab handler (gitSyncWriteBody);
// here we assert the datasource forwards the base so the owning instance can
// run the 3-way reconcile.
describe('with a baseMarkdown (three-way merge)', () => {
it('forwards the parsed base body so the owning instance can three-way merge', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
updatedAt: new Date('2026-06-20T11:00:00.000Z'),
});
await service
.bind(CTX)
.importPageMarkdown('p1', '# Full\n\ngit', '# Base\n\nbase');
expect(mocks.collabGateway.writePageBody).toHaveBeenCalledTimes(1);
const [, payload] = mocks.collabGateway.writePageBody.mock.calls[0];
// Both the incoming body AND the last-synced base were converted and
// forwarded — proof the 3-way common-ancestor is plumbed through.
expect(payload.prosemirrorJson).toEqual(
expect.objectContaining({ type: 'doc' }),
);
expect(payload.baseProsemirrorJson).toEqual(
expect.objectContaining({ type: 'doc' }),
);
});
});
// The cross-space confused-deputy guard (review S2) fires only when
// ctx.spaceId is bound. A vault file in space A can carry space B's pageId;
// without this check the reconciling space could overwrite B's page body — a
// write it has no authority over. When the resolved page already lives in a
// DIFFERENT space, the import is SKIPPED (writePageBody not called) and the
// page's own updatedAt is returned unchanged.
describe('cross-space guard (ctx.spaceId bound, review S2)', () => {
it('does NOT call writePageBody when the target page lives in another space', async () => {
const { service, mocks } = build();
// The resolved page is in space-2, but the reconciling context is space-1.
// Its content DIFFERS from the parsed doc, so guard #2 (docsCanonicallyEqual)
// cannot be what suppresses the write — proving NON-VACUITY: without the S2
// guard the flow would reach writeBody and writePageBody WOULD be called.
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
deletedAt: null,
spaceId: 'space-2',
updatedAt: new Date('2026-06-21T09:00:00.000Z'),
content: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B body' }] },
],
},
});
const res = await service
.bind(CTX_SPACE)
.importPageMarkdown('p1', '# Hello\n\nworld');
// The cross-space page is preserved: no body write happened.
expect(mocks.collabGateway.writePageBody).not.toHaveBeenCalled();
// Early-return shape: only the page's own updatedAt is surfaced.
expect(res).toEqual({ updatedAt: '2026-06-21T09:00:00.000Z' });
});
});
});
// Bug C9-D1: a vault file with a malformed (non-UUID) `gitmost_id` frontmatter
// makes the id reach a Postgres `uuid` predicate, which throws error code
// '22P02'. Left unhandled the push apply records it as a per-cycle failure that
// never clears -> the whole space's sync loops forever. The bind() seam wraps the
// id-scoped writes so exactly that error is swallowed as an inert no-op.
describe('malformed-id guard (bug C9-D1: non-UUID gitmost_id must not wedge sync)', () => {
const pgInvalidUuid = Object.assign(
new Error('invalid input syntax for type uuid: "not-a-uuid"'),
{ code: '22P02' },
);
it('importPageMarkdown swallows a 22P02 and does NOT write the body', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockRejectedValue(pgInvalidUuid);
const res = await service
.bind(CTX)
.importPageMarkdown('not-a-uuid', '# x');
expect(res).toEqual({}); // inert no-op, no throw
expect(mocks.collabGateway.writePageBody).not.toHaveBeenCalled();
});
it('deletePage swallows the 22P02 thrown by the uuid predicate (no wedge)', async () => {
const { service, mocks } = build();
// The malformed id reaches removePage's `uuid` predicate, which throws 22P02.
mocks.pageService.removePage.mockRejectedValue(pgInvalidUuid);
await expect(
service.bind(CTX).deletePage('not-a-uuid'),
).resolves.toBeUndefined();
});
it('re-throws a NON-22P02 error (does not mask real failures)', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockRejectedValue(new Error('db down'));
await expect(
service.bind(CTX).importPageMarkdown('not-a-uuid', '# x'),
).rejects.toThrow('db down');
});
});
describe('createPage', () => {
it('creates the shell with git-sync provenance, writes body, returns id', async () => {
const { service, mocks } = build();
mocks.pageService.create.mockResolvedValue({ id: 'new-id' });
mocks.pageRepo.findById.mockResolvedValue({
id: 'new-id',
updatedAt: new Date('2026-06-20T12:00:00.000Z'),
});
const res = await service
.bind(CTX)
.createPage('Title', 'body md', 'space-1', PARENT_UUID);
expect(mocks.pageService.create).toHaveBeenCalledWith(
'svc-user',
'ws-1',
{ spaceId: 'space-1', title: 'Title', parentPageId: PARENT_UUID },
{ actor: 'git-sync', aiChatId: null },
);
expect(mocks.collabGateway.writePageBody).toHaveBeenCalledWith(
'page.new-id',
expect.objectContaining({ userId: 'svc-user' }),
);
expect(res).toEqual({
data: { id: 'new-id' },
updatedAt: '2026-06-20T12:00:00.000Z',
});
});
it('returns updatedAt:undefined when the fresh page row is missing after create', async () => {
const { service, mocks } = build();
mocks.pageService.create.mockResolvedValue({ id: 'new-id' });
// The post-create findById returns nothing -> the optional updatedAt is
// omitted (the id is still returned from create()).
mocks.pageRepo.findById.mockResolvedValue(undefined);
const res = await service
.bind(CTX)
.createPage('Title', 'body md', 'space-1');
expect(res).toEqual({ data: { id: 'new-id' }, updatedAt: undefined });
});
// F1 (bug C9-D1, parent-id variant): a parent folder-note carrying a broken
// non-UUID `gitmost_id` makes the push planner hand createPage a malformed
// parentPageId. Left as-is it flows into pageService.create -> findById
// (slugId fallback -> no row) -> NotFoundException, a throw that never clears
// the push `failures` set, so the WHOLE space wedges forever. The fix COERCES
// the malformed parent to root (undefined) so the page is created at the space
// root (self-heal) instead of being dropped — and never wedges.
//
// NON-VACUITY: the mocked pageService.create asserts it received
// `parentPageId: undefined`. Against the UNcoerced createPage the malformed
// string would flow straight through and this assertion would fail (create
// would be called with parentPageId:'[unclosed-broken-id'), so the test
// genuinely exercises the coercion.
it('coerces a malformed (non-UUID) parentPageId to root and does NOT wedge (F1)', async () => {
const { service, mocks } = build();
mocks.pageService.create.mockResolvedValue({ id: 'new-id' });
mocks.pageRepo.findById.mockResolvedValue({
id: 'new-id',
updatedAt: new Date('2026-06-20T12:00:00.000Z'),
});
const res = await service
.bind(CTX)
.createPage('Title', 'body md', 'space-1', '[unclosed-broken-id');
// No throw propagated to `failures`; create ran with the parent coerced to
// root (undefined), NOT the malformed string — so create's findById /
// nextPagePosition never see a non-uuid and cannot 22P02 / NotFound.
expect(mocks.pageService.create).toHaveBeenCalledWith(
'svc-user',
'ws-1',
{ spaceId: 'space-1', title: 'Title', parentPageId: undefined },
{ actor: 'git-sync', aiChatId: null },
);
expect(res).toEqual({
data: { id: 'new-id' },
updatedAt: '2026-06-20T12:00:00.000Z',
});
});
it('leaves a VALID uuid parentPageId unchanged (only a malformed parent is coerced)', async () => {
const { service, mocks } = build();
mocks.pageService.create.mockResolvedValue({ id: 'new-id' });
mocks.pageRepo.findById.mockResolvedValue({
id: 'new-id',
updatedAt: new Date('2026-06-20T12:00:00.000Z'),
});
await service
.bind(CTX)
.createPage('Title', 'body md', 'space-1', PARENT_UUID);
// A real uuid passes the isValidUUID check and is forwarded untouched.
expect(mocks.pageService.create).toHaveBeenCalledWith(
'svc-user',
'ws-1',
{ spaceId: 'space-1', title: 'Title', parentPageId: PARENT_UUID },
{ actor: 'git-sync', aiChatId: null },
);
});
});
describe('deletePage', () => {
it('uses the soft-delete path (removePage), not a force delete', async () => {
const { service, mocks } = build();
await service.bind(CTX).deletePage('p1');
// Passes git-sync provenance so the soft-delete stamps
// lastUpdatedSource='git-sync' (loop-guard, PR #119 review).
expect(mocks.pageService.removePage).toHaveBeenCalledWith(
'p1',
'svc-user',
'ws-1',
{ actor: 'git-sync', aiChatId: null },
);
// No forceDelete on the service surface used here.
expect((mocks.pageService as any).forceDelete).toBeUndefined();
});
// The cross-space MOVE guard fires only when ctx.spaceId is bound. It re-reads
// the page and SKIPS the soft-delete when the page has already moved to a
// DIFFERENT space (otherwise a move-out would trash the page from both vaults).
describe('cross-space move guard (ctx.spaceId bound)', () => {
it("skips removePage when the page moved to another space (move-out)", async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
deletedAt: null,
spaceId: 'space-2',
});
const res = await service.bind(CTX_SPACE).deletePage('p1');
// The page still lives in space-2 — it must NOT be trashed.
expect(mocks.pageService.removePage).not.toHaveBeenCalled();
expect(res).toEqual({ id: 'p1', skipped: 'moved-to-other-space' });
});
it('soft-deletes when the page is still in the reconciling space (genuine delete)', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
deletedAt: null,
spaceId: 'space-1',
});
await service.bind(CTX_SPACE).deletePage('p1');
// Same space -> a real deletion; removePage runs with git-sync provenance.
expect(mocks.pageService.removePage).toHaveBeenCalledWith(
'p1',
'svc-user',
'ws-1',
{ actor: 'git-sync', aiChatId: null },
);
});
it('soft-deletes when the page row is not found (guard must not swallow a real delete)', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue(undefined);
await service.bind(CTX_SPACE).deletePage('p1');
expect(mocks.pageService.removePage).toHaveBeenCalledWith(
'p1',
'svc-user',
'ws-1',
{ actor: 'git-sync', aiChatId: null },
);
});
it('soft-deletes when the page is already soft-deleted (deletedAt non-null)', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
deletedAt: new Date('2026-06-20T10:00:00.000Z'),
spaceId: 'space-2',
});
await service.bind(CTX_SPACE).deletePage('p1');
// deletedAt is non-null -> not treated as a live move-out; removePage runs.
expect(mocks.pageService.removePage).toHaveBeenCalledWith(
'p1',
'svc-user',
'ws-1',
{ actor: 'git-sync', aiChatId: null },
);
});
});
});
describe('movePage', () => {
it('computes a fractional position when none is supplied', async () => {
// db query returns a last sibling at 'a0' -> jittered key after it.
const { service, mocks } = build([{ position: 'a0' }]);
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
spaceId: 'space-1',
});
await service.bind(CTX).movePage('p1', PARENT_UUID);
expect(mocks.pageService.movePage).toHaveBeenCalledTimes(1);
const [dto, page, provenance, actorUserId] =
mocks.pageService.movePage.mock.calls[0];
expect(dto.pageId).toBe('p1');
expect(dto.parentPageId).toBe(PARENT_UUID);
expect(typeof dto.position).toBe('string');
expect(dto.position.length).toBeGreaterThan(0);
expect(page).toEqual({ id: 'p1', spaceId: 'space-1' });
expect(provenance).toEqual({ actor: 'git-sync', aiChatId: null });
// The git-initiated move is attributed to the service user (lastUpdatedById
// parity with create/delete/rename).
expect(actorUserId).toBe('svc-user');
});
it('passes through an explicit position unchanged', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
spaceId: 'space-1',
});
await service.bind(CTX).movePage('p1', null, 'zz');
const [dto] = mocks.pageService.movePage.mock.calls[0];
expect(dto.position).toBe('zz');
// db not consulted for a supplied position.
expect(mocks.db.selectFrom).not.toHaveBeenCalled();
});
it('throws NotFound and moves nothing when the page does not exist', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue(undefined);
await expect(
service.bind(CTX).movePage('gone', 'parent-1'),
).rejects.toThrow(/not found/i);
expect(mocks.pageService.movePage).not.toHaveBeenCalled();
});
// F1 (parent-id variant), mirror of the createPage case. A malformed
// (non-UUID) destination parentPageId would reach computeMovePosition's raw
// uuid predicate (22P02, swallowed but mis-logged) or — when a position is
// supplied — pageService.movePage's findById -> NotFoundException (NOT a
// 22P02, so NOT swallowed -> the space wedges). The fix coerces it to root
// (null) so the reparent targets root instead of wedging. The page here has a
// current parent, so coerced-root != current parent and the echo-guard does
// not short-circuit — the move proceeds against a null (root) parent.
//
// NON-VACUITY: the assertion is `dto.parentPageId === null`. Against the
// UNcoerced movePage the malformed string would flow through to
// pageService.movePage's dto and this would fail (it would be the raw
// '[broken-parent' token).
it('coerces a malformed (non-UUID) parentPageId to root and does NOT wedge (F1)', async () => {
const { service, mocks } = build([]); // no siblings -> fresh position key
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
spaceId: 'space-1',
parentPageId: '22222222-2222-4222-8222-222222222222',
});
await service.bind(CTX).movePage('p1', '[broken-parent');
expect(mocks.pageService.movePage).toHaveBeenCalledTimes(1);
const [dto] = mocks.pageService.movePage.mock.calls[0];
expect(dto.pageId).toBe('p1');
// Coerced to root: the malformed parent never reaches the uuid predicate.
expect(dto.parentPageId).toBeNull();
expect(typeof dto.position).toBe('string');
});
});
describe('renamePage', () => {
it('updates only the title with git-sync provenance', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({ id: 'p1', title: 'old' });
await service.bind(CTX).renamePage('p1', 'new title');
const [page, dto, user, provenance] =
mocks.pageService.update.mock.calls[0];
expect(page).toEqual({ id: 'p1', title: 'old' });
expect(dto.title).toBe('new title');
expect(user).toEqual({ id: 'svc-user' });
expect(provenance).toEqual({ actor: 'git-sync', aiChatId: null });
});
it("strips the ` ~<slugId>` disambiguation suffix when it matches the page's OWN slugId", async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
slugId: 's1',
title: 'old',
});
await service.bind(CTX).renamePage('p1', 'Real Title ~s1');
const [, dto] = mocks.pageService.update.mock.calls[0];
// The trailing ` ~s1` equals this page's own slugId -> a vault artifact; strip.
expect(dto.title).toBe('Real Title');
});
it('leaves a FOREIGN ` ~<slugId>` tail untouched (only the own slugId is stripped)', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
slugId: 's1',
title: 'old',
});
await service.bind(CTX).renamePage('p1', 'Real Title ~other');
const [, dto] = mocks.pageService.update.mock.calls[0];
// ` ~other` is not this page's slugId -> a genuine title; must not be corrupted.
expect(dto.title).toBe('Real Title ~other');
});
it('throws NotFound and renames nothing when the page does not exist', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue(undefined);
await expect(
service.bind(CTX).renamePage('gone', 'whatever'),
).rejects.toThrow(/not found/i);
expect(mocks.pageService.update).not.toHaveBeenCalled();
});
});
describe('restorePage', () => {
it('restores via the repo restore path scoped to the workspace', async () => {
const { service, mocks } = build();
const res = await service.bind(CTX).restorePage('p1');
// Stamps lastUpdatedSource='git-sync' on restore (loop-guard, PR #119).
expect(mocks.pageRepo.restorePage).toHaveBeenCalledWith(
'p1',
'ws-1',
'git-sync',
);
expect(res).toEqual({ id: 'p1' });
});
});
// Phase-B+ continuous-sync methods: not yet called by the engine but wired into
// the GitSyncClient seam (PR #119 review #5). Exercised via the bound client.
describe('listRecentSince', () => {
it('queries non-deleted pages newest-first and ISO-stringifies updatedAt', async () => {
const rows = [
{
id: 'p1',
slugId: 's1',
title: 'A',
parentPageId: null,
spaceId: 'space-1',
updatedAt: new Date('2026-06-20T10:00:00.000Z'),
},
];
const { service, mocks } = build(rows);
const qb = mocks.db.selectFrom.mock.results; // populated after the call
const out = (await service
.bind(CTX)
.listRecentSince('space-1', '2026-06-19T00:00:00.000Z', 100)) as any[];
// Query builder shaped against the `pages` table with the expected chain.
expect(mocks.db.selectFrom).toHaveBeenCalledWith('pages');
const builder = qb[0].value;
expect(builder.select).toHaveBeenCalled();
expect(builder.orderBy).toHaveBeenCalledWith('updatedAt', 'desc');
// deletedAt is null + the conditional spaceId / since / cap clauses.
const whereArgs = builder.where.mock.calls.map((c: any[]) => c[0]);
expect(whereArgs).toContain('deletedAt');
expect(whereArgs).toContain('spaceId');
expect(whereArgs).toContain('updatedAt');
expect(builder.limit).toHaveBeenCalledWith(100);
expect(out).toEqual([
{
id: 'p1',
slugId: 's1',
title: 'A',
parentPageId: null,
spaceId: 'space-1',
updatedAt: '2026-06-20T10:00:00.000Z',
},
]);
});
it('omits the spaceId / since / cap clauses when not supplied', async () => {
const { service, mocks } = build([]);
await service.bind(CTX).listRecentSince(undefined, null);
const builder = mocks.db.selectFrom.mock.results[0].value;
const whereArgs = builder.where.mock.calls.map((c: any[]) => c[0]);
// Only the deletedAt-is-null guard; no spaceId / updatedAt> clauses.
expect(whereArgs).toEqual(['deletedAt']);
expect(builder.limit).not.toHaveBeenCalled();
});
});
describe('listTrash', () => {
it('queries soft-deleted pages and ISO-stringifies deletedAt (null stays null)', async () => {
const rows = [
{
id: 'p1',
slugId: 's1',
title: 'Trashed',
parentPageId: null,
spaceId: 'space-1',
deletedAt: new Date('2026-06-21T09:00:00.000Z'),
},
{
id: 'p2',
slugId: 's2',
title: 'NoDate',
parentPageId: null,
spaceId: 'space-1',
deletedAt: null,
},
];
const { service, mocks } = build(rows);
const out = (await service.bind(CTX).listTrash('space-1')) as any[];
expect(mocks.db.selectFrom).toHaveBeenCalledWith('pages');
const builder = mocks.db.selectFrom.mock.results[0].value;
const whereCalls = builder.where.mock.calls;
// deletedAt is-not null (the trash predicate) + spaceId filter.
expect(whereCalls).toContainEqual(['deletedAt', 'is not', null]);
expect(whereCalls).toContainEqual(['spaceId', '=', 'space-1']);
expect(builder.orderBy).toHaveBeenCalledWith('deletedAt', 'desc');
expect(out[0].deletedAt).toBe('2026-06-21T09:00:00.000Z');
expect(out[1].deletedAt).toBeNull();
});
});
});