Release 0.6.0

This commit is contained in:
hnzio committed 2026-10-25 09:45:31 +01:00
1 parent fd47910d09
commit b80dded55d
40 files changed
+4080 -856

No files matched your search

+288 -7
View File
@@ -1,6 +1,6 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs';
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const adminEmail = 'barkeeper@example.test';
@@ -85,7 +85,8 @@ function addSentHistoryFixture(pieceId: string) {
database.pragma('foreign_keys = ON');
const insertShow = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?)`
revision, created_at_utc, updated_at_utc, completed_at_utc)
VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?, ?)`
);
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
@@ -104,9 +105,24 @@ function addSentHistoryFixture(pieceId: string) {
] as const) {
const showId = `e2e-history-${suffix}`;
const sectionId = `e2e-history-section-${suffix}`;
insertShow.run(showId, title, date, date, date);
insertShow.run(showId, title, date, date, date, date);
insertSection.run(sectionId, showId);
insertUsage.run(`e2e-history-usage-${suffix}`, sectionId, pieceId);
const usageId = `e2e-history-usage-${suffix}`;
insertUsage.run(usageId, sectionId, pieceId);
database
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_at_utc) VALUES (?, ?, 1, ?)`
)
.run(showId, `fixture-${suffix}`, date);
database
.prepare(
`INSERT INTO piece_usage_history
(usage_id, show_id, original_piece_id, show_title_snapshot,
usage_title_snapshot, used_at_utc, actual_duration_seconds)
VALUES (?, ?, ?, ?, 'Historische Verwendung', ?, 300)`
)
.run(usageId, showId, pieceId, title, date);
}
})();
database.close();
@@ -117,10 +133,120 @@ function removeSentHistoryFixture() {
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.prepare("DELETE FROM piece_usage_history WHERE show_id LIKE 'e2e-history-%'").run();
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run();
database.close();
}
function addCompletionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
const now = '2026-10-25T19:00:00.000Z';
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
mkdirSync(path.join(dataDirectory, 'uploads'), { recursive: true });
writeFileSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), png);
database.transaction(() => {
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc)
VALUES ('e2e-completion', 'E2E Abschluss', ?, '', '', 'live', 1, ?, ?)`
)
.run(now, now, now);
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES
('e2e-completion-section', 'e2e-completion', 'Finaler Ablauf', NULL, 0, NULL, 0),
('e2e-completion-reserve', 'e2e-completion', 'Reservebank', NULL, 1, NULL, 1)`
)
.run();
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
interaction_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, 'e2e-completion-section', ?, ?, ?, 300, ?, ?, ?, ?, ?, ?, '', ?, 0)`
);
insertUsage.run(
'e2e-completion-done',
'piece-console',
0,
'done',
'Gesendeter Abschlussbeitrag',
'FREIGABE_EINLEITUNG',
'PRIVATE_FREIGABE_RECHERCHE',
'FREIGABE_MODERATION',
'FREIGABE_INTERAKTION',
'FREIGABE_UEBERLEITUNG',
'PRIVATE_FREIGABE_NOTIZ'
);
insertUsage.run(
'e2e-completion-skipped',
'piece-effects',
1,
'skipped',
'Übersprungener Abschlussbeitrag',
'',
'PRIVATE_SKIPPED_RESEARCH',
'',
'',
'',
''
);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES ('e2e-completion', 0, ?, ?, 0, ?)`
)
.run('2026-10-25T18:00:00.000Z', now, now);
database
.prepare(
`INSERT INTO piece_media
(id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc)
VALUES ('e2e-share-media', 'piece-console', 'e2e-share.png', 'intern.png',
'image/png', ?, 'Freigegebenes Abschlussbild', 77, ?)`
)
.run(png.byteLength, now);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('e2e-share-snapshot', 'e2e-completion-done', 'e2e-share-media',
'Freigegebenes Abschlussbild', 0, 1)`
)
.run();
})();
database.close();
}
function removeCompletionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.transaction(() => {
database
.prepare(
`DELETE FROM show_share_sessions WHERE share_id IN (
SELECT id FROM show_shares WHERE show_id = 'e2e-completion'
)`
)
.run();
database.prepare("DELETE FROM show_shares WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_usage_history WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM shows WHERE id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_media WHERE id = 'e2e-share-media'").run();
})();
database.close();
rmSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), { force: true });
}
function addPresentationFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
@@ -204,10 +330,10 @@ function addCalendarFixture() {
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
);
const now = '2026-10-24T10:00:00.000Z';
const now = '2026-10-25T10:00:00.000Z';
database.transaction(() => {
insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-24T18:00:00.000Z', now, now);
insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-24T08:00:00.000Z', now, now);
insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-25T18:00:00.000Z', now, now);
insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-25T09:00:00.000Z', now, now);
insert.run(
'e2e-calendar-next',
'Kalender Novembertermin',
@@ -1532,6 +1658,161 @@ test('live control persists state and exposes only explicit presentation data',
await expectNoHorizontalOverflow(page);
});
test('completion, archive and protected guest sharing work end to end', async ({
page,
browser
}, testInfo) => {
addCompletionFixture();
try {
await page.goto('/sendungen/e2e-completion');
const planningShares = page.locator('.share-management');
await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible();
await planningShares.getByLabel('Informationsumfang').selectOption('moderation');
await planningShares.getByRole('button', { name: 'Leselink anlegen' }).click();
const planningSharePath = await planningShares.locator('.share-result a').getAttribute('href');
expect(planningSharePath).toMatch(/^\/freigabe\//);
await page.screenshot({
path: testInfo.outputPath('share-editor-desktop.png'),
fullPage: true
});
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('share-editor-mobile.png'), fullPage: true });
await page.setViewportSize({ width: 1280, height: 900 });
const planningGuest = await browser.newContext();
const planningGuestPage = await planningGuest.newPage();
await planningGuestPage.goto(planningSharePath!);
await expect(planningGuestPage.getByText('Aktuelle Sendungsplanung · Nur lesen')).toBeVisible();
await expect(planningGuestPage.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(planningGuestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
const guestThemePreference = await planningGuestPage
.locator('html')
.getAttribute('data-theme-preference');
await planningGuestPage.getByRole('button', { name: /Darstellung:/ }).click();
await expect(planningGuestPage.locator('html')).not.toHaveAttribute(
'data-theme-preference',
guestThemePreference!
);
const planningShareRow = planningShares.locator('.share-row').first();
await planningShareRow.locator('summary').getByText('Ablauf und Moderation').click();
await planningShareRow.getByLabel('Informationsumfang').selectOption('research');
await planningShareRow.locator('select[name="passwordMode"]').selectOption('replace');
await planningShareRow.locator('input[name="password"]').fill('Planung-Freigabe-Passwort');
await planningShareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await planningGuestPage.reload();
await expect(
planningGuestPage.getByRole('heading', { name: 'Passwort erforderlich' })
).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
await planningShares
.locator('.share-row')
.first()
.locator('summary')
.getByText('Vollständig mit Recherche')
.click();
await planningShares.getByRole('button', { name: 'Freigabe löschen' }).click();
await planningGuestPage.reload();
await expect(planningGuestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
await planningGuest.close();
await page.goto('/live?show=e2e-completion');
await page.getByRole('button', { name: 'Menü', exact: true }).click();
await page.getByRole('button', { name: 'Sendung abschließen' }).click();
const completion = page.locator('.completion-dialog');
await expect(completion).toBeVisible();
await expect(completion).toContainText('1 gesendet');
await expect(completion).toContainText('1 übersprungen');
await expect(completion.getByText('Die kleine Konsole mit dem großen Dreh')).toBeVisible();
await expect(completion.getByText('Warum praktische Effekte wieder auffallen')).toHaveCount(0);
await completion.getByRole('button', { name: 'Historischen Stand abschließen' }).click();
await expect(completion).toBeHidden();
await page.goto('/archiv');
await expect(page.getByRole('link', { name: 'E2E Abschluss' })).toBeVisible();
await page.getByRole('link', { name: 'E2E Abschluss' }).click();
await expect(page.getByText('Unveränderliche Sendungshistorie')).toBeVisible();
await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0);
await page.goto('/verwaltung');
const shareManagement = page.locator('.share-management');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort');
await shareManagement.getByRole('button', { name: 'Leselink anlegen' }).click();
const shareLink = page.locator('.share-result a');
await expect(shareLink).toBeVisible();
const sharePath = await shareLink.getAttribute('href');
expect(sharePath).toMatch(/^\/freigabe\//);
await page.screenshot({ path: testInfo.outputPath('share-admin-desktop.png'), fullPage: true });
const guest = await browser.newContext();
const guestPage = await guest.newPage();
await guestPage.goto(sharePath!);
await expect(guestPage.getByRole('heading', { name: 'Passwort erforderlich' })).toBeVisible();
const passwordHtml = await guestPage.content();
expect(passwordHtml).not.toContain('password_hash');
expect(passwordHtml).not.toContain('e2e-completion');
await guestPage.getByLabel('Passwort').fill('falsch');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click();
await expect(guestPage.getByRole('alert')).toContainText('Passwort falsch');
await guestPage.getByLabel('Passwort').fill('E2E-Freigabe-Passwort');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0);
await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible();
await guestPage.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(guestPage);
await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true });
const imagePath = await guestPage
.getByAltText('Freigegebenes Abschlussbild')
.getAttribute('src');
const sharedImage = await guest.request.get(imagePath!);
expect(sharedImage.status()).toBe(200);
expect(sharedImage.headers()['content-security-policy']).toBe("default-src 'none'; sandbox");
expect((await guest.request.get(`${sharePath}/media/foreign-id`)).status()).toBe(404);
const guestWrite = await guest.request.post('/sendungen/e2e-completion?/saveMetadata', {
maxRedirects: 0,
form: { title: 'Verboten', revision: '2', status: 'draft' }
});
expect(guestWrite.status()).toBe(403);
await page.goto('/verwaltung');
const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await expect(showCard.getByText('Abgeschlossen')).toBeVisible();
const shareRow = showCard.locator('.share-row').first();
await shareRow.locator('summary').getByText('Ablauf und Moderation').click();
await shareRow.getByLabel('Informationsumfang').selectOption('rundown');
await shareRow.locator('select[name="passwordMode"]').selectOption('remove');
await shareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toHaveCount(0);
page.once('dialog', (dialog) => dialog.accept());
const updatedCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await updatedCard
.locator('.share-row')
.first()
.locator('summary')
.getByText('Nur Ablauf')
.click();
await updatedCard.getByRole('button', { name: 'Freigabe löschen' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
expect((await guest.request.get(imagePath!)).status()).toBe(404);
await guest.close();
} finally {
removeCompletionFixture();
}
});
test('pieces autosave safely, detect conflicts and protect uploaded images', async ({
page,
browser