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>
423 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|