Files
immich-folio/lib/__tests__/journal.test.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

190 lines
6.3 KiB
TypeScript

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 <script>alert(1)</script><img src="x" onerror="alert(2)">';
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('<strong>bold</strong>');
expect(html).toContain('<em>italic</em>');
expect(html).toContain('<a href="https://example.com"');
});
});
describe('parseJournalMarkdown and serializeJournalMarkdown', () => {
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();
});
});
});