veza/tests/e2e/playlists-detail-audit.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

430 lines
16 KiB
TypeScript

/**
* E2E tests — Playlist Detail Page (/playlists/:id)
* Covers: loading, features, security, a11y, i18n, responsive, regression
*/
import { test, expect } from '@playwright/test';
import { loginViaAPI, CONFIG, navigateTo } from './helpers';
// We need a valid playlist ID. The seeded database has playlists owned by various users.
// We'll navigate to /playlists first to discover a valid ID dynamically.
let validPlaylistId: string;
test.describe('Playlist Detail (/playlists/:id)', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test.describe('Chargement & Rendu', () => {
test('page loads without crash @critical', async ({ page }) => {
// Navigate to playlists list to find a valid playlist
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
// Find a playlist link
const playlistLink = page.locator('a[href^="/playlists/"]').first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (href && href !== '/playlists/favoris') {
validPlaylistId = href.replace('/playlists/', '');
await navigateTo(page, `/playlists/${validPlaylistId}`);
} else {
// Fallback: try a known seeded playlist page
await navigateTo(page, '/playlists');
// If no playlists exist, the test still validates page load
return;
}
// Page should load with main content
const main = page.locator('main');
await expect(main).toBeVisible({ timeout: 15_000 });
// Should not show error state
const notFound = page.getByText('Playlist Not Found');
await expect(notFound).not.toBeVisible();
});
test('no JS errors in console on valid playlist', async ({ page }) => {
const jsErrors: string[] = [];
page.on('pageerror', (error) => {
jsErrors.push(error.message);
});
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (href) {
await navigateTo(page, href);
await page.waitForLoadState('networkidle').catch(() => {});
// Filter out known non-critical errors (React DevTools, etc.)
const criticalErrors = jsErrors.filter(
(e) => !e.includes('React DevTools') && !e.includes('Download the React DevTools'),
);
expect(criticalErrors).toHaveLength(0);
}
});
test('all key visual elements are present', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
// Heading (playlist title)
const heading = page.locator('h1');
await expect(heading).toBeVisible({ timeout: 10_000 });
// Play All button
const playAll = page.getByRole('button', { name: /play all/i });
await expect(playAll).toBeVisible();
// Shuffle button
const shuffle = page.getByRole('button', { name: /shuffle/i });
await expect(shuffle).toBeVisible();
// Tabs
const tracksTab = page.getByRole('tab', { name: /tracks/i });
await expect(tracksTab).toBeVisible();
});
});
test.describe('Fonctionnalites', () => {
test('track list displays with 1-based numbering', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
// First track should be numbered 1 (not 0)
const firstTrack = page.getByRole('listitem').filter({ hasText: /^1/ }).first();
// Also check via aria-label pattern "Track 1: ..."
const track1 = page.locator('[aria-label^="Track 1:"]');
const isVisible = await track1.isVisible({ timeout: 5_000 }).catch(() => false);
if (isVisible) {
await expect(track1).toBeVisible();
} else {
// Fallback: check that "Track 0:" does NOT appear
const track0 = page.locator('[aria-label^="Track 0:"]');
await expect(track0).not.toBeVisible();
}
});
test('tabs switch content correctly', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
// Tracks tab should be selected by default
const tracksTab = page.getByRole('tab', { name: /tracks/i });
await expect(tracksTab).toHaveAttribute('aria-selected', 'true');
// Click Recommendations tab
const recoTab = page.getByRole('tab', { name: /recommendations/i });
if (await recoTab.isVisible().catch(() => false)) {
await recoTab.click();
await expect(recoTab).toHaveAttribute('aria-selected', 'true');
await expect(tracksTab).toHaveAttribute('aria-selected', 'false');
}
});
test('filter tracks input has aria-label', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
const filterInput = page.locator('input[aria-label]').filter({ hasText: '' });
const trackFilter = page.locator('input[placeholder]').first();
if (await trackFilter.isVisible().catch(() => false)) {
const ariaLabel = await trackFilter.getAttribute('aria-label');
expect(ariaLabel).toBeTruthy();
}
});
});
test.describe('Securite', () => {
test('invalid UUID shows Playlist Not Found', async ({ page }) => {
await navigateTo(page, '/playlists/00000000-0000-0000-0000-000000000000');
await page.waitForLoadState('networkidle').catch(() => {});
const notFound = page.getByText('Playlist Not Found');
await expect(notFound).toBeVisible({ timeout: 10_000 });
// Should have a "Back to Library" link
const backLink = page.getByRole('link', { name: /back to library/i });
await expect(backLink).toBeVisible();
});
test('XSS injection in ID is handled safely', async ({ page }) => {
const jsErrors: string[] = [];
page.on('pageerror', (error) => {
jsErrors.push(error.message);
});
await navigateTo(page, '/playlists/%3Cscript%3Ealert(1)%3C%2Fscript%3E');
await page.waitForLoadState('networkidle').catch(() => {});
// Should show not found, not execute script
const notFound = page.getByText('Playlist Not Found');
await expect(notFound).toBeVisible({ timeout: 10_000 });
// No JS errors from XSS
const xssErrors = jsErrors.filter((e) => e.includes('alert'));
expect(xssErrors).toHaveLength(0);
});
test('no sensitive data in URL or page source', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
const url = page.url();
// URL should not contain tokens, emails, or API keys
expect(url).not.toMatch(/token=/i);
expect(url).not.toMatch(/@.*\./);
expect(url).not.toMatch(/api[_-]?key/i);
});
});
test.describe('Accessibilite', () => {
test('track list items have descriptive aria-labels', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
// Track items should have aria-labels like "Track 1: Title"
const trackItems = page.locator('[role="listitem"][aria-label^="Track"]');
const count = await trackItems.count();
if (count > 0) {
const firstLabel = await trackItems.first().getAttribute('aria-label');
expect(firstLabel).toMatch(/^Track \d+: .+/);
}
});
test('action buttons have accessible names', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
// Play All and Shuffle should have accessible text
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();
});
test('playlist actions group has aria-label', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
const actionsGroup = page.locator('[role="group"][aria-label]');
if (await actionsGroup.isVisible().catch(() => false)) {
const label = await actionsGroup.getAttribute('aria-label');
expect(label).toBeTruthy();
// Should not be a raw i18n key
expect(label).not.toMatch(/^playlists\./);
}
});
});
test.describe('i18n', () => {
test('no raw i18n keys displayed on page', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
await page.waitForLoadState('networkidle').catch(() => {});
// Check that no raw i18n keys like "playlists.detail.xxx" are visible
const bodyText = await page.textContent('main') || '';
expect(bodyText).not.toMatch(/playlists\.detail\./);
expect(bodyText).not.toMatch(/playlists\.shared\./);
expect(bodyText).not.toMatch(/playlists\.actions\./);
expect(bodyText).not.toMatch(/playlists\.duplicate\./);
expect(bodyText).not.toMatch(/playlists\.export\./);
expect(bodyText).not.toMatch(/playlists\.followBtn\./);
});
});
test.describe('Responsive', () => {
test('mobile layout (375px) does not overflow', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
// Main content should be visible
const main = page.locator('main');
await expect(main).toBeVisible({ timeout: 10_000 });
// Check no horizontal overflow
const overflowX = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(overflowX).toBe(false);
});
test('tablet layout (768px) renders correctly', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
const heading = page.locator('h1');
await expect(heading).toBeVisible({ timeout: 10_000 });
});
});
test.describe('Regression', () => {
test('BUG #2: track numbering starts at 1 not 0', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
// "Track 0:" should NOT exist
const track0 = page.locator('[aria-label^="Track 0:"]');
await expect(track0).toHaveCount(0);
// "Track 1:" SHOULD exist (if playlist has tracks)
const track1 = page.locator('[aria-label^="Track 1:"]');
const count = await track1.count();
// If playlist has tracks, track 1 should be present
if (count > 0) {
await expect(track1).toBeVisible();
}
});
test('BUG #3: page title includes playlist name', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
await page.waitForLoadState('networkidle').catch(() => {});
// Wait a bit for the useEffect to update the title
await page.waitForTimeout(1_000);
const title = await page.title();
// Title should contain "Veza" AND not be just "Veza"
expect(title).toContain('Veza');
expect(title).not.toBe('Veza');
expect(title).toMatch(/.+ — Veza/);
});
test('BUG #1: no hardcoded FR/EN mix on playlist detail', async ({ page }) => {
await navigateTo(page, '/playlists');
await page.waitForLoadState('networkidle').catch(() => {});
const playlistLink = page.locator('a[href^="/playlists/"]').filter({
hasNot: page.locator('[href="/playlists/favoris"]'),
}).first();
const href = await playlistLink.getAttribute('href').catch(() => null);
if (!href) return;
await navigateTo(page, href);
// These French strings should NOT appear when locale is EN
const mainText = await page.textContent('main') || '';
expect(mainText).not.toContain('Dupliquer la playlist');
expect(mainText).not.toContain('Réorganiser');
expect(mainText).not.toContain('Aucun track dans cette playlist');
expect(mainText).not.toContain('Chargement des pistes');
});
});
});