Files
gitmost/apps/server/src/integrations/export/utils.spec.ts
a 40d1cdfc77 refactor(review): address #230 third review — callout dedup, ticket/type tidy
Approve-with-comments follow-ups (no blockers):

- callout: unify the GitHub-callout feature ticket on #192 (the callout-paste
  feature the CHANGELOG already tracks); #218 is the public-share security work.
  Fixed the code comment and test reference.
- export/utils.spec: pin current behavior of a leading-dot name (".gitignore" ->
  "") — same bug class as #204 but unreachable via the sole caller, so document
  not change.
- share.types: narrow ISharedPage to the actual /shares/page-info allowlist
  (page -> Pick of id/slugId/title/icon/content; trimmed share; dropped the
  spurious `extends IShare`). Verified all three consumers (shared-page,
  link-view, mention-view) read only allowlist fields.
- editor-ext: extract shared CALLOUT_TYPES / normalizeCalloutType /
  renderCalloutHtml into callout-common.marked.ts; both tokenizers
  (`:::type` and `> [!type]`) now share the renderer + type dict while staying
  separate. Eliminates the byte-identical renderer + duplicated type list.
- share.service: extract named predicate shareIdGrantsAccess(requestedShareId,
  resolvedShare) for the id-or-key fast path (naming only, no control-flow
  change); kept narrower than resolveReadableSharePage's id-only gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:11:16 +03:00

180 lines
6.3 KiB
TypeScript

import {
buildTree,
computeLocalPath,
getExportExtension,
extractPageSlugId,
getInternalLinkPageName,
INTERNAL_LINK_REGEX,
PageExportTree,
} from './utils';
import { ExportFormat } from './dto/export-dto';
import { Page } from '@docmost/db/types/entity.types';
/**
* Unit tests for export/utils.ts pure helpers:
* - buildTree: groups pages by parentPageId and de-duplicates sibling titles.
* - computeLocalPath / getExportExtension: builds the slugId -> file path map.
* - extractPageSlugId / INTERNAL_LINK_REGEX: parse the trailing slugId.
* - getInternalLinkPageName: derive a page name from a relative file path.
*/
function page(partial: Partial<Page>): Page {
return partial as Page;
}
describe('buildTree', () => {
it('groups pages by their parentPageId', () => {
const pages = [
page({ id: 'a', parentPageId: 'root', title: 'A', slugId: 'sa' }),
page({ id: 'b', parentPageId: 'root', title: 'B', slugId: 'sb' }),
page({ id: 'c', parentPageId: 'a', title: 'C', slugId: 'sc' }),
];
const tree = buildTree(pages);
expect(Object.keys(tree).sort()).toEqual(['a', 'root']);
expect(tree['root'].map((p) => p.id)).toEqual(['a', 'b']);
expect(tree['a'].map((p) => p.id)).toEqual(['c']);
});
it('suffixes duplicate sibling titles with " (1)", " (2)"', () => {
const pages = [
page({ id: '1', parentPageId: 'root', title: 'Doc', slugId: 's1' }),
page({ id: '2', parentPageId: 'root', title: 'Doc', slugId: 's2' }),
page({ id: '3', parentPageId: 'root', title: 'Doc', slugId: 's3' }),
];
const tree = buildTree(pages);
expect(tree['root'].map((p) => p.title)).toEqual([
'Doc',
'Doc (1)',
'Doc (2)',
]);
});
it('does not collide identical titles across different parents', () => {
const pages = [
page({ id: '1', parentPageId: 'p1', title: 'Same', slugId: 's1' }),
page({ id: '2', parentPageId: 'p2', title: 'Same', slugId: 's2' }),
];
const tree = buildTree(pages);
expect(tree['p1'][0].title).toBe('Same');
expect(tree['p2'][0].title).toBe('Same');
});
it('falls back to "untitled" for empty titles', () => {
const pages = [
page({ id: '1', parentPageId: 'root', title: '', slugId: 's1' }),
];
const tree = buildTree(pages);
expect(tree['root'][0].title).toBe('untitled');
});
it('returns an empty object for empty input', () => {
expect(buildTree([])).toEqual({});
});
});
describe('computeLocalPath + getExportExtension', () => {
it('builds nested parent/child paths with the markdown extension', () => {
const tree: PageExportTree = {
// root level uses the literal string 'null' as key only when parentPageId
// is null; here we use an explicit top-level key.
top: [page({ id: 'parent', title: 'Parent', slugId: 'sp' })],
parent: [page({ id: 'child', title: 'Child', slugId: 'sc' })],
};
const slugIdToPath: Record<string, string> = {};
computeLocalPath(tree, ExportFormat.Markdown, 'top', '', slugIdToPath);
expect(slugIdToPath['sp']).toBe('Parent.md');
expect(slugIdToPath['sc']).toBe('Parent/Child.md');
});
it('uses the html extension when the format is html', () => {
const tree: PageExportTree = {
top: [page({ id: 'parent', title: 'Parent', slugId: 'sp' })],
};
const slugIdToPath: Record<string, string> = {};
computeLocalPath(tree, ExportFormat.HTML, 'top', '', slugIdToPath);
expect(slugIdToPath['sp']).toBe('Parent.html');
});
it('getExportExtension returns the right extension and undefined for unknown', () => {
expect(getExportExtension(ExportFormat.HTML)).toBe('.html');
expect(getExportExtension(ExportFormat.Markdown)).toBe('.md');
expect(getExportExtension('pdf')).toBeUndefined();
});
});
describe('extractPageSlugId', () => {
it('returns the trailing segment after the last dash', () => {
expect(extractPageSlugId('slug-with-dashes-abc123')).toBe('abc123');
});
it('returns the input unchanged when there is no dash (bare slugId)', () => {
expect(extractPageSlugId('abc123')).toBe('abc123');
});
it('returns undefined for empty input', () => {
expect(extractPageSlugId('')).toBeUndefined();
});
});
describe('INTERNAL_LINK_REGEX', () => {
it('matches a /s/{space}/p/{slug} url and captures the slug in group 5', () => {
const match = '/s/space/p/page-abc123'.match(INTERNAL_LINK_REGEX);
expect(match).not.toBeNull();
expect(match![5]).toBe('page-abc123');
expect(extractPageSlugId(match![5])).toBe('abc123');
});
it('does not match a non-internal url', () => {
expect('https://example.com/foo/bar'.match(INTERNAL_LINK_REGEX)).toBeNull();
});
});
describe('getInternalLinkPageName', () => {
it('strips the file extension and decodes the name', () => {
expect(getInternalLinkPageName('Parent/My%20Page.md')).toBe('My Page');
});
it('keeps the full basename when the path has no extension (#204)', () => {
// An extensionless link target must NOT be stripped to an empty string —
// there is no extension to drop. Previously `.split('.').slice(0,-1)`
// collapsed "My Page" to "" and the internal link rendered with no text.
expect(getInternalLinkPageName('Parent/My%20Page')).toBe('My Page');
expect(getInternalLinkPageName('Just A Name')).toBe('Just A Name');
});
it('preserves dots in a dotted name that has a real extension (#204)', () => {
// "v1.2.md" -> "v1.2": only the final ".md" segment is the extension.
expect(getInternalLinkPageName('docs/v1.2.md')).toBe('v1.2');
});
it('documents current behavior: a leading-dot name collapses to empty text', () => {
// ".gitignore" -> base ".gitignore", parts ["", "gitignore"]: the leading
// dot is treated as a (empty) name + extension, so the name drops to "".
// Same bug class as #204, but unreachable via the sole caller (page titles
// never start with a dot), so we only pin the behavior — not fix it.
expect(getInternalLinkPageName('.gitignore')).toBe('');
});
it('falls back to the raw name without throwing on malformed encoding', () => {
// "%E0%A4" is an incomplete escape; decodeURIComponent throws and the
// helper returns the raw (still-encoded) name.
let result: string | undefined;
expect(() => {
result = getInternalLinkPageName('dir/%E0%A4.md', 'current.md');
}).not.toThrow();
expect(result).toBe('%E0%A4');
});
});