veza/tests/e2e/playlists-shared-token.spec.ts

424 lines
16 KiB
TypeScript
Raw Normal View History

/**
* 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');
});
});
});