/** * E2E Tests — Page Playlist partagée (/playlists/shared/:token) * * Tests exhaustifs : chargement, fonctionnalités, sécurité, a11y, i18n, responsive, régression. */ import { test, expect, type Page } from '@playwright/test'; import { CONFIG, loginViaAPI } from './helpers'; // --------------------------------------------------------------------------- // HELPERS // --------------------------------------------------------------------------- const VALID_SHARE_TOKEN_KEY = 'VALID_SHARE_TOKEN'; const PLAYLIST_ID_KEY = 'PLAYLIST_ID'; /** Create a share token via the API (must be authenticated as admin). */ async function createShareToken(page: Page): Promise<{ token: string; playlistId: string }> { const base = CONFIG.baseURL; // Login as admin await loginViaAPI(page, 'admin@veza.music', 'Admin123!'); // Get admin's playlists const playlistsRes = await page.request.get(`${base}/api/v1/playlists?user_id=me&limit=50`); let playlists = []; if (playlistsRes.ok()) { const body = await playlistsRes.json(); playlists = body?.data?.playlists ?? []; } // If no admin playlists with tracks, use the public list and pick one with tracks if (playlists.length === 0 || playlists.every((p: { track_count: number }) => p.track_count === 0)) { const allRes = await page.request.get(`${base}/api/v1/playlists?limit=50`); if (allRes.ok()) { const body = await allRes.json(); playlists = (body?.data?.playlists ?? []).filter((p: { track_count: number }) => p.track_count > 0); } } // Find a playlist with tracks that admin owns const adminRes = await page.request.get(`${base}/api/v1/auth/me`); const adminBody = await adminRes.json(); const adminId = adminBody?.data?.id; let targetPlaylist = playlists.find( (p: { user_id: string; track_count: number }) => p.user_id === adminId && p.track_count > 0, ); if (!targetPlaylist) { targetPlaylist = playlists.find((p: { track_count: number }) => p.track_count > 0); } if (!targetPlaylist) { throw new Error('No playlist with tracks found for share token creation'); } // Get CSRF token const csrfRes = await page.request.get(`${base}/api/v1/csrf-token`); const csrfBody = await csrfRes.json(); const csrfToken = csrfBody?.data?.csrf_token; // Create share link const shareRes = await page.request.post(`${base}/api/v1/playlists/${targetPlaylist.id}/share`, { headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' }, }); if (!shareRes.ok()) { throw new Error(`Failed to create share link: ${shareRes.status()} ${await shareRes.text()}`); } const shareBody = await shareRes.json(); return { token: shareBody.data.share_token, playlistId: targetPlaylist.id, }; } /** Navigate to the shared playlist page and wait for it to load. */ async function goToSharedPlaylist(page: Page, token: string): Promise { await page.goto(`${CONFIG.baseURL}/playlists/shared/${token}`, { waitUntil: 'domcontentloaded', timeout: 30_000, }); await page.waitForLoadState('networkidle').catch(() => {}); // Wait for content to render (either playlist or not-found) await page.waitForTimeout(2_000); } // --------------------------------------------------------------------------- // SETUP // --------------------------------------------------------------------------- let validShareToken = ''; let playlistId = ''; test.beforeAll(async ({ browser }) => { const page = await browser.newPage(); try { const result = await createShareToken(page); validShareToken = result.token; playlistId = result.playlistId; } finally { await page.close(); } }); // --------------------------------------------------------------------------- // TESTS // --------------------------------------------------------------------------- test.describe('Playlist partagée (/playlists/shared/:token)', () => { test.describe('Chargement & Rendu', () => { test('la page se charge sans erreur avec un token valide', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); // Should NOT redirect to /login expect(page.url()).toContain('/playlists/shared/'); expect(page.url()).not.toContain('/login'); // Title should be visible await expect(page.locator('h1')).toBeVisible(); }); test('la page est accessible sans authentification', async ({ page }) => { // Ensure we're not authenticated (fresh context) await page.context().clearCookies(); await goToSharedPlaylist(page, validShareToken); expect(page.url()).toContain('/playlists/shared/'); expect(page.url()).not.toContain('/login'); await expect(page.locator('h1')).toBeVisible(); }); test('tous les éléments visuels sont présents', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); // Cover / music icon await expect(page.locator('img, svg').first()).toBeVisible(); // Title await expect(page.locator('h1')).toBeVisible(); // Buttons await expect(page.getByRole('button', { name: /play all/i })).toBeVisible(); await expect(page.getByRole('button', { name: /shuffle/i })).toBeVisible(); await expect(page.getByRole('button', { name: /copy link/i })).toBeVisible(); // Track list await expect(page.getByRole('list')).toBeVisible(); // Track items const items = page.getByRole('listitem'); await expect(items.first()).toBeVisible(); }); test('le skeleton de chargement apparaît brièvement', async ({ page }) => { // Navigate with slow connection to catch skeleton await page.goto(`${CONFIG.baseURL}/playlists/shared/${validShareToken}`, { waitUntil: 'commit', }); // The page should not crash during loading await page.waitForTimeout(3_000); expect(page.url()).toContain('/playlists/shared/'); }); test('le GlobalPlayer est présent', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); await expect(page.getByRole('region', { name: /global player/i })).toBeVisible(); }); }); test.describe('Token invalide', () => { test('affiche "Playlist Not Found" pour un token inexistant', async ({ page }) => { await goToSharedPlaylist(page, 'nonexistent-token-abc123'); await expect(page.getByRole('heading', { name: /not found|introuvable/i })).toBeVisible(); }); test('pas de crash pour un token avec caractères spéciaux', async ({ page }) => { await goToSharedPlaylist(page, '%3Cscript%3Ealert(1)%3C%2Fscript%3E'); // Should show not found, not crash await expect(page.getByRole('heading', { name: /not found|introuvable/i })).toBeVisible(); }); test('pas de crash pour un token avec injection SQL', async ({ page }) => { await goToSharedPlaylist(page, "'; DROP TABLE playlists; --"); await expect(page.getByRole('heading', { name: /not found|introuvable/i })).toBeVisible(); }); test('le lien "Back to Library" pointe vers /library', async ({ page }) => { await goToSharedPlaylist(page, 'invalid-token'); const link = page.getByRole('link', { name: /back to library|retour/i }); await expect(link).toBeVisible(); await expect(link).toHaveAttribute('href', '/library'); }); }); test.describe('Fonctionnalités', () => { test('affiche les infos de la playlist (titre, description, nombre de tracks)', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); // Title const title = page.locator('h1'); await expect(title).toBeVisible(); const titleText = await title.textContent(); expect(titleText).toBeTruthy(); expect(titleText!.length).toBeGreaterThan(0); // Track count visible somewhere const bodyText = await page.textContent('body'); expect(bodyText).toMatch(/\d+\s*(track|piste|pista)/i); }); test('le bouton Play All charge la queue du player', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); await page.getByRole('button', { name: /play all/i }).click(); await page.waitForTimeout(1_000); // GlobalPlayer should show the first track name const playerRegion = page.getByRole('region', { name: /global player/i }); const playerText = await playerRegion.textContent(); // Player should have changed from default "Veza / Select a track" expect(playerText).not.toContain('Select a track'); }); test('le bouton Shuffle active le mode aléatoire', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); await page.getByRole('button', { name: /shuffle/i }).click(); await page.waitForTimeout(1_000); // Player should be loaded with a track const playerRegion = page.getByRole('region', { name: /global player/i }); const playerText = await playerRegion.textContent(); expect(playerText).not.toContain('Select a track'); }); test('le bouton Copy link copie l\'URL et affiche un toast', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); await page.getByRole('button', { name: /copy link/i }).click(); // Toast should appear await expect(page.getByRole('status').first()).toBeVisible({ timeout: 5_000 }); }); test('la track list affiche les tracks en lecture seule', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); const items = page.getByRole('listitem'); const count = await items.count(); expect(count).toBeGreaterThan(0); // No drag handles visible (read-only) const dragHandles = page.locator('[data-testid="drag-handle"], .grip-handle'); await expect(dragHandles).toHaveCount(0); }); test('pas de bouton de suppression de track', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); // No remove/delete buttons const removeButtons = page.locator('button:has-text("Remove"), button:has-text("Delete"), button[aria-label*="remove"], button[aria-label*="supprimer"]'); await expect(removeButtons).toHaveCount(0); }); }); test.describe('Sécurité', () => { test('pas de fuite de tokens sensibles dans le DOM', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); const html = await page.content(); // Should not contain access_token, refresh_token or CSRF token in the DOM expect(html).not.toMatch(/access_token/); expect(html).not.toMatch(/refresh_token/); }); test('XSS via paramètre token ne s\'exécute pas', async ({ page }) => { let alertTriggered = false; page.on('dialog', (dialog) => { alertTriggered = true; dialog.dismiss(); }); await goToSharedPlaylist(page, ''); expect(alertTriggered).toBe(false); }); }); test.describe('Accessibilité', () => { test('focus order logique via Tab', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); // Skip to content link should be first focusable await page.keyboard.press('Tab'); const focused = page.locator(':focus'); const tagName = await focused.evaluate((el) => el.tagName.toLowerCase()); expect(tagName).toBe('a'); // Skip to content link }); test('les boutons ont des accessible names', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); const playAll = page.getByRole('button', { name: /play all/i }); await expect(playAll).toBeVisible(); const shuffle = page.getByRole('button', { name: /shuffle/i }); await expect(shuffle).toBeVisible(); const copyLink = page.getByRole('button', { name: /copy link/i }); await expect(copyLink).toBeVisible(); }); test('la liste des tracks a un role="list" avec aria-label', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); const list = page.getByRole('list'); await expect(list).toBeVisible(); const label = await list.getAttribute('aria-label'); expect(label).toBeTruthy(); }); }); test.describe('i18n', () => { test('pas de clés i18n brutes affichées', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); const bodyText = await page.textContent('body') ?? ''; // i18n keys look like "playlists.shared.xxx" or "common.xxx" expect(bodyText).not.toMatch(/playlists\.shared\.\w+/); expect(bodyText).not.toMatch(/common\.\w+/); }); test('pas de mélange FR/EN dans la page', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); const bodyText = await page.textContent('body') ?? ''; // If English UI, should not contain known French-only phrases if (bodyText.includes('Play All')) { expect(bodyText).not.toContain('Aucun track'); expect(bodyText).not.toContain('Ajoutez des tracks'); } }); }); test.describe('Responsive', () => { test('mobile 375px — pas de débordement horizontal', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await goToSharedPlaylist(page, validShareToken); const bodyWidth = await page.evaluate(() => document.body.scrollWidth); expect(bodyWidth).toBeLessThanOrEqual(375 + 5); // small tolerance }); test('tablet 768px — la page se charge correctement', async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await goToSharedPlaylist(page, validShareToken); await expect(page.locator('h1')).toBeVisible(); await expect(page.getByRole('button', { name: /play all/i })).toBeVisible(); }); test('desktop 1280px — layout complet', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await goToSharedPlaylist(page, validShareToken); await expect(page.locator('h1')).toBeVisible(); await expect(page.getByRole('button', { name: /play all/i })).toBeVisible(); await expect(page.getByRole('button', { name: /shuffle/i })).toBeVisible(); await expect(page.getByRole('button', { name: /copy link/i })).toBeVisible(); }); }); test.describe('Régression', () => { test('[BUG#1] API response shape — la playlist s\'affiche avec un token valide', async ({ page }) => { // Was: getByShareToken returned undefined because response.data.playlist was undefined await goToSharedPlaylist(page, validShareToken); // Must show playlist content, not "Not Found" const notFound = page.getByRole('heading', { name: /not found|introuvable/i }); await expect(notFound).not.toBeVisible(); // Must show track list const list = page.getByRole('list'); await expect(list).toBeVisible(); }); test('[BUG#2] unauthenticated access — pas de redirect vers /login', async ({ page }) => { await page.context().clearCookies(); await goToSharedPlaylist(page, validShareToken); expect(page.url()).not.toContain('/login'); await expect(page.locator('h1')).toBeVisible(); }); test('[BUG#3] lien Back to Library — pointe vers /library', async ({ page }) => { await goToSharedPlaylist(page, 'invalid-token'); const link = page.getByRole('link', { name: /back to library|retour/i }); await expect(link).toHaveAttribute('href', '/library'); }); test('[BUG#4] boutons Play All et Shuffle — fonctionnels', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); // Play All should trigger player await page.getByRole('button', { name: /play all/i }).click(); await page.waitForTimeout(1_000); const playerRegion = page.getByRole('region', { name: /global player/i }); const playerText = await playerRegion.textContent(); expect(playerText).not.toContain('Select a track'); }); test('[BUG#5] i18n — pas de textes hardcodés en français dans le track list', async ({ page }) => { await goToSharedPlaylist(page, validShareToken); const bodyText = await page.textContent('body') ?? ''; // These were hardcoded French defaults before the fix expect(bodyText).not.toContain('Aucun track dans cette playlist'); expect(bodyText).not.toContain('Ajoutez des tracks'); }); }); });