""" 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