veza/tests/e2e/playlists-shared-token.spec.ts
senke 9a4c0d2af4 feat(web): update all features, stories, e2e tests, and auth interceptor
Update auth, playlists, tracks, search, profile, dashboard, player,
settings, and social features. Add e2e audit specs for all major pages.
Update ESLint config, vitest config, and route configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:16:36 +02:00

423 lines
16 KiB
TypeScript

/**
* 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<void> {
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, '<script>alert("xss")</script>');
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');
});
});
});