diff --git a/static/test_download/test_episode_01.mp4 b/static/test_download/test_episode_01.mp4 new file mode 100644 index 0000000..085fd90 Binary files /dev/null and b/static/test_download/test_episode_01.mp4 differ diff --git a/templates/components/downloads_list.html b/templates/components/downloads_list.html index e49bfb9..84df5f0 100644 --- a/templates/components/downloads_list.html +++ b/templates/components/downloads_list.html @@ -1,10 +1,10 @@ {% if tasks %}
{% for task in tasks %} -
+
{{ task.filename }} - {{ task.status | upper }} + {{ task.status | upper }}
diff --git a/tests/e2e/download_flow.spec.ts b/tests/e2e/download_flow.spec.ts new file mode 100644 index 0000000..113261f --- /dev/null +++ b/tests/e2e/download_flow.spec.ts @@ -0,0 +1,319 @@ +import { test, expect, Page } from '@playwright/test'; +import { switchTab, waitForHtmx, collectJsErrors } from './helpers'; + +/** + * Download Flow E2E Tests + * + * These tests cover the complete user journey for discovering and downloading + * anime/series content, including mocked provider flows and real file downloads. + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function getAuthToken(page: Page): Promise { + return page.evaluate(() => localStorage.getItem('auth_token')); +} + +async function createDownloadViaApi(page: Page, url: string): Promise { + const token = await getAuthToken(page); + if (!token) throw new Error('No auth token found'); + + const response = await page.request.post(`/api/anime/download?url=${encodeURIComponent(url)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + expect(response.status()).toBeLessThan(400); + const body = await response.json(); + return body.task_id as string; +} + +async function deleteDownloadViaApi(page: Page, taskId: string): Promise { + const token = await getAuthToken(page); + if (!token) return; + + await page.request.delete(`/api/downloads/${taskId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +// --------------------------------------------------------------------------- +// Test: Episode picker + download toast (fully mocked) +// --------------------------------------------------------------------------- + +test.describe('Download Flow E2E', () => { + test('should choose episodes from search result and trigger download toast', async ({ page }) => { + const jsErrors = collectJsErrors(page); + + // 1. Mock search results with a full card including dropdown + await page.route('/api/anime/search?**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: ` +
+
+ + Frieren + +
+
+

Frieren: Beyond Journey's End

+
+
+
+ +
+ + +
+
+
+
+
+
+ `, + }); + }); + + // 2. Mock episode list pointing to local static file for real download + const testFileUrl = 'http://localhost:3000/static/test_download/test_episode_01.mp4'; + await page.route('/api/anime/episodes?**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: ` +
+
+
+

Frieren

+ 1 épisodes disponibles +
+
+
+
+
+
EP 1
+
Le départ
+
+ + +
+
+
+
+ `, + }); + }); + + await page.goto('/web'); + await switchTab(page, 'Anime'); + await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 }); + + // Trigger search + await page.fill('#animeSearchInput', 'Frieren'); + await page.click('#tab-anime button[type="submit"]'); + + // Wait for search results + await page.locator('#animeSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 }); + await expect(page.locator('#animeSearchResults')).toContainText("Frieren: Beyond Journey's End"); + + // Open dropdown + await page.locator('#animeSearchResults .sr-card').first().locator('.sr-btn-dl').click(); + await page.locator('.sr-dropdown-item:has-text("Choisir des episodes")').waitFor({ state: 'visible', timeout: 5000 }); + + // Click "Choisir des épisodes" + await page.locator('.sr-dropdown-item:has-text("Choisir des episodes")').click(); + + // Wait for episode list + await page.locator('#player-container .episode-item').first().waitFor({ state: 'visible', timeout: 10000 }); + await expect(page.locator('#player-container')).toContainText('EP 1'); + + // Click download on first episode and wait for the real server response + const [response] = await Promise.all([ + page.waitForResponse( + (resp) => resp.url().includes('/api/anime/download') && resp.request().method() === 'POST' + ), + page.locator('#player-container .episode-item').first() + .locator('button[title="Télécharger cet épisode"]').click(), + ]); + + expect(response.status()).toBeLessThan(400); + + // Wait for toast triggered by HX-Trigger header + await page.locator('#toast-container .toast-success') + .filter({ hasText: /Téléchargement lancé/i }) + .waitFor({ state: 'visible', timeout: 8000 }); + + // Cleanup the created download task via API + const body = await response.json(); + if (body.task_id) { + await deleteDownloadViaApi(page, body.task_id as string); + } + + expect(jsErrors).toHaveLength(0); + }); + + // --------------------------------------------------------------------------- + // Test: Real file download via static fixture + // --------------------------------------------------------------------------- + + test('should download a real file and show it in downloads list', async ({ page }) => { + test.setTimeout(60000); + const jsErrors = collectJsErrors(page); + + // Navigate first so localStorage is available on the correct origin + await page.goto('/web'); + + // Use the static test file served by the app itself + const testFileUrl = 'http://localhost:3000/static/test_download/test_episode_01.mp4'; + + // 1. Create download via API + const taskId = await createDownloadViaApi(page, testFileUrl); + + // 2. Navigate to downloads tab + await switchTab(page, 'Téléchargements'); + await page.locator('#tab-downloads').waitFor({ state: 'visible', timeout: 5000 }); + + // 3. Wait for the task to appear in the list + await page.locator('#downloads-container-inner .download-item').first().waitFor({ state: 'visible', timeout: 10000 }); + + // 4. Wait for completion (poll until status is completed) + await expect(page.locator('#downloads-container-inner .download-item.status-completed')).toBeVisible({ timeout: 30000 }); + + // 5. Verify progress is 100% + const progressText = await page.locator('#downloads-container-inner .download-item.status-completed .download-meta span').first().textContent(); + expect(progressText).toContain('100'); + + // 6. Verify filename is shown + await expect(page.locator('#downloads-container-inner .download-item .download-name')).toContainText('test_episode_01.mp4'); + + // 7. Verify completed actions are present (stream + download links) + await expect(page.locator('#downloads-container-inner .download-item.status-completed a[title="Streamer"]')).toBeVisible(); + await expect(page.locator('#downloads-container-inner .download-item.status-completed a[download]')).toBeVisible(); + + // Cleanup + await deleteDownloadViaApi(page, taskId); + + expect(jsErrors).toHaveLength(0); + }); + + // --------------------------------------------------------------------------- + // Test: Click a new release on homepage + // --------------------------------------------------------------------------- + + test('should click a new release and switch to anime search', async ({ page }) => { + const jsErrors = collectJsErrors(page); + + // Mock releases with a single anime card + await page.route('/api/releases/latest', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: ` +
+
+ Spy x Family +
+
+ Anime-Sama + Spy x Family +
+
+ `, + }); + }); + + // Mock empty recommendations so they don't interfere + await page.route('/api/recommendations', async (route) => { + await route.fulfill({ status: 200, contentType: 'text/html', body: '

' }); + }); + + await page.goto('/web'); + + // Wait for releases to load + await page.locator('#releasesList .hc').first().waitFor({ state: 'visible', timeout: 10000 }); + await expect(page.locator('#releasesList .hc-title')).toContainText('Spy x Family'); + + // Click the release card + await page.locator('#releasesList .hc').first().click(); + + // Should switch to anime tab and populate search input + await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 }); + await expect(page.locator('#animeSearchInput')).toHaveValue('Spy x Family'); + + expect(jsErrors).toHaveLength(0); + }); + + // --------------------------------------------------------------------------- + // Test: Series search flow + // --------------------------------------------------------------------------- + + test('should search for series and display results', async ({ page }) => { + const jsErrors = collectJsErrors(page); + + await page.route('/api/series/search?**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: ` +
+
+
+
+

Breaking Bad

+
+

A high school chemistry teacher turned methamphetamine producer.

+
+
+
+
+
+

Better Call Saul

+
+

The trials and tribulations of criminal lawyer Jimmy McGill.

+
+
+
+ `, + }); + }); + + await page.goto('/web'); + await switchTab(page, 'Série'); + await page.locator('#tab-series').waitFor({ state: 'visible', timeout: 5000 }); + + await page.fill('#seriesSearchInput', 'Breaking'); + await page.click('#tab-series button[type="submit"]'); + + await page.locator('#seriesSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 }); + await expect(page.locator('#seriesSearchResults')).toContainText('Breaking Bad'); + await expect(page.locator('#seriesSearchResults')).toContainText('Better Call Saul'); + + expect(jsErrors).toHaveLength(0); + }); +});