268 lines
8.9 KiB
TypeScript
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);
|
|
});
|
|
});
|