import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import os from 'os'; import path from 'path'; import { mkdtemp, rm } from 'fs/promises'; import { parseJournalMarkdown, serializeJournalMarkdown, sanitizeHtml, renderInlineMarkdown, isValidSlug, sanitizeSlug, } from '../journal'; describe('Journal Service & Parser', () => { describe('slug validation and sanitization', () => { it('validates safe alphanumeric and hyphenated slugs', () => { expect(isValidSlug('my-story-2026')).toBe(true); expect(isValidSlug('nordkap_journey')).toBe(true); expect(isValidSlug('story')).toBe(true); }); it('rejects path traversal and illegal characters', () => { expect(isValidSlug('../etc/passwd')).toBe(false); expect(isValidSlug('..\\secret')).toBe(false); expect(isValidSlug('story/sub')).toBe(false); expect(isValidSlug('story?query=1')).toBe(false); }); it('sanitizes titles to clean slugs', () => { expect(sanitizeSlug('Mein Trip nach Island & Co.!')).toBe('mein-trip-nach-island-co'); expect(sanitizeSlug(' Nordkap 2026 -- Part 1 ')).toBe('nordkap-2026-part-1'); }); }); describe('sanitizeHtml & inline markdown', () => { it('makes script tags and inline events inert', () => { const malicious = 'Hello '; const clean = sanitizeHtml(malicious); // The word "onerror" may well survive as text — what matters is that no // tag can form around it, so it is never an attribute. Testing for the // absence of the substring tested the old denylist, not the property. expect(clean).not.toContain('<'); expect(clean).not.toContain('>'); expect(clean).not.toContain('"'); }); it('formats bold, italic, and safe links', () => { const md = 'This is **bold** and *italic* and a [link](https://example.com).'; const html = renderInlineMarkdown(md); expect(html).toContain('bold'); expect(html).toContain('italic'); expect(html).toContain(' { it('parses frontmatter and structured blocks', () => { const markdown = `--- title: "Nordkap Tour" subtitle: "Auf den Spuren des Nordlichts" date: "2026-08-14" author: "Ralf" coverAssetId: "cover-uuid-1" draft: true --- # Kapitel 1: Die Reise beginnt Es war ein stürmischer Tag am Fjord. > Das Licht im Norden ist unvergleichlich. -- Ralf ![asset-uuid-2:fullbleed](Blick über den Fjord) ![pair-1, pair-2](Zwei Perspektiven)`; const parsed = parseJournalMarkdown(markdown); expect(parsed.frontmatter.title).toBe('Nordkap Tour'); expect(parsed.frontmatter.subtitle).toBe('Auf den Spuren des Nordlichts'); expect(parsed.frontmatter.draft).toBe(true); expect(parsed.frontmatter.coverAssetId).toBe('cover-uuid-1'); expect(parsed.blocks).toHaveLength(5); expect(parsed.blocks[0]).toEqual({ type: 'heading', level: 1, text: 'Kapitel 1: Die Reise beginnt', }); expect(parsed.blocks[1]).toEqual({ type: 'paragraph', html: 'Es war ein stürmischer Tag am Fjord.', }); expect(parsed.blocks[2]).toEqual({ type: 'quote', text: 'Das Licht im Norden ist unvergleichlich.', author: 'Ralf', }); expect(parsed.blocks[3]).toEqual({ type: 'photo', assetId: 'asset-uuid-2', caption: 'Blick über den Fjord', layout: 'fullbleed', }); expect(parsed.blocks[4]).toEqual({ type: 'photo-pair', assetIds: ['pair-1', 'pair-2'], caption: 'Zwei Perspektiven', }); expect(parsed.referencedAssetIds).toContain('cover-uuid-1'); expect(parsed.referencedAssetIds).toContain('asset-uuid-2'); expect(parsed.referencedAssetIds).toContain('pair-1'); expect(parsed.referencedAssetIds).toContain('pair-2'); }); it('serializes parsed journal back to markdown', () => { const markdown = `--- title: "Nordkap" draft: true --- # Title A paragraph of text. ![photo-uuid:wide](Caption)`; const parsed = parseJournalMarkdown(markdown); const serialized = serializeJournalMarkdown(parsed); expect(serialized).toContain('title: "Nordkap"'); expect(serialized).toContain('draft: true'); expect(serialized).toContain('# Title'); expect(serialized).toContain('A paragraph of text.'); expect(serialized).toContain('![photo-uuid:wide](Caption)'); }); }); describe('filesystem CRUD operations', () => { // The service resolves content/journal from process.cwd() at import time. // Without a temp cwd this wrote a real entry into the checkout's content/ // — the live site's, in a deployment — and left a .deleted.bak behind. const testSlug = 'test-journey-nordkap'; let root: string; let service: typeof import('../admin/journal-service'); beforeEach(async () => { root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-crud-')); vi.spyOn(process, 'cwd').mockReturnValue(root); vi.resetModules(); service = await import('../admin/journal-service'); }); afterEach(async () => { vi.restoreAllMocks(); await rm(root, { recursive: true, force: true }); }); it('writes, reads, lists and deletes journal files safely', async () => { const { writeJournalEntry, readJournalEntry, deleteJournalEntry, listJournalEntries } = service; const testContent = `--- title: "Vitest Journey" date: "2026-08-14" draft: false --- Testing the journal filesystem operations.`; await writeJournalEntry(testSlug, testContent); const entry = await readJournalEntry(testSlug); expect(entry).not.toBeNull(); expect(entry?.parsed.frontmatter.title).toBe('Vitest Journey'); const all = await listJournalEntries(); const found = all.find((e) => e.slug === testSlug); expect(found).toBeDefined(); expect(found?.frontmatter.title).toBe('Vitest Journey'); const deleted = await deleteJournalEntry(testSlug); expect(deleted).toBe(true); const checkAfter = await readJournalEntry(testSlug); expect(checkAfter).toBeNull(); }); }); });