From 18c3c4d27b3bc9065d4bc9b509f47fddf66db02c Mon Sep 17 00:00:00 2001 From: root Date: Mon, 30 Mar 2026 17:42:14 +0000 Subject: [PATCH] test: add E2E user journey test suite (pytest + Playwright skeleton) - tests/test_user_journey.py: 23 pytest tests covering auth, search, settings, and download flows using TestClient with mocked providers (no real network calls) - tests/e2e/user_journey.spec.ts: 6 fixme Playwright test placeholders for full UI journey (register, login, browse, search, settings, logout) --- tests/e2e/user_journey.spec.ts | 67 ++++ tests/test_user_journey.py | 587 +++++++++++++++++++++++++++++++++ 2 files changed, 654 insertions(+) create mode 100644 tests/e2e/user_journey.spec.ts create mode 100644 tests/test_user_journey.py diff --git a/tests/e2e/user_journey.spec.ts b/tests/e2e/user_journey.spec.ts new file mode 100644 index 0000000..e61ca22 --- /dev/null +++ b/tests/e2e/user_journey.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; + +/** + * User Journey E2E Tests + * + * Simulates a complete user flow: register → login → browse → search → settings → logout. + * All tests are serial because they share browser state (auth token, navigation). + * + * FORBIDDEN: Do NOT use page.waitForTimeout() — use waitForResponse() or waitForSelector() + */ + +test.describe('User Journey E2E', () => { + test.describe.configure({ mode: 'serial' }); + + test.fixme('should register a new user', async ({ page }) => { + // TODO: Navigate to /web or /login + // Switch to register tab (text=Inscription) + // Fill #registerUsername with unique username + // Fill #registerPassword and #registerPasswordConfirm + // Click #registerSubmit + // Wait for API response via waitForResponse(r => r.url().includes('/api/auth/register')) + // Verify #authSuccess becomes visible or contains success message + }); + + test.fixme('should login with registered credentials', async ({ page }) => { + // TODO: Navigate to /login + // Fill #loginUsername and #loginPassword + // Click #loginSubmit + // Wait for response via waitForResponse(r => r.url().includes('/api/auth/login')) + // Verify redirect to /web or home page + // Verify auth token is stored (check localStorage or cookie) + }); + + test.fixme('should browse homepage without errors', async ({ page }) => { + // TODO: Navigate to /web + // Wait for page to load via waitForSelector for main content area + // Verify no console errors + // Verify page title or main heading is visible + // Note: content may be empty in test env — just verify no crash + }); + + test.fixme('should search for anime', async ({ page }) => { + // TODO: Click on anime search tab (if tabs exist) + // Fill #animeSearchInput with "Naruto" + // Submit the search form (trigger HTMX request) + // Wait for response via waitForResponse(r => r.url().includes('/api/anime/search')) + // Verify search results appear or "no results" message shown + // Verify results container has expected selectors + }); + + test.fixme('should update settings', async ({ page }) => { + // TODO: Click on settings tab or navigate to settings section + // Wait for settings panel to load via waitForResponse(r => r.url().includes('/api/settings')) + // Verify #default_lang dropdown exists + // Change language setting (select different option) + // Submit/save settings form + // Wait for response via waitForResponse(r => r.url().includes('/api/settings') && r.request().method() === 'PATCH') + // Verify success toast notification appears + }); + + test.fixme('should logout successfully', async ({ page }) => { + // TODO: Click logout button + // Wait for response via waitForResponse(r => r.url().includes('/api/auth/logout')) + // Verify redirect to login page or auth state is cleared + // Verify protected content is no longer accessible + }); +}); diff --git a/tests/test_user_journey.py b/tests/test_user_journey.py new file mode 100644 index 0000000..99c0629 --- /dev/null +++ b/tests/test_user_journey.py @@ -0,0 +1,587 @@ +""" +End-to-end user journey tests for Ohm Stream Downloader. + +These tests verify complete user workflows including authentication, +search, settings management, and download operations. + +All tests use mocked providers and in-memory SQLite — no real network calls. +""" + +import os +import time +import uuid + +# FORCE DATABASE_URL to in-memory for ALL tests before ANY app imports +os.environ["DATABASE_URL"] = "sqlite://" + +import pytest +from typing import Dict, List, Optional, Tuple +from unittest.mock import AsyncMock, Mock, patch +from fastapi.testclient import TestClient + +from main import app + + +# ============================================================================= +# MOCK DATA CONSTANTS +# ============================================================================= + +MOCK_ANIME_SEARCH_RESULTS: Dict[str, List[Dict]] = { + "anime-sama": [ + { + "title": "Naruto Shippuden", + "url": "https://anime-sama.si/catalogue/naruto/saison1/vostfr/", + "cover_image": "https://example.com/naruto.jpg", + "type": "search_result", + }, + { + "title": "One Piece", + "url": "https://anime-sama.si/catalogue/one-piece/saison1/vostfr/", + "cover_image": "https://example.com/onepiece.jpg", + "type": "search_result", + }, + ], + "anime-ultime": [ + { + "title": "Naruto Shippuden", + "url": "https://www.anime-ultime.net/naruto-shippuden", + "cover_image": "https://example.com/naruto-au.jpg", + "type": "search_result", + }, + ], +} + +MOCK_SERIES_SEARCH_RESULTS: Dict[str, Dict] = { + "Breaking Bad": { + "base_name": "Breaking Bad", + "cover": "https://example.com/bb.jpg", + "synopsis": "A chemistry teacher turns to manufacturing methamphetamine...", + "seasons": { + 1: [ + { + "id": "fs7", + "url": "https://fs7.fr/breaking-bad/saison-1", + "provider_id": "fs7", + } + ], + 2: [ + { + "id": "fs7", + "url": "https://fs7.fr/breaking-bad/saison-2", + "provider_id": "fs7", + } + ], + }, + } +} + +MOCK_EPISODE_LIST: List[Dict] = [ + { + "episode": 1, + "url": "https://example.com/video1.mp4|https://anime-sama.si/catalogue/naruto/s1/ep1|Naruto - Episode 1", + }, + { + "episode": 2, + "url": "https://example.com/video2.mp4|https://anime-sama.si/catalogue/naruto/s1/ep2|Naruto - Episode 2", + }, + { + "episode": 3, + "url": "https://example.com/video3.mp4|https://anime-sama.si/catalogue/naruto/s1/ep3|Naruto - Episode 3", + }, + { + "episode": 4, + "url": "https://example.com/video4.mp4|https://anime-sama.si/catalogue/naruto/s1/ep4|Naruto - Episode 4", + }, + { + "episode": 5, + "url": "https://example.com/video5.mp4|https://anime-sama.si/catalogue/naruto/s1/ep5|Naruto - Episode 5", + }, +] + +MOCK_DOWNLOAD_LINK: Tuple[str, str] = ( + "https://example.com/video1.mp4", + "Naruto_Episode_1.mp4", +) + + +# ============================================================================= +# FIXTURES +# ============================================================================= + + +@pytest.fixture +def journey_client(): + """ + Create a TestClient with a registered and logged-in user. + + Uses 'with' context manager to ensure startup events fire + and database tables are properly initialized. + """ + unique_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" + test_username = f"journey_user_{unique_id}" + test_password = "TestPassword123!" + test_email = f"{test_username}@test.example.com" + + with TestClient(app) as client: + register_response = client.post( + "/api/auth/register", + json={ + "username": test_username, + "password": test_password, + "email": test_email, + }, + ) + assert register_response.status_code == 200, ( + f"Registration failed: {register_response.json()}" + ) + + login_response = client.post( + "/api/auth/login", + json={ + "username": test_username, + "password": test_password, + }, + ) + assert login_response.status_code == 200, ( + f"Login failed: {login_response.json()}" + ) + + access_token = login_response.json()["access_token"] + client.headers["Authorization"] = f"Bearer {access_token}" + + yield client + + app.dependency_overrides.clear() + + +@pytest.fixture +def unauth_client(): + """Create a plain TestClient without authentication.""" + with TestClient(app) as client: + yield client + + +# ============================================================================= +# TEST CLASSES +# ============================================================================= + + +class TestAuthJourney: + """Tests for the authentication user journey — registration, login, tokens.""" + + def test_register_new_user(self): + unique_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" + username = f"auth_test_{unique_id}" + with TestClient(app) as client: + response = client.post( + "/api/auth/register", + json={"username": username, "password": "SecurePass123!"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["user"]["username"] == username + assert "id" in data["user"] + + def test_register_duplicate_user(self): + username = f"dup_{uuid.uuid4().hex[:8]}" + with TestClient(app) as client: + first = client.post( + "/api/auth/register", + json={"username": username, "password": "SecurePass123!"}, + ) + assert first.status_code == 200 + + second = client.post( + "/api/auth/register", + json={"username": username, "password": "SecurePass123!"}, + ) + assert second.status_code in (400, 500) + + def test_login_success(self): + unique_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" + username = f"login_test_{unique_id}" + password = "SecurePass123!" + + with TestClient(app) as client: + client.post( + "/api/auth/register", + json={"username": username, "password": password}, + ) + response = client.post( + "/api/auth/login", + json={"username": username, "password": password}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + assert data["user"]["username"] == username + + def test_login_wrong_password(self): + unique_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" + username = f"wrong_pw_{unique_id}" + + with TestClient(app) as client: + client.post( + "/api/auth/register", + json={"username": username, "password": "CorrectPass123!"}, + ) + + with TestClient(app) as client: + response = client.post( + "/api/auth/login", + json={"username": username, "password": "WrongPass999!"}, + ) + assert response.status_code == 401 + + def test_get_current_user(self, journey_client): + response = journey_client.get("/api/auth/me") + assert response.status_code == 200 + data = response.json() + assert "user" in data + assert "username" in data["user"] + assert "id" in data["user"] + + def test_unauthorized_access(self): + with TestClient(app) as client: + response = client.get("/api/auth/me") + assert response.status_code == 403 + + +class TestSearchJourney: + """Tests for the search user journey — anime/series search, episodes, metadata.""" + + @patch("app.routers.router_anime.providers_manager") + @patch("app.routers.router_anime.get_metadata_enricher") + def test_search_anime(self, mock_enricher, mock_pm, journey_client): + mock_provider = Mock() + mock_provider.id = "anime-sama" + mock_provider.search = AsyncMock( + return_value=[ + Mock( + model_dump=Mock( + return_value={ + "title": "Naruto Shippuden", + "url": "https://anime-sama.si/catalogue/naruto/s1/vostfr/", + "cover_image": "https://example.com/naruto.jpg", + "type": "search_result", + } + ) + ), + ] + ) + mock_pm.get_active_providers.return_value = [mock_provider] + + mock_meta = AsyncMock() + mock_meta.enrich_metadata = AsyncMock(return_value=None) + mock_enricher.return_value = mock_meta + + response = journey_client.get("/api/anime/search?q=naruto") + assert response.status_code == 200 + data = response.json() + assert data["query"] == "naruto" + assert "results" in data + + @patch("app.routers.router_anime.providers_manager") + @patch("app.routers.router_anime.get_metadata_enricher") + def test_search_anime_results_have_providers( + self, mock_enricher, mock_pm, journey_client + ): + mock_provider = Mock() + mock_provider.id = "anime-sama" + mock_provider.search = AsyncMock( + return_value=[ + Mock( + model_dump=Mock( + return_value={ + "title": "Naruto Shippuden", + "url": "https://anime-sama.si/catalogue/naruto/s1/vostfr/", + "cover_image": "https://example.com/naruto.jpg", + "type": "search_result", + } + ) + ), + ] + ) + mock_pm.get_active_providers.return_value = [mock_provider] + + mock_meta = AsyncMock() + mock_meta.enrich_metadata = AsyncMock(return_value=None) + mock_enricher.return_value = mock_meta + + response = journey_client.get("/api/anime/search?q=naruto") + data = response.json() + assert "anime-sama" in data["results"] + + @patch("app.routers.router_anime.get_series_providers") + @patch("app.routers.router_anime.get_metadata_enricher") + def test_search_series(self, mock_enricher, mock_series_providers, journey_client): + mock_series_providers.return_value = {"fs7": {"name": "FS7"}} + + mock_meta = AsyncMock() + mock_meta.enrich_metadata = AsyncMock(return_value=None) + mock_enricher.return_value = mock_meta + + mock_fs7_instance = Mock() + mock_fs7_instance.search_anime = AsyncMock( + return_value=[ + { + "title": "Breaking Bad", + "url": "https://fs7.fr/breaking-bad/saison-1", + "cover_image": "https://example.com/bb.jpg", + } + ] + ) + + with patch.dict( + "sys.modules", + { + "app.downloaders.series_sites.fs7": Mock( + FS7Downloader=Mock(return_value=mock_fs7_instance) + ) + }, + ): + response = journey_client.get("/api/series/search?q=breaking+bad") + assert response.status_code == 200 + data = response.json() + assert data["query"] == "breaking bad" + assert "results" in data + + @patch("app.routers.router_anime.providers_manager") + @patch("app.routers.router_anime.get_metadata_enricher") + def test_search_anime_no_results(self, mock_enricher, mock_pm, journey_client): + mock_provider = Mock() + mock_provider.id = "anime-sama" + mock_provider.search = AsyncMock(return_value=[]) + mock_pm.get_active_providers.return_value = [mock_provider] + + mock_meta = AsyncMock() + mock_meta.enrich_metadata = AsyncMock(return_value=None) + mock_enricher.return_value = mock_meta + + response = journey_client.get("/api/anime/search?q=test_no_results_xyz") + assert response.status_code == 200 + data = response.json() + assert "results" in data + assert "anime-sama" not in data["results"] + + @patch("app.routers.router_anime.get_downloader") + def test_get_episodes(self, mock_get_dl): + mock_dl = Mock() + mock_dl.get_episodes = AsyncMock(return_value=MOCK_EPISODE_LIST) + mock_get_dl.return_value = mock_dl + + with TestClient(app) as client: + response = client.get( + "/api/anime/episodes?url=https://anime-sama.si/test&lang=vostfr" + ) + assert response.status_code == 200 + data = response.json() + assert data["lang"] == "vostfr" + assert len(data["episodes"]) == 5 + assert data["episodes"][0]["episode"] == 1 + + @patch("app.routers.router_anime.get_downloader") + def test_get_anime_metadata(self, mock_get_dl): + mock_dl = Mock() + mock_dl.get_anime_metadata = AsyncMock( + return_value={ + "synopsis": "A ninja story", + "genres": ["Action", "Adventure"], + "rating": "8.5/10", + } + ) + mock_dl.hasattr = Mock(return_value=True) + mock_get_dl.return_value = mock_dl + + with TestClient(app) as client: + response = client.get( + "/api/anime/metadata?url=https://anime-sama.si/naruto" + ) + assert response.status_code == 200 + data = response.json() + assert data["url"] == "https://anime-sama.si/naruto" + assert data["metadata"]["synopsis"] == "A ninja story" + + +class TestSettingsJourney: + """Tests for the settings management user journey — preferences, providers, theme.""" + + def test_get_default_settings(self, journey_client): + response = journey_client.get("/api/settings") + assert response.status_code == 200 + data = response.json() + assert data["default_lang"] == "vostfr" + assert data["theme"] == "dark" + + def test_update_lang(self, journey_client): + response = journey_client.patch("/api/settings", json={"default_lang": "vf"}) + assert response.status_code == 200 + data = response.json() + assert data["default_lang"] == "vf" + + verify = journey_client.get("/api/settings") + assert verify.json()["default_lang"] == "vf" + + def test_update_theme(self, journey_client): + response = journey_client.patch("/api/settings", json={"theme": "oled"}) + assert response.status_code == 200 + assert response.json()["theme"] == "oled" + + def test_settings_persist_across_requests(self, journey_client): + journey_client.patch( + "/api/settings", + json={ + "default_lang": "vf", + "theme": "oled", + }, + ) + + first = journey_client.get("/api/settings").json() + second = journey_client.get("/api/settings").json() + + assert first["default_lang"] == "vf" + assert first["theme"] == "oled" + assert second["default_lang"] == first["default_lang"] + assert second["theme"] == first["theme"] + + @patch("app.routers.router_settings.providers_manager") + def test_get_providers_availability(self, mock_pm, journey_client): + mock_pm.get_all_status.return_value = { + "anime-sama": {"status": "up"}, + "fs7": {"status": "up"}, + } + + response = journey_client.get("/api/settings/providers/availability") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + provider_ids = {p["id"] for p in data} + assert len(provider_ids) > 0 + + @patch("app.routers.router_settings.providers_manager") + def test_toggle_provider(self, mock_pm, journey_client): + mock_pm.get_all_status.return_value = {} + + toggle = journey_client.post("/api/settings/providers/anime-sama/toggle") + assert toggle.status_code == 200 + data = toggle.json() + assert data["id"] == "anime-sama" + assert data["enabled"] is False + + toggle_back = journey_client.post("/api/settings/providers/anime-sama/toggle") + assert toggle_back.json()["enabled"] is True + + settings = journey_client.get("/api/settings").json() + assert "anime-sama" not in settings["disabled_providers"] + + +class TestDownloadJourney: + """Tests for the download management user journey — create, list, status, cancel.""" + + @patch("app.routers.router_anime.get_download_manager") + def test_create_single_download(self, mock_get_dm, journey_client): + import tempfile + from pathlib import Path + from app.download_manager import DownloadManager + + tmp_dir = Path(tempfile.mkdtemp()) / "downloads" + tmp_dir.mkdir(exist_ok=True) + manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1) + mock_get_dm.return_value = manager + + response = journey_client.post( + "/api/anime/download?url=https://example.com/video.mp4" + ) + assert response.status_code == 200 + data = response.json() + assert "task_id" in data + + @patch("app.routers.router_downloads.get_download_manager") + def test_list_downloads(self, mock_get_dm, journey_client): + import tempfile + from pathlib import Path + from app.download_manager import DownloadManager + + tmp_dir = Path(tempfile.mkdtemp()) / "downloads" + tmp_dir.mkdir(exist_ok=True) + manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1) + mock_get_dm.return_value = manager + + journey_client.post( + "/api/downloads", + json={"url": "https://example.com/video1.mp4"}, + ) + response = journey_client.get("/api/downloads") + assert response.status_code == 200 + data = response.json() + assert "downloads" in data + assert len(data["downloads"]) >= 1 + + @patch("app.routers.router_downloads.get_download_manager") + def test_download_task_status(self, mock_get_dm, journey_client): + import tempfile + from pathlib import Path + from app.download_manager import DownloadManager + + tmp_dir = Path(tempfile.mkdtemp()) / "downloads" + tmp_dir.mkdir(exist_ok=True) + manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1) + mock_get_dm.return_value = manager + + task_resp = journey_client.post( + "/api/downloads", + json={"url": "https://example.com/status_test.mp4"}, + ) + task_id = task_resp.json()["id"] + + status_resp = journey_client.get(f"/api/downloads/{task_id}") + assert status_resp.status_code == 200 + assert status_resp.json()["id"] == task_id + + @patch("app.routers.router_downloads.get_download_manager") + def test_cancel_download(self, mock_get_dm, journey_client): + import tempfile + from pathlib import Path + from app.download_manager import DownloadManager + + tmp_dir = Path(tempfile.mkdtemp()) / "downloads" + tmp_dir.mkdir(exist_ok=True) + manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1) + mock_get_dm.return_value = manager + + task_resp = journey_client.post( + "/api/downloads", + json={"url": "https://example.com/cancel_test.mp4"}, + ) + assert task_resp.status_code == 200 + task_id = task_resp.json()["id"] + + cancel_resp = journey_client.delete(f"/api/downloads/{task_id}") + assert cancel_resp.status_code == 200 + + @patch("app.routers.router_anime.get_download_manager") + @patch("app.routers.router_anime.get_downloader") + def test_download_season(self, mock_get_dl, mock_get_dm, journey_client): + import tempfile + from pathlib import Path + from app.download_manager import DownloadManager + + mock_dl = Mock() + mock_dl.get_episodes = AsyncMock(return_value=MOCK_EPISODE_LIST) + mock_get_dl.return_value = mock_dl + + tmp_dir = Path(tempfile.mkdtemp()) / "downloads" + tmp_dir.mkdir(exist_ok=True) + manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1) + mock_get_dm.return_value = manager + mock_get_dm.return_value = manager + + response = journey_client.post( + "/api/anime/download-season?url=https://anime-sama.si/test&lang=vostfr" + ) + assert response.status_code == 200 + data = response.json() + assert data["total_episodes"] == 5 + assert len(data["task_ids"]) == 5