2026-04-22 16:33:03 +02:00
|
|
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
|
|
|
|
|
|
|
|
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
|
|
|
|
|
const TEST_PASSWORD = process.env.TEST_PASSWORD || '12345678';
|
|
|
|
|
|
|
2026-04-29 12:35:30 +02:00
|
|
|
|
/** Primärer Submit auf der Login-Seite (nicht den Tab "Login" vs. "Registrieren"). */
|
|
|
|
|
|
async function submitLoginForm(page) {
|
|
|
|
|
|
await page.getByRole('button', { name: 'Anmelden' }).click();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 16:33:03 +02:00
|
|
|
|
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);
|
2026-04-29 12:35:30 +02:00
|
|
|
|
await submitLoginForm(page);
|
2026-05-10 11:36:10 +02:00
|
|
|
|
// Wait until auth is complete: URL leaves /login and Dashboard is rendered
|
|
|
|
|
|
await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
|
2026-04-22 16:33:03 +02:00
|
|
|
|
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);
|
2026-04-29 12:35:30 +02:00
|
|
|
|
await submitLoginForm(page);
|
2026-04-22 16:33:03 +02:00
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
|
|
|
2026-04-29 12:35:30 +02:00
|
|
|
|
// Nach Login soll der Tab "Login" (Moduswahl) verschwinden — nicht der Submit "Anmelden"
|
2026-04-22 16:33:03 +02:00
|
|
|
|
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 });
|
|
|
|
|
|
|
2026-05-07 07:51:24 +02:00
|
|
|
|
// 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 });
|
2026-04-22 16:33:03 +02:00
|
|
|
|
|
|
|
|
|
|
await page.screenshot({ path: 'screenshots/02-dashboard.png' });
|
|
|
|
|
|
console.log('✓ Dashboard OK');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('3. Navigation zu Übungen', async ({ page }) => {
|
|
|
|
|
|
await login(page);
|
|
|
|
|
|
|
2026-05-14 08:17:15 +02:00
|
|
|
|
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
|
|
|
|
|
|
2026-04-29 13:41:17 +02:00
|
|
|
|
// Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
|
|
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
|
|
|
|
|
2026-05-14 08:17:15 +02:00
|
|
|
|
// Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
|
|
|
|
|
|
const exercisesLink = page.locator('.bottom-nav').getByRole('link', { name: /Übungen/i });
|
|
|
|
|
|
await Promise.all([
|
|
|
|
|
|
page.waitForURL(
|
|
|
|
|
|
(u) => {
|
|
|
|
|
|
const path = u.pathname.replace(/\/$/, '') || '/'
|
|
|
|
|
|
return path === '/exercises'
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 15000 },
|
|
|
|
|
|
),
|
|
|
|
|
|
exercisesLink.click(),
|
|
|
|
|
|
]);
|
2026-04-22 16:33:03 +02:00
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
|
|
|
2026-05-14 08:17:15 +02:00
|
|
|
|
// Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
|
|
|
|
|
|
const main = page.locator('.app-main');
|
|
|
|
|
|
await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
|
|
|
|
|
|
timeout: 10000,
|
|
|
|
|
|
});
|
2026-04-22 16:33:03 +02:00
|
|
|
|
|
|
|
|
|
|
await page.screenshot({ path: 'screenshots/03-uebungen.png' });
|
|
|
|
|
|
console.log('✓ Übungen-Seite erreichbar');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('4. Navigation zu Vereine', async ({ page }) => {
|
|
|
|
|
|
await login(page);
|
2026-04-29 13:41:17 +02:00
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
2026-04-22 16:33:03 +02:00
|
|
|
|
|
2026-04-29 13:41:17 +02:00
|
|
|
|
await page.locator('.bottom-nav a[href="/clubs"]').click();
|
2026-04-22 16:33:03 +02:00
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
|
|
|
2026-05-05 23:03:40 +02:00
|
|
|
|
// 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,
|
|
|
|
|
|
});
|
2026-04-22 16:33:03 +02:00
|
|
|
|
|
|
|
|
|
|
await page.screenshot({ path: 'screenshots/04-vereine.png' });
|
|
|
|
|
|
console.log('✓ Vereine-Seite erreichbar');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-29 12:35:30 +02:00
|
|
|
|
test('5. Desktop-Sidebar sichtbar (Desktop)', async ({ page }) => {
|
2026-04-22 16:33:03 +02:00
|
|
|
|
// 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 });
|
2026-05-05 23:03:40 +02:00
|
|
|
|
await expect(
|
|
|
|
|
|
page.locator('.app-main').getByRole('heading', { level: 1, name: 'Dashboard' }),
|
|
|
|
|
|
).toBeVisible({ timeout: 10000 });
|
2026-04-22 16:33:03 +02:00
|
|
|
|
|
2026-04-29 13:41:17 +02:00
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
2026-04-22 16:33:03 +02:00
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
|
|
|
2026-04-29 13:41:17 +02:00
|
|
|
|
// 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 });
|
2026-05-05 23:03:40 +02:00
|
|
|
|
await expect(page).not.toHaveURL(/\/login(?:\/|$|\?|#)/, { timeout: 20000 });
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
page.locator('.app-main').getByRole('heading', { level: 1, name: 'Dashboard' }),
|
|
|
|
|
|
).toBeVisible({
|
|
|
|
|
|
timeout: 20000,
|
2026-04-29 13:41:17 +02:00
|
|
|
|
});
|
2026-04-22 16:33:03 +02:00
|
|
|
|
|
|
|
|
|
|
await page.screenshot({ path: 'screenshots/07-nach-reload.png' });
|
|
|
|
|
|
console.log('✓ Session bleibt nach Reload erhalten');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-14 06:49:15 +02:00
|
|
|
|
/**
|
2026-05-14 07:47:27 +02:00
|
|
|
|
* Refaktor Phase 2 (Dashboard): Kurzüberblick per GET /api/dashboard/kpis; genau zwei GET /api/training-units (Übersicht).
|
2026-05-14 06:49:15 +02:00
|
|
|
|
* Production-ähnlicher Build empfohlen (kein React StrictMode-Doppel-Mount im lokalen Vite-Dev).
|
|
|
|
|
|
*/
|
2026-05-14 07:47:27 +02:00
|
|
|
|
test('8. Dashboard API-Budget nach Reload (profiles/me, training-units, dashboard/kpis)', async ({ page }) => {
|
2026-05-14 06:49:15 +02:00
|
|
|
|
await login(page);
|
|
|
|
|
|
|
|
|
|
|
|
let profilesMe = 0;
|
|
|
|
|
|
let trainingUnits = 0;
|
2026-05-14 07:47:27 +02:00
|
|
|
|
let dashboardKpis = 0;
|
2026-05-14 06:49:15 +02:00
|
|
|
|
|
|
|
|
|
|
const onRequest = (request) => {
|
|
|
|
|
|
if (request.method() !== 'GET') return;
|
|
|
|
|
|
let pathname = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
pathname = new URL(request.url()).pathname;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pathname === '/api/profiles/me') profilesMe += 1;
|
|
|
|
|
|
if (pathname === '/api/training-units') trainingUnits += 1;
|
2026-05-14 07:47:27 +02:00
|
|
|
|
if (pathname === '/api/dashboard/kpis') dashboardKpis += 1;
|
2026-05-14 06:49:15 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
page.on('request', onRequest);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await page.reload({ waitUntil: 'networkidle' });
|
|
|
|
|
|
|
|
|
|
|
|
const main = page.locator('.app-main');
|
|
|
|
|
|
await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
|
|
|
|
|
|
timeout: 15000,
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Nächste Termine' })).toBeVisible({
|
|
|
|
|
|
timeout: 20000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(profilesMe).toBe(1);
|
2026-05-14 07:47:27 +02:00
|
|
|
|
expect(trainingUnits).toBe(2);
|
|
|
|
|
|
expect(dashboardKpis).toBe(1);
|
2026-05-14 06:49:15 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
page.off('request', onRequest);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 07:47:27 +02:00
|
|
|
|
console.log('✓ Dashboard API-Budget: 1× profiles/me, 2× training-units, 1× dashboard/kpis');
|
2026-05-14 06:49:15 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-10 09:08:28 +02:00
|
|
|
|
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');
|
|
|
|
|
|
|
2026-05-10 09:17:19 +02:00
|
|
|
|
// App.jsx DesktopSidebar-Logout zeigt confirm() — in Playwright headless akzeptieren
|
|
|
|
|
|
page.once('dialog', dialog => dialog.accept());
|
2026-05-10 09:08:28 +02:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-10 09:41:45 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
|
|
// Seitentitel korrekt
|
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toContainText(route.label);
|
|
|
|
|
|
|
2026-05-11 09:39:15 +02:00
|
|
|
|
// Platzhalterhinweis sichtbar – nur wenn noch kein echtes Dokument veröffentlicht wurde
|
|
|
|
|
|
const hasPlaceholder = await page.getByText('MUSTER / PLATZHALTER').first().isVisible().catch(() => false);
|
|
|
|
|
|
if (hasPlaceholder) {
|
|
|
|
|
|
console.log(`✓ P-01: ${route.label} – Platzhalter sichtbar`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`✓ P-01: ${route.label} – echtes Dokument veröffentlicht (kein Platzhalter)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 09:41:45 +02:00
|
|
|
|
// Reload funktioniert
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
|
|
expect(page.url()).toContain(route.path);
|
|
|
|
|
|
|
2026-05-11 09:39:15 +02:00
|
|
|
|
console.log(`✓ P-01: ${route.label} – ohne Auth erreichbar, Reload OK`);
|
2026-05-10 09:41:45 +02:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-10 10:51:20 +02:00
|
|
|
|
// P-01b: Rechtliches über Einstellungen (Mobile/PWA-Erreichbarkeit)
|
|
|
|
|
|
|
2026-05-10 11:36:10 +02:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 10:51:20 +02:00
|
|
|
|
test('P-01b: Einstellungen enthält Link zu Rechtliches', async ({ page }) => {
|
|
|
|
|
|
await login(page);
|
2026-05-10 11:36:10 +02:00
|
|
|
|
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
2026-05-10 10:51:20 +02:00
|
|
|
|
|
2026-05-10 11:36:10 +02:00
|
|
|
|
await gotoAuthenticated(page, '/settings');
|
|
|
|
|
|
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Einstellungen' })).toBeVisible({ timeout: 8000 });
|
2026-05-10 10:51:20 +02:00
|
|
|
|
|
2026-05-10 11:36:10 +02:00
|
|
|
|
const link = page.locator('.app-main a[href="/settings/legal"]');
|
2026-05-10 10:51:20 +02:00
|
|
|
|
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);
|
2026-05-10 11:36:10 +02:00
|
|
|
|
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
2026-05-10 10:51:20 +02:00
|
|
|
|
|
2026-05-10 11:36:10 +02:00
|
|
|
|
await gotoAuthenticated(page, '/settings/legal');
|
|
|
|
|
|
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtliches' })).toBeVisible({ timeout: 8000 });
|
2026-05-10 10:51:20 +02:00
|
|
|
|
|
|
|
|
|
|
for (const route of LEGAL_ROUTES) {
|
2026-05-10 11:36:10 +02:00
|
|
|
|
const link = page.locator(`.app-main a[href="${route.path}"]`);
|
2026-05-10 10:51:20 +02:00
|
|
|
|
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);
|
2026-05-10 11:36:10 +02:00
|
|
|
|
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
2026-05-10 10:51:20 +02:00
|
|
|
|
|
|
|
|
|
|
for (const route of LEGAL_ROUTES) {
|
2026-05-10 11:36:10 +02:00
|
|
|
|
await gotoAuthenticated(page, '/settings/legal');
|
|
|
|
|
|
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtliches' })).toBeVisible({ timeout: 8000 });
|
2026-05-10 10:51:20 +02:00
|
|
|
|
|
2026-05-10 11:36:10 +02:00
|
|
|
|
await page.locator(`.app-main a[href="${route.path}"]`).click();
|
2026-05-10 10:51:20 +02:00
|
|
|
|
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`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-10 11:53:07 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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
|
|
|
|
// ── 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)');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-14 06:49:15 +02:00
|
|
|
|
test('9. Keine kritischen Console-Fehler', async ({ page }) => {
|
2026-04-22 16:33:03 +02:00
|
|
|
|
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);
|
|
|
|
|
|
});
|