veza/tests/e2e/tracks-detail-audit.spec.ts

364 lines
11 KiB
TypeScript
Raw Normal View History

/**
* E2E tests Track Detail Page (/tracks/:id)
* Covers: loading, features, security, a11y, i18n, responsive, regression
*/
import { test, expect } from '@playwright/test';
import { loginViaAPI, CONFIG, navigateTo } from './helpers';
// Discover a valid track ID dynamically via API
async function getValidTrackId(page: import('@playwright/test').Page): Promise<string | null> {
const result = await page.evaluate(() =>
fetch('/api/v1/tracks?page=1&limit=1')
.then((r) => r.json())
.then((d) => d.data?.tracks?.[0]?.id ?? null),
);
return result as string | null;
}
test.describe('Track Detail (/tracks/: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 }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
const main = page.locator('main');
await expect(main).toBeVisible({ timeout: 15_000 });
// Should show track title as h1
const heading = page.locator('h1');
await expect(heading).toBeVisible({ timeout: 10_000 });
});
test('no JS errors on valid track', async ({ page }) => {
const jsErrors: string[] = [];
page.on('pageerror', (error) => {
jsErrors.push(error.message);
});
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
await page.waitForLoadState('networkidle').catch(() => {});
const criticalErrors = jsErrors.filter(
(e) => !e.includes('React DevTools'),
);
expect(criticalErrors).toHaveLength(0);
});
test('all key visual elements present', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
// Back button
const backBtn = page.getByRole('button', { name: /back/i });
await expect(backBtn).toBeVisible({ timeout: 10_000 });
// Play button
const playBtn = page.getByRole('button', { name: /^play$/i }).first();
await expect(playBtn).toBeVisible();
// Tabs
const discussionTab = page.getByRole('tab', { name: /discussion/i });
await expect(discussionTab).toBeVisible();
});
});
test.describe('Fonctionnalites', () => {
test('metadata grid shows track info', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
// Duration label should be present
const duration = page.getByText('Duration');
await expect(duration).toBeVisible({ timeout: 10_000 });
// Genre should be visible
const genre = page.getByText('Genre');
await expect(genre).toBeVisible();
});
test('tabs switch content', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
// Discussion tab selected by default
const discussionTab = page.getByRole('tab', { name: /discussion/i });
await expect(discussionTab).toHaveAttribute('data-state', 'active', { timeout: 10_000 });
// Click Lyrics tab
const lyricsTab = page.getByRole('tab', { name: /lyrics/i });
await lyricsTab.click();
await expect(lyricsTab).toHaveAttribute('data-state', 'active');
});
test('action buttons are present', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
// Like button
const likeBtn = page.getByRole('button', { name: /add to favorites/i });
await expect(likeBtn).toBeVisible({ timeout: 10_000 });
// Repost button
const repostBtn = page.getByRole('button', { name: /repost to your profile/i });
await expect(repostBtn).toBeVisible();
// Add to Queue
const queueBtn = page.getByRole('button', { name: /add to queue/i });
await expect(queueBtn).toBeVisible();
// Share
const shareBtn = page.getByRole('button', { name: /share/i }).first();
await expect(shareBtn).toBeVisible();
});
test('comment section shows placeholder', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
const commentInput = page.getByPlaceholder(/write a comment/i);
await expect(commentInput).toBeVisible({ timeout: 10_000 });
});
});
test.describe('Securite', () => {
test('invalid UUID shows error state', async ({ page }) => {
await navigateTo(page, '/tracks/00000000-0000-0000-0000-000000000000');
await page.waitForLoadState('networkidle').catch(() => {});
// Should show error/not-found state
const errorAlert = page.getByRole('alert');
await expect(errorAlert).toBeVisible({ timeout: 10_000 });
});
test('XSS injection in ID is safe', async ({ page }) => {
const jsErrors: string[] = [];
page.on('pageerror', (error) => {
jsErrors.push(error.message);
});
await navigateTo(page, '/tracks/%3Cscript%3Ealert(1)%3C%2Fscript%3E');
await page.waitForLoadState('networkidle').catch(() => {});
const xssErrors = jsErrors.filter((e) => e.includes('alert'));
expect(xssErrors).toHaveLength(0);
});
test('no sensitive data in URL', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
const url = page.url();
expect(url).not.toMatch(/token=/i);
expect(url).not.toMatch(/@.*\./);
});
});
test.describe('Accessibilite', () => {
test('action buttons have descriptive aria-labels', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
// Like button has meaningful aria-label
const likeBtn = page.getByRole('button', { name: /favorites/i }).first();
await expect(likeBtn).toBeVisible({ timeout: 10_000 });
const ariaLabel = await likeBtn.getAttribute('aria-label');
expect(ariaLabel).toBeTruthy();
expect(ariaLabel).not.toBe('Button');
// Repost button has meaningful aria-label
const repostBtn = page.getByRole('button', { name: /repost/i });
const repostLabel = await repostBtn.getAttribute('aria-label');
expect(repostLabel).toBeTruthy();
});
test('tabs have proper ARIA roles', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
const tablist = page.getByRole('tablist');
await expect(tablist).toBeVisible({ timeout: 10_000 });
const tabs = page.getByRole('tab');
const count = await tabs.count();
expect(count).toBeGreaterThanOrEqual(4);
});
});
test.describe('i18n', () => {
test('no raw i18n keys displayed', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
await page.waitForLoadState('networkidle').catch(() => {});
const bodyText = (await page.textContent('main')) || '';
expect(bodyText).not.toMatch(/tracks\.detail\./);
expect(bodyText).not.toMatch(/tracks\.commentSection\./);
expect(bodyText).not.toMatch(/tracks\.repost\./);
expect(bodyText).not.toMatch(/tracks\.likeAction\./);
expect(bodyText).not.toMatch(/tracks\.shareDialog\./);
expect(bodyText).not.toMatch(/tracks\.lyricsSection\./);
expect(bodyText).not.toMatch(/tracks\.stemsSection\./);
expect(bodyText).not.toMatch(/tracks\.stats\./);
});
test('no FR/EN mix on page', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
await page.waitForLoadState('networkidle').catch(() => {});
const mainText = (await page.textContent('main')) || '';
// These FR strings should NOT appear when locale is EN
expect(mainText).not.toContain('Reposter sur votre profil');
expect(mainText).not.toContain('Commentaires');
expect(mainText).not.toContain('Écrire un commentaire');
expect(mainText).not.toContain('Aucun commentaire');
});
});
test.describe('Responsive', () => {
test('mobile (375px) no overflow', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
const main = page.locator('main');
await expect(main).toBeVisible({ timeout: 10_000 });
const overflowX = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
);
expect(overflowX).toBe(false);
});
});
test.describe('Regression', () => {
test('BUG #1: page title includes track name', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(1_000);
const title = await page.title();
expect(title).toContain('Veza');
expect(title).not.toBe('Veza');
expect(title).toMatch(/.+ — Veza/);
});
test('BUG #2: repost button uses EN not FR', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
// Repost button should have EN aria-label, not FR
const repostBtn = page.locator('button[aria-label]').filter({
has: page.locator('svg'),
});
const allLabels = await repostBtn.evaluateAll((btns) =>
btns.map((b) => b.getAttribute('aria-label')).filter(Boolean),
);
const frLabels = allLabels.filter(
(l) => l && (l.includes('Reposter') || l.includes('Retirer le repost')),
);
expect(frLabels).toHaveLength(0);
});
test('BUG #5: comment section uses EN not FR', async ({ page }) => {
const trackId = await getValidTrackId(page);
if (!trackId) {
test.skip();
return;
}
await navigateTo(page, `/tracks/${trackId}`);
// Comments heading should be EN
const heading = page.getByRole('heading', { name: /comments/i });
await expect(heading).toBeVisible({ timeout: 10_000 });
// Placeholder should be EN
const input = page.getByPlaceholder(/write a comment/i);
await expect(input).toBeVisible();
});
});
});