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).
81 lines
2.3 KiB
TypeScript
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);
|
|
});
|
|
});
|