import { execFileSync } from 'node:child_process'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createPiece, createTaxonomy, deletePieceImage, deleteTaxonomy, duplicatePiece, getMediaFile, getPiece, getTaxonomy, listPieceTemplates, listPieces, loadPiecePresentationMetadata, savePiece, savePieceAsTemplate, setPieceArchived, uploadPieceImage, type PieceInput } from './content.js'; import { closeDatabase } from './database.js'; import { renderMarkdown } from './markdown.js'; let directory: string; beforeEach(() => { directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-content-test-')); process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite'); process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads'); execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env }); execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env }); }); afterEach(() => { closeDatabase(); delete process.env.SENDEWERK_DB_PATH; delete process.env.SENDEWERK_UPLOAD_DIR; rmSync(directory, { recursive: true, force: true }); }); function completeInput(overrides: Partial = {}): PieceInput { return { title: 'Getestetes Piece', outputTitle: '', summary: 'Eine persistente Kurzbeschreibung.', formatId: 'format-talk', status: 'ready', durationSeconds: 420, intro: '**Starker Einstieg**', researchText: 'Private Recherche', moderationNotes: '- Punkt eins', transitionText: 'Weiter geht es.', presentationText: '## Sichtbare Aussage', presentationMode: 'text', presentationSourcePosition: null, presentationTitle: '', presentationDescription: '', presentationImageUrl: '', presentationSiteName: '', presentationProvider: '', presentationProviderId: '', presentationTitleManual: false, presentationDescriptionManual: false, presentationImageManual: false, presentationMetadataFetchedAtUtc: null, topicIds: ['topic-gaming', 'topic-internet'], categoryIds: ['cat-jc', 'cat-reaction'], sources: [ { label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 } ], ...overrides }; } describe('content management', () => { it('persists independently versioned templates and copies their complete snapshot', () => { const sourceId = createPiece('talk'); const source = getPiece(sourceId)!; savePiece( sourceId, source.revision, completeInput({ title: 'Interner Vorlagentitel', outputTitle: 'Titel für die Ausgabe', presentationText: 'Unveränderlicher Vorlagentext' }) ); const created = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Redaktioneller Stand'); const template = listPieceTemplates().find((entry) => entry.id === created.id)!; expect(template).toMatchObject({ name: 'Eigene Vorlage', revision: 1 }); const changed = getPiece(sourceId)!; savePiece(sourceId, changed.revision, { ...changed, status: 'ready', presentationText: 'Spätere Änderung' }); const copyId = createPiece(created.id); expect(getPiece(copyId)).toMatchObject({ title: 'Interner Vorlagentitel', outputTitle: 'Titel für die Ausgabe', presentationText: 'Unveränderlicher Vorlagentext', sources: [ { label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 } ] }); const updated = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Neuer Stand', created.id, 1); expect(updated.revision).toBe(2); expect(() => savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Veraltet', created.id, 1) ).toThrow('CONFLICT'); }); it('persists complete pieces and rejects stale revisions atomically', () => { const id = createPiece('talk'); const initial = getPiece(id)!; const saved = savePiece(id, initial.revision, completeInput()); const piece = getPiece(id)!; expect(saved.revision).toBe(2); expect(piece).toMatchObject({ title: 'Getestetes Piece', status: 'ready', researchText: 'Private Recherche', topicIds: ['topic-gaming', 'topic-internet'], categoryIds: ['cat-jc', 'cat-reaction'] }); expect(piece.sources).toEqual([ { label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 } ]); expect(() => savePiece(id, initial.revision, completeInput({ title: 'Veralteter Titel', topicIds: [] })) ).toThrow('CONFLICT'); expect(getPiece(id)).toMatchObject({ title: 'Getestetes Piece', topicIds: ['topic-gaming', 'topic-internet'] }); }); it('stores fetched preview metadata and preserves manual corrections on reload', async () => { const id = createPiece('find'); const initial = getPiece(id)!; const saved = savePiece( id, initial.revision, completeInput({ presentationMode: 'link', presentationSourcePosition: 0, presentationTitle: 'Manueller Titel', presentationTitleManual: true }) ); const loaded = await loadPiecePresentationMetadata(id, saved.revision, async () => ({ title: 'Automatischer Titel', description: 'Geladener Anreißer', imageUrl: 'https://example.com/vorschau.jpg', siteName: 'Example', provider: '', providerId: '' })); expect(getPiece(id)).toMatchObject({ revision: loaded.revision, presentationTitle: 'Manueller Titel', presentationDescription: 'Geladener Anreißer', presentationImageUrl: 'https://example.com/vorschau.jpg', presentationSiteName: 'Example' }); await loadPiecePresentationMetadata(id, loaded.revision, async () => ({ title: 'Anderer automatischer Titel', description: 'Aktualisierter Anreißer', imageUrl: '', siteName: 'Example', provider: '', providerId: '' })); expect(getPiece(id)).toMatchObject({ presentationTitle: 'Manueller Titel', presentationDescription: 'Aktualisierter Anreißer' }); }); it('searches independent taxonomies and archives, restores and duplicates pieces', async () => { createTaxonomy('topic', 'Technikgeschichte'); const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!; const sourceId = createPiece('find'); const source = getPiece(sourceId)!; savePiece( sourceId, source.revision, completeInput({ title: 'Seltene Hardware', formatId: 'format-find', topicIds: [topic.id], categoryIds: ['cat-reaction'] }) ); expect( listPieces({ statuses: ['ready'], query: 'dokumentation', topicId: topic.id }) ).toHaveLength(1); expect(listPieces({ statuses: ['ready'], query: '%' })).toHaveLength(0); expect(() => deleteTaxonomy('topic', topic.id)).toThrow('TAXONOMY_IN_USE'); createTaxonomy('topic', 'Unbenutzt'); const unused = getTaxonomy().topics.find((entry) => entry.name === 'Unbenutzt')!; deleteTaxonomy('topic', unused.id); expect(getTaxonomy().topics.some((entry) => entry.id === unused.id)).toBe(false); expect(() => deleteTaxonomy('format', 'format-find')).toThrow('TAXONOMY_IN_USE'); const duplicateId = await duplicatePiece(sourceId); expect(getPiece(duplicateId)).toMatchObject({ title: 'Kopie von Seltene Hardware', status: 'preparing', topicIds: [topic.id] }); const current = getPiece(sourceId)!; setPieceArchived(sourceId, current.revision, true); expect(listPieces({ statuses: ['ready'] }).some((piece) => piece.id === sourceId)).toBe(false); const archived = listPieces({ statuses: ['archived'] }).find((piece) => piece.id === sourceId)!; setPieceArchived(sourceId, archived.revision, false); expect(getPiece(sourceId)?.status).toBe('ready'); }); it('sanitizes dangerous markdown while preserving useful formatting', () => { const html = renderMarkdown( '# Verbotene Ebene\n\n**sicher** [Link](javascript:alert(1))' ); expect(html).toContain('sicher'); expect(html).not.toContain(' { const id = createPiece(); const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64' ); await uploadPieceImage( id, 1, new File([png], '../bild.png', { type: 'text/html' }), 'Ein heller Pixel' ); const piece = getPiece(id)!; expect(piece.media).toHaveLength(1); expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' }); const media = await getMediaFile(piece.media[0].id); expect(media?.data.equals(png)).toBe(true); expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false); await expect( uploadPieceImage( id, piece.revision, new File([''], 'angriff.png', { type: 'image/png' }), '' ) ).rejects.toThrow('INVALID_IMAGE_TYPE'); await deletePieceImage(id, piece.media[0].id, piece.revision); expect(getPiece(id)?.media).toHaveLength(0); }); });