shinkan-jinkendo/tests/dev-smoke-test.spec.js
Lars 34235ef46d
Some checks failed
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Failing after 1m23s
feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).

Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
  check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
  write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
  media_asset_rights_declarations (Append-only Audit-Log), Felder
  rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
  POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
  GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
  assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert

Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
  (konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
  Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet

Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt

Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log

Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.

version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00

481 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { test, expect } = require('@playwright/test');
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
const TEST_PASSWORD = process.env.TEST_PASSWORD || '12345678';
/** Primärer Submit auf der Login-Seite (nicht den Tab "Login" vs. "Registrieren"). */
async function submitLoginForm(page) {
await page.getByRole('button', { name: 'Anmelden' }).click();
}
async function login(page) {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Warte bis Login-Seite geladen ist
await page.waitForSelector('input[type="email"]', { timeout: 10000 });
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await submitLoginForm(page);
// Wait until auth is complete: URL leaves /login and Dashboard is rendered
await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
await page.waitForLoadState('networkidle');
}
test('1. Login funktioniert', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('input[type="email"]', { timeout: 10000 });
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await submitLoginForm(page);
await page.waitForLoadState('networkidle');
// Nach Login soll der Tab "Login" (Moduswahl) verschwinden — nicht der Submit "Anmelden"
const loginButton = page.locator('button:has-text("Login")');
await expect(loginButton).toHaveCount(0, { timeout: 10000 });
await page.screenshot({ path: 'screenshots/01-nach-login.png' });
console.log('✓ Login erfolgreich');
});
test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
await login(page);
// Warte bis Spinner verschwunden
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
// Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift)
const main = page.locator('.app-main');
await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
timeout: 5000,
});
await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 });
await page.screenshot({ path: 'screenshots/02-dashboard.png' });
console.log('✓ Dashboard OK');
});
test('3. Navigation zu Übungen', async ({ page }) => {
await login(page);
// Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
await page.setViewportSize({ width: 390, height: 844 });
// Desktop-Sidebar enthält ebenfalls Übungen nur Mobile-Bottom-Nav klicken (sichtbarer Link)
await page.locator('.bottom-nav a[href="/exercises"]').click();
await page.waitForLoadState('networkidle');
// Prüfe ob Übungen-Seite geladen
await expect(page.locator('h1, h2, .page-title')).toContainText(/übungen/i, { timeout: 5000 });
await page.screenshot({ path: 'screenshots/03-uebungen.png' });
console.log('✓ Übungen-Seite erreichbar');
});
test('4. Navigation zu Vereine', async ({ page }) => {
await login(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('.bottom-nav a[href="/clubs"]').click();
await page.waitForLoadState('networkidle');
// ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter
// Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen.
await expect(page).toHaveURL(/\/clubs\/?$/, { timeout: 5000 });
await expect(page.getByRole('heading', { level: 1, name: /Vereinsverwaltung/i })).toBeVisible({
timeout: 5000,
});
await page.screenshot({ path: 'screenshots/04-vereine.png' });
console.log('✓ Vereine-Seite erreichbar');
});
test('5. Desktop-Sidebar sichtbar (Desktop)', async ({ page }) => {
// Desktop-Viewport
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
// Prüfe ob Desktop-Sidebar existiert
const sidebar = page.locator('.desktop-sidebar');
await expect(sidebar).toBeVisible({ timeout: 5000 });
await page.screenshot({ path: 'screenshots/05-desktop-sidebar.png' });
console.log('✓ Desktop-Sidebar sichtbar');
});
test('6. Bottom-Nav sichtbar (Mobile)', async ({ page }) => {
// Mobile-Viewport
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
// Prüfe ob Bottom-Nav existiert
const bottomNav = page.locator('.bottom-nav');
await expect(bottomNav).toBeVisible({ timeout: 5000 });
await page.screenshot({ path: 'screenshots/06-mobile-bottom-nav.png' });
console.log('✓ Bottom-Nav sichtbar');
});
test('7. Session-Persistenz nach Reload', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await expect(
page.locator('.app-main').getByRole('heading', { level: 1, name: 'Dashboard' }),
).toBeVisible({ timeout: 10000 });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle');
// Auth lädt erst nach Spinner nicht auf /login stranden (stabiler als Button „Login“-Tab auf Login-Screen)
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 20000 });
await expect(page).not.toHaveURL(/\/login(?:\/|$|\?|#)/, { timeout: 20000 });
await expect(
page.locator('.app-main').getByRole('heading', { level: 1, name: 'Dashboard' }),
).toBeVisible({
timeout: 20000,
});
await page.screenshot({ path: 'screenshots/07-nach-reload.png' });
console.log('✓ Session bleibt nach Reload erhalten');
});
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
await page.waitForLoadState('networkidle');
// Coach-Sitzungsdaten simulieren (wie sie TrainingCoachPage schreibt)
await page.evaluate(() => {
sessionStorage.setItem('sj_coach_step_42', '3');
sessionStorage.setItem('sj_coach_deltas_42', '[{"id":1}]');
sessionStorage.setItem('sj_coach_debrief_42', '0');
sessionStorage.setItem('fremd_anderer_key', 'muss_erhalten_bleiben');
});
const vorLogout = await page.evaluate(() => ({
step: sessionStorage.getItem('sj_coach_step_42'),
fremd: sessionStorage.getItem('fremd_anderer_key'),
}));
expect(vorLogout.step).toBe('3');
expect(vorLogout.fremd).toBe('muss_erhalten_bleiben');
// App.jsx DesktopSidebar-Logout zeigt confirm() — in Playwright headless akzeptieren
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Abmelden' }).click();
await page.waitForLoadState('networkidle');
const nachLogout = await page.evaluate(() => ({
step: sessionStorage.getItem('sj_coach_step_42'),
deltas: sessionStorage.getItem('sj_coach_deltas_42'),
debrief: sessionStorage.getItem('sj_coach_debrief_42'),
fremd: sessionStorage.getItem('fremd_anderer_key'),
token: localStorage.getItem('authToken'),
}));
expect(nachLogout.step).toBeNull();
expect(nachLogout.deltas).toBeNull();
expect(nachLogout.debrief).toBeNull();
expect(nachLogout.fremd).toBe('muss_erhalten_bleiben');
expect(nachLogout.token).toBeNull();
await page.screenshot({ path: 'screenshots/p12-session-storage-nach-logout.png' });
console.log('✓ P-12: sj_coach_* entfernt, Fremd-Key erhalten, authToken entfernt');
});
// P-01: Rechtstextseiten öffentliche Routen ohne Auth
const LEGAL_ROUTES = [
{ path: '/impressum', label: 'Impressum' },
{ path: '/datenschutz', label: 'Datenschutz' },
{ path: '/nutzungsbedingungen', label: 'Nutzungsbedingungen' },
{ path: '/medienrichtlinie', label: 'Medienrichtlinie' },
];
for (const route of LEGAL_ROUTES) {
test(`P-01: ${route.label} ohne Auth erreichbar und enthält Platzhalterhinweis`, async ({ page }) => {
// Direkt aufrufen ohne Login
await page.goto(route.path);
await page.waitForLoadState('networkidle');
// Seite ist erreichbar (kein Redirect zur Login-Seite)
expect(page.url()).toContain(route.path);
// Platzhalterhinweis sichtbar
const hinweis = await page.getByText('MUSTER / PLATZHALTER').first();
await expect(hinweis).toBeVisible();
// Seitentitel korrekt
await expect(page.getByRole('heading', { level: 1 })).toContainText(route.label);
// Reload funktioniert
await page.reload();
await page.waitForLoadState('networkidle');
expect(page.url()).toContain(route.path);
console.log(`✓ P-01: ${route.label} ohne Auth erreichbar, Platzhalter sichtbar, Reload OK`);
});
}
test('P-01: Login-Seite enthält Links zu allen vier Rechtstextseiten', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle');
for (const route of LEGAL_ROUTES) {
const link = page.locator(`a[href="${route.path}"]`);
await expect(link).toBeVisible();
}
console.log('✓ P-01: Login-Seite alle vier Rechtstext-Links vorhanden');
});
// P-01b: Rechtliches über Einstellungen (Mobile/PWA-Erreichbarkeit)
async function gotoAuthenticated(page, path) {
await page.goto(path);
await page.waitForLoadState('networkidle');
// After full-page reload the app re-checks auth from localStorage; wait for spinner to clear
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 15000 });
}
test('P-01b: Einstellungen enthält Link zu Rechtliches', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/settings');
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Einstellungen' })).toBeVisible({ timeout: 8000 });
const link = page.locator('.app-main a[href="/settings/legal"]');
await expect(link).toBeVisible();
console.log('✓ P-01b: Einstellungen enthält Link zu /settings/legal');
});
test('P-01b: /settings/legal enthält Links zu allen vier Rechtstextseiten', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/settings/legal');
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtliches' })).toBeVisible({ timeout: 8000 });
for (const route of LEGAL_ROUTES) {
const link = page.locator(`.app-main a[href="${route.path}"]`);
await expect(link).toBeVisible();
}
console.log('✓ P-01b: /settings/legal Überschrift + alle vier Rechtstext-Links vorhanden');
});
test('P-01b: Jeder Rechtstext-Link aus /settings/legal führt zur korrekten Route', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
for (const route of LEGAL_ROUTES) {
await gotoAuthenticated(page, '/settings/legal');
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtliches' })).toBeVisible({ timeout: 8000 });
await page.locator(`.app-main a[href="${route.path}"]`).click();
await page.waitForLoadState('networkidle');
expect(page.url()).toContain(route.path);
await expect(page.getByRole('heading', { level: 1 })).toContainText(route.label);
console.log(`✓ P-01b: ${route.label} Link aus /settings/legal korrekt`);
}
});
// P-01c: Admin-konfigurierbare Rechtstexte
test('P-01c: Rechtstextseiten zeigen Platzhalter-Banner wenn kein published-Dokument', async ({ page }) => {
// Keine Auth — public route
await page.goto('/impressum');
await page.waitForLoadState('networkidle');
// Seite erreichbar (kein Redirect zu /login)
expect(page.url()).toContain('/impressum');
// Spinner weg, dann entweder Platzhalter-Banner oder API-Inhalt (beides OK)
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 5000 });
console.log('✓ P-01c: /impressum lädt ohne Fehler (API-fetch mit Fallback)');
});
test('P-01c: /admin/legal-documents erreichbar für Superadmin', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/admin/legal-documents');
// Superadmin sieht die Seite; normaler Admin landet auf 403 (PlatformAdminRoute)
// Test läuft mit Superadmin-Account (TEST_EMAIL), also Seite sichtbar
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtstexte verwalten' })).toBeVisible({ timeout: 8000 });
await page.screenshot({ path: 'screenshots/p01c-admin-legal-documents.png' });
console.log('✓ P-01c: /admin/legal-documents erreichbar, Überschrift sichtbar');
});
test('P-01c: Admin-Nav enthält Link zu Rechtstexten', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/admin/hierarchy');
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
const link = page.locator('a[href="/admin/legal-documents"]');
await expect(link).toBeVisible({ timeout: 5000 });
console.log('✓ P-01c: Admin-Nav enthält Link /admin/legal-documents');
});
// ── P-06: Upload-Einwilligungsdialog ────────────────────────────────────────
test('P-06a: Medienbibliothek lädt ohne Fehler', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/media');
await expect(page.locator('h1')).toContainText('Medienbibliothek', { timeout: 8000 });
await page.screenshot({ path: 'screenshots/p06a-media-library.png' });
console.log('✓ P-06a: Medienbibliothek erreichbar');
});
test('P-06b: Rechte-Dialog erscheint bei Dateiauswahl', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/media');
await expect(page.locator('h1')).toContainText('Medienbibliothek', { timeout: 8000 });
// Datei-Upload simulieren (ohne echte Datei Datei-Input finden und Datei setzen)
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles({
name: 'test.png',
mimeType: 'image/png',
buffer: Buffer.from('PNG-Testinhalt'),
});
// Rechte-Dialog muss erscheinen
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="dialog"]')).toContainText('Rechte-Erklärung');
await expect(page.locator('[role="dialog"]')).toContainText('VORLÄUFIG');
await page.screenshot({ path: 'screenshots/p06b-rights-dialog.png' });
console.log('✓ P-06b: Rechte-Dialog erscheint bei Dateiauswahl');
});
test('P-06c: Dialog-Abbrechen bricht Upload ab', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/media');
await expect(page.locator('h1')).toContainText('Medienbibliothek', { timeout: 8000 });
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles({
name: 'test.png',
mimeType: 'image/png',
buffer: Buffer.from('PNG-Testinhalt'),
});
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
await page.locator('[role="dialog"] button:has-text("Abbrechen")').click();
// Dialog geschlossen, kein Upload-Fortschrittsbalken
await expect(page.locator('[role="dialog"]')).toHaveCount(0, { timeout: 3000 });
await page.screenshot({ path: 'screenshots/p06c-dialog-cancel.png' });
console.log('✓ P-06c: Dialog-Abbrechen schließt Dialog ohne Upload');
});
test('P-06d: Dialog-Bestätigung ohne Pflichtfelder zeigt Fehler', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/media');
await expect(page.locator('h1')).toContainText('Medienbibliothek', { timeout: 8000 });
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles({
name: 'test.png',
mimeType: 'image/png',
buffer: Buffer.from('PNG-Testinhalt'),
});
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
// Ohne Felder ausfüllen direkt bestätigen
await page.locator('[role="dialog"] button:has-text("Bestätigen")').click();
// Fehlermeldung im Dialog
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 2000 });
// Fehlermeldung muss sichtbar sein
await expect(dialog.locator('p[style*="danger"], p[style*="color: var(--danger)"]')).toBeVisible({ timeout: 2000 });
await page.screenshot({ path: 'screenshots/p06d-dialog-validation.png' });
console.log('✓ P-06d: Dialog zeigt Fehler ohne Pflichtfelder');
});
test('P-06e: API-Endpoint /api/admin/media-rights/legacy-summary erreichbar (Superadmin)', async ({ request }) => {
// Superadmin-Login via API
const loginRes = await request.post('/api/auth/login', {
data: { email: TEST_EMAIL, password: TEST_PASSWORD },
});
if (!loginRes.ok()) {
console.log('⚠ P-06e: Login fehlgeschlagen Test übersprungen');
return;
}
const loginData = await loginRes.json();
const token = loginData.token;
if (!token) {
console.log('⚠ P-06e: Kein Token Test übersprungen');
return;
}
const res = await request.get('/api/admin/media-rights/legacy-summary', {
headers: { 'X-Auth-Token': token },
});
// Endpoint existiert (200 oder 403 wenn kein Superadmin — aber nicht 404/500)
expect([200, 403]).toContain(res.status());
if (res.status() === 200) {
const data = await res.json();
expect(data).toHaveProperty('total_active_assets');
expect(data).toHaveProperty('legacy_unreviewed');
console.log(`✓ P-06e: Legacy-Summary: ${data.legacy_unreviewed} von ${data.total_active_assets} Medien`);
} else {
console.log('✓ P-06e: Endpoint existiert (403 erwartet für Nicht-Superadmin)');
}
});
test('8. Keine kritischen Console-Fehler', async ({ page }) => {
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await login(page);
await page.waitForLoadState('networkidle');
// Filtere unkritische Fehler
const kritisch = errors.filter(e =>
!e.includes('favicon') &&
!e.includes('sourceMap') &&
!e.includes('404') &&
!e.includes('vite.svg')
);
if (kritisch.length > 0) {
console.log('⚠ Console-Fehler:', kritisch.join(', '));
} else {
console.log('✓ Keine kritischen Console-Fehler');
}
expect(kritisch.length).toBe(0);
});