Files
docmost-sync/test/apply-push-actions.test.ts
vvzvlad 9c6283aa8e feat(sync): FS->Docmost push #1 — diff/ref primitives + pure planner + apply (fakes)
First slice of the push direction (SPEC §6), mirroring pull: VaultGit primitives +
pure planner + thin injectable apply, exercised via fakes (no live destructive run).

- git.ts: diffNameStatus (--name-status -M -z, NUL-parsed, rename-aware),
  revParse/readRef/updateRef (refs/docmost/last-pushed), showFileAtRef (recover a
  deleted file's pre-image pageId)
- push.ts computePushActions (pure): A/M/D/R -> create/update/delete/renamesMoves;
  delete only when pageId is recovered from the pre-image, else skipped (§8 guard —
  no spurious Docmost delete)
- push.ts applyPushActions (fakes): update via importPageMarkdown (collab/Yjs path,
  §2 — never a raw jsonb overwrite); create via createPage then write the assigned
  pageId back into the file meta (body preserved); delete via deletePage (soft, §8);
  renamesMoves deferred; advances last-pushed
- tests (+26): diffNameStatus A/M/D/rename, ref round-trip, showFileAtRef; pure
  classification incl. §8 no-pageid skip; apply with fakes (collab-path update,
  pageid write-back, soft-delete, deferred moves)
- 683 -> 709 green; build clean; corpus STABLE

Deferred (next increment): move/rename apply, loop-guard (§10), watcher/debounce,
remote push, live main wiring, empty-spaceId create guard, per-page error isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:32:15 +03:00

275 lines
9.2 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { applyPushActions, LAST_PUSHED_REF } from '../src/push.js';
import type { ApplyPushDeps, PushActions } from '../src/push.js';
import {
parseDocmostMarkdown,
serializeDocmostMarkdownBody,
} from '../packages/docmost-client/src/lib/markdown-document.js';
// FS→Docmost push, FIRST increment (SPEC §6). `applyPushActions` is the THIN IO
// half: create/update/delete via FAKES that record every call — no real network,
// git, or fs. Asserts: update uses importPageMarkdown (collab path, SPEC
// §2/§15.6); create writes the assigned pageId BACK into the file meta; delete
// soft-deletes; rename/move is returned as `deferred` with NO client call; the
// last-pushed ref is advanced.
/** A recording client fake; createPage returns a configurable assigned id. */
function makeClient(opts?: { createId?: string }) {
const client = {
importPageMarkdown: vi.fn(async (_pageId: string, _md: string) => ({
success: true,
})),
createPage: vi.fn(
async (
title: string,
_content: string,
_spaceId: string,
_parentPageId?: string,
) => ({
// Mirrors the real `createPage` shape: `{ data: { id, ... }, success }`.
data: { id: opts?.createId ?? 'assigned-id', title },
success: true,
}),
),
deletePage: vi.fn(async (_pageId: string) => ({ success: true })),
};
return client;
}
/** A recording git fake (only updateRef is used by the push applier). */
function makeGit() {
const updateRefCalls: { ref: string; target: string }[] = [];
const git = {
updateRef: vi.fn(async (ref: string, target: string) => {
updateRefCalls.push({ ref, target });
}),
};
return { git, updateRefCalls };
}
/** A recording fs fake over a path->text store. */
function makeFs(initial: Record<string, string> = {}) {
const store: Record<string, string> = { ...initial };
const writes: { path: string; text: string }[] = [];
const reads: string[] = [];
const fs = {
readFile: vi.fn(async (path: string) => {
reads.push(path);
if (!(path in store)) throw new Error(`no such file: ${path}`);
return store[path];
}),
writeFile: vi.fn(async (path: string, text: string) => {
store[path] = text;
writes.push({ path, text });
}),
};
return { fs, store, writes, reads };
}
function deps(client: any, git: any, fs: ReturnType<typeof makeFs>): ApplyPushDeps {
return {
client,
git,
readFile: fs.fs.readFile,
writeFile: fs.fs.writeFile,
};
}
function actions(partial: Partial<PushActions>): PushActions {
return {
creates: [],
updates: [],
deletes: [],
renamesMoves: [],
skipped: [],
...partial,
};
}
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('applyPushActions — update (collab path, SPEC §2/§15.6)', () => {
it('reads the file body and calls importPageMarkdown with it', async () => {
const fileBody =
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nupdated body\n';
const client = makeClient();
const { git } = makeGit();
const fs = makeFs({ 'Doc.md': fileBody });
const res = await applyPushActions(
deps(client, git, fs),
actions({ updates: [{ pageId: 'p-1', path: 'Doc.md' }] }),
);
expect(res.updated).toBe(1);
// The collab/Yjs write path is used — NOT a raw jsonb overwrite.
expect(client.importPageMarkdown).toHaveBeenCalledTimes(1);
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-1', fileBody);
// No raw-overwrite path exists on the injected client surface at all.
expect((client as any).updatePageJson).toBeUndefined();
expect(client.createPage).not.toHaveBeenCalled();
expect(client.deletePage).not.toHaveBeenCalled();
});
});
describe('applyPushActions — create (assigned pageId written back to meta)', () => {
it('createPage is called and the new pageId is serialized back into the file', async () => {
// A brand-new local file: meta has title/spaceId but NO pageId yet.
const original = serializeDocmostMarkdownBody(
{ version: 1, title: 'My New Page', spaceId: 'sp-7', parentPageId: 'parent-9' },
'# My New Page\n\nbody text',
);
const client = makeClient({ createId: 'page-new-42' });
const { git } = makeGit();
const fs = makeFs({ 'New.md': original });
const res = await applyPushActions(
deps(client, git, fs),
actions({ creates: [{ path: 'New.md' }] }),
);
expect(res.created).toBe(1);
// createPage was called with title/body/spaceId/parentPageId from meta.
expect(client.createPage).toHaveBeenCalledTimes(1);
const [title, content, spaceId, parentPageId] =
client.createPage.mock.calls[0];
expect(title).toBe('My New Page');
expect(spaceId).toBe('sp-7');
expect(parentPageId).toBe('parent-9');
expect(content).toContain('body text');
// The file was rewritten with the assigned pageId in meta...
expect(fs.writes.map((w) => w.path)).toEqual(['New.md']);
const rewritten = fs.store['New.md'];
const parsed = parseDocmostMarkdown(rewritten);
expect(parsed.meta?.pageId).toBe('page-new-42');
// ...preserving the rest of the meta and the body.
expect(parsed.meta?.title).toBe('My New Page');
expect(parsed.meta?.spaceId).toBe('sp-7');
expect(parsed.body).toContain('body text');
// The write-back is recorded so a follow-up commit can be made (NEXT inc).
expect(res.writtenBack).toEqual([{ path: 'New.md', pageId: 'page-new-42' }]);
});
});
describe('applyPushActions — delete (soft-delete to Trash, SPEC §8)', () => {
it('calls deletePage(pageId)', async () => {
const client = makeClient();
const { git } = makeGit();
const fs = makeFs();
const res = await applyPushActions(
deps(client, git, fs),
actions({ deletes: [{ pageId: 'p-del' }] }),
);
expect(res.deleted).toBe(1);
expect(client.deletePage).toHaveBeenCalledTimes(1);
expect(client.deletePage).toHaveBeenCalledWith('p-del');
// No body read needed for a delete.
expect(fs.reads).toEqual([]);
});
});
describe('applyPushActions — rename/move is DEFERRED (NEXT increment)', () => {
it('returns renames/moves as `deferred` with NO client call', async () => {
const client = makeClient();
const { git } = makeGit();
const fs = makeFs();
const rm = { pageId: 'p-mv', oldPath: 'Old.md', newPath: 'New.md' };
const res = await applyPushActions(
deps(client, git, fs),
actions({ renamesMoves: [rm] }),
);
expect(res.deferred).toEqual([rm]);
// NOTHING was pushed for the move this increment.
expect(client.importPageMarkdown).not.toHaveBeenCalled();
expect(client.createPage).not.toHaveBeenCalled();
expect(client.deletePage).not.toHaveBeenCalled();
});
});
describe('applyPushActions — last-pushed ref advance (SPEC §6 step 3)', () => {
it('advances refs/docmost/last-pushed to the pushed commit', async () => {
const client = makeClient();
const { git, updateRefCalls } = makeGit();
const fs = makeFs();
const res = await applyPushActions(
deps(client, git, fs),
actions({ deletes: [{ pageId: 'p' }] }),
'commit-sha-abc',
);
expect(res.lastPushedAdvanced).toBe(true);
expect(updateRefCalls).toEqual([
{ ref: LAST_PUSHED_REF, target: 'commit-sha-abc' },
]);
});
it('does NOT advance the ref when no pushed commit is given', async () => {
const client = makeClient();
const { git, updateRefCalls } = makeGit();
const fs = makeFs();
const res = await applyPushActions(
deps(client, git, fs),
actions({ updates: [] }),
);
expect(res.lastPushedAdvanced).toBe(false);
expect(updateRefCalls).toEqual([]);
expect(git.updateRef).not.toHaveBeenCalled();
});
});
describe('applyPushActions — mixed batch + skipped passthrough', () => {
it('applies update + create + delete and carries skipped rows through', async () => {
const updFile =
'<!-- docmost:meta\n{"version":1,"pageId":"u-1"}\n-->\n\nupd\n';
const newFile = serializeDocmostMarkdownBody(
{ version: 1, title: 'N', spaceId: 'sp' },
'fresh body',
);
const client = makeClient({ createId: 'created-1' });
const { git, updateRefCalls } = makeGit();
const fs = makeFs({ 'U.md': updFile, 'N.md': newFile });
const skipped = [
{ path: 'Stray.md', status: 'D' as const, reason: 'no recoverable pageId' },
];
const res = await applyPushActions(
deps(client, git, fs),
actions({
updates: [{ pageId: 'u-1', path: 'U.md' }],
creates: [{ path: 'N.md' }],
deletes: [{ pageId: 'd-1' }],
skipped,
}),
'sha-9',
);
expect(res).toMatchObject({
created: 1,
updated: 1,
deleted: 1,
lastPushedAdvanced: true,
});
expect(res.writtenBack).toEqual([{ path: 'N.md', pageId: 'created-1' }]);
expect(res.skipped).toEqual(skipped);
expect(updateRefCalls).toEqual([{ ref: LAST_PUSHED_REF, target: 'sha-9' }]);
expect(client.importPageMarkdown).toHaveBeenCalledWith('u-1', updFile);
expect(client.deletePage).toHaveBeenCalledWith('d-1');
});
});