Files
sendewerk/src/lib/server/content.spec.ts
T
2026-10-23 07:47:27 +02:00

268 lines
8.9 KiB
TypeScript

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> = {}): 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** <script>globalThis.compromised = true</script> [Link](javascript:alert(1))'
);
expect(html).toContain('<strong>sicher</strong>');
expect(html).not.toContain('<script');
expect(html).not.toContain('javascript:');
expect(html).not.toContain('<h1');
});
it('accepts signed raster images, rejects disguised files and keeps storage private', async () => {
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(['<script>alert(1)</script>'], '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);
});
});