Files
docmost-sync/test/strip-block-ids.test.ts
vvzvlad 447d2508ae feat(sync): scaffold monorepo, extract docmost-client, add Phase-0 harness + read-only pull
Lock the access-layer decision (REST only) and start implementation per SPEC.

- monorepo (npm workspaces): packages/docmost-client = DocmostClient + lib/*
  copied 1:1 from docmost-mcp/src (backport target), plus bannered sync methods
  (listTrash, restorePage, listAllSpacePages, exportPageBody, listRecentSince /
  collectRecentSince cursor scan)
- engine stays the root app per AGENTS.md (src/, test/, build/, data/, settings.ts);
  add roundtrip.ts (SPEC §11 idempotency harness), pull.ts (SPEC §6 read-only
  Docmost->FS mirror), sanitize.ts (SPEC §12 filenames, path-traversal-safe)
- Dockerfile builds the workspace lib before the app; vitest gates CI
- exportPageBody never touches /comments (SPEC §3); serializeDocmostMarkdownBody
  emits meta + body only
- SPEC: resolve access-layer (REST), reflect root-engine layout + REST pagination
- tests: sanitize (incl. dot-traversal), collectRecentSince (cutoff/dedup/cap),
  stripBlockIds, markdown round-trip byte-stability

Note: raw ProseMirror round-trip is byte-stable in Markdown but not yet attribute-
idempotent (SPEC §11 Задача №0, before Phase 2).
2026-06-16 20:20:20 +03:00

81 lines
2.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { stripBlockIds } from '../src/roundtrip.js';
describe('stripBlockIds', () => {
it('removes only attrs.id, recursively, keeping every other attribute', () => {
const input = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { id: 'h1', level: 2 },
content: [{ type: 'text', text: 'Title' }],
},
{
type: 'callout',
attrs: { id: 'c1', kind: 'info' },
content: [
{
type: 'paragraph',
attrs: { id: 'p1', indent: null },
content: [{ type: 'text', text: 'Body' }],
},
],
},
],
};
const out = stripBlockIds(input);
expect(out).toEqual({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Title' }],
},
{
type: 'callout',
attrs: { kind: 'info' },
content: [
{
type: 'paragraph',
attrs: { indent: null },
content: [{ type: 'text', text: 'Body' }],
},
],
},
],
});
// No stray `id` survives anywhere in the tree.
expect(JSON.stringify(out)).not.toContain('"id"');
});
it('does not mutate its input (frozen object passes through unchanged)', () => {
const inner = Object.freeze({
type: 'paragraph',
attrs: Object.freeze({ id: 'p1', indent: null }),
content: Object.freeze([
Object.freeze({ type: 'text', text: 'x' }),
]),
});
const input = Object.freeze({
type: 'doc',
content: Object.freeze([inner]),
});
const before = JSON.stringify(input);
// Would throw on any write to a frozen node if the function mutated input.
const out = stripBlockIds(input);
// Input is structurally identical after the call (no mutation).
expect(JSON.stringify(input)).toBe(before);
// The id is gone from the returned (new) tree.
expect((out.content[0].attrs as Record<string, unknown>).id).toBeUndefined();
expect((out.content[0].attrs as Record<string, unknown>).indent).toBeNull();
// A fresh tree is returned, not the same reference.
expect(out).not.toBe(input);
});
});