diff --git a/README.md b/README.md index 45a9ea8..b800834 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ Get up and running in three simple steps: ```bash viu anilist auth ``` - This will open your browser. Authorize the app and paste the obtained token back into the terminal. + This will open your browser. Authorize the app and paste the obtained token back into the terminal. Alternatively, you can pass the token directly as an argument, or provide a path to a text file containing the token. 2. **Launch the Interactive TUI:** ```bash diff --git a/tests/cli/commands/anilist/commands/test_auth.py b/tests/cli/commands/anilist/commands/test_auth.py new file mode 100644 index 0000000..45fcb4f --- /dev/null +++ b/tests/cli/commands/anilist/commands/test_auth.py @@ -0,0 +1,284 @@ +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from viu_media.cli.commands.anilist.commands.auth import auth + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mock_config(): + config = MagicMock() + config.user.interactive = True + return config + + +@pytest.fixture +def mock_auth_service(): + with patch("viu_media.cli.service.auth.AuthService") as mock: + yield mock + + +@pytest.fixture +def mock_feedback_service(): + with patch("viu_media.cli.service.feedback.FeedbackService") as mock: + yield mock + + +@pytest.fixture +def mock_selector(): + with patch("viu_media.libs.selectors.selector.create_selector") as mock: + yield mock + + +@pytest.fixture +def mock_api_client(): + with patch("viu_media.libs.media_api.api.create_api_client") as mock: + yield mock + + +@pytest.fixture +def mock_webbrowser(): + with patch("viu_media.cli.commands.anilist.commands.auth.webbrowser") as mock: + yield mock + + +def test_auth_with_token_argument( + runner, + mock_config, + mock_auth_service, + mock_feedback_service, + mock_selector, + mock_api_client, +): + """Test 'viu anilist auth '.""" + api_client_instance = mock_api_client.return_value + profile_mock = MagicMock() + profile_mock.name = "testuser" + api_client_instance.authenticate.return_value = profile_mock + + auth_service_instance = mock_auth_service.return_value + auth_service_instance.get_auth.return_value = None + + result = runner.invoke(auth, ["test_token"], obj=mock_config) + + assert result.exit_code == 0 + mock_api_client.assert_called_with("anilist", mock_config) + api_client_instance.authenticate.assert_called_with("test_token") + auth_service_instance.save_user_profile.assert_called_with( + profile_mock, "test_token" + ) + feedback_instance = mock_feedback_service.return_value + feedback_instance.info.assert_called_with("Successfully logged in as testuser! ✨") + + +def test_auth_with_token_file( + runner, + mock_config, + mock_auth_service, + mock_feedback_service, + mock_selector, + mock_api_client, + tmp_path, +): + """Test 'viu anilist auth '.""" + token_file = tmp_path / "token.txt" + token_file.write_text("file_token") + + api_client_instance = mock_api_client.return_value + profile_mock = MagicMock() + profile_mock.name = "testuser" + api_client_instance.authenticate.return_value = profile_mock + + auth_service_instance = mock_auth_service.return_value + auth_service_instance.get_auth.return_value = None + + result = runner.invoke(auth, [str(token_file)], obj=mock_config) + + assert result.exit_code == 0 + mock_api_client.assert_called_with("anilist", mock_config) + api_client_instance.authenticate.assert_called_with("file_token") + auth_service_instance.save_user_profile.assert_called_with( + profile_mock, "file_token" + ) + feedback_instance = mock_feedback_service.return_value + feedback_instance.info.assert_called_with("Successfully logged in as testuser! ✨") + + +def test_auth_with_empty_token_file( + runner, + mock_config, + mock_auth_service, + mock_feedback_service, + mock_selector, + mock_api_client, + tmp_path, +): + """Test 'viu anilist auth' with an empty token file.""" + token_file = tmp_path / "token.txt" + token_file.write_text("") + + auth_service_instance = mock_auth_service.return_value + auth_service_instance.get_auth.return_value = None + + result = runner.invoke(auth, [str(token_file)], obj=mock_config) + + assert result.exit_code == 0 + feedback_instance = mock_feedback_service.return_value + feedback_instance.error.assert_called_with(f"Token file is empty: {token_file}") + + +def test_auth_interactive( + runner, + mock_config, + mock_auth_service, + mock_feedback_service, + mock_selector, + mock_api_client, + mock_webbrowser, +): + """Test 'viu anilist auth' interactive mode.""" + mock_webbrowser.open.return_value = True + + selector_instance = mock_selector.return_value + selector_instance.ask.return_value = "interactive_token" + + api_client_instance = mock_api_client.return_value + profile_mock = MagicMock() + profile_mock.name = "testuser" + api_client_instance.authenticate.return_value = profile_mock + + auth_service_instance = mock_auth_service.return_value + auth_service_instance.get_auth.return_value = None + + result = runner.invoke(auth, [], obj=mock_config) + + assert result.exit_code == 0 + selector_instance.ask.assert_called_with("Enter your AniList Access Token") + api_client_instance.authenticate.assert_called_with("interactive_token") + auth_service_instance.save_user_profile.assert_called_with( + profile_mock, "interactive_token" + ) + feedback_instance = mock_feedback_service.return_value + feedback_instance.info.assert_called_with("Successfully logged in as testuser! ✨") + + +def test_auth_status_logged_in( + runner, mock_config, mock_auth_service, mock_feedback_service +): + """Test 'viu anilist auth --status' when logged in.""" + auth_service_instance = mock_auth_service.return_value + user_data_mock = MagicMock() + user_data_mock.user_profile = "testuser" + auth_service_instance.get_auth.return_value = user_data_mock + + result = runner.invoke(auth, ["--status"], obj=mock_config) + + assert result.exit_code == 0 + feedback_instance = mock_feedback_service.return_value + feedback_instance.info.assert_called_with("Logged in as: testuser") + + +def test_auth_status_logged_out( + runner, mock_config, mock_auth_service, mock_feedback_service +): + """Test 'viu anilist auth --status' when logged out.""" + auth_service_instance = mock_auth_service.return_value + auth_service_instance.get_auth.return_value = None + + result = runner.invoke(auth, ["--status"], obj=mock_config) + + assert result.exit_code == 0 + feedback_instance = mock_feedback_service.return_value + feedback_instance.error.assert_called_with("Not logged in.") + + +def test_auth_logout( + runner, mock_config, mock_auth_service, mock_feedback_service, mock_selector +): + """Test 'viu anilist auth --logout'.""" + selector_instance = mock_selector.return_value + selector_instance.confirm.return_value = True + + result = runner.invoke(auth, ["--logout"], obj=mock_config) + + assert result.exit_code == 0 + auth_service_instance = mock_auth_service.return_value + auth_service_instance.clear_user_profile.assert_called_once() + feedback_instance = mock_feedback_service.return_value + feedback_instance.info.assert_called_with("You have been logged out.") + + +def test_auth_logout_cancel( + runner, mock_config, mock_auth_service, mock_feedback_service, mock_selector +): + """Test 'viu anilist auth --logout' when user cancels.""" + selector_instance = mock_selector.return_value + selector_instance.confirm.return_value = False + + result = runner.invoke(auth, ["--logout"], obj=mock_config) + + assert result.exit_code == 0 + auth_service_instance = mock_auth_service.return_value + auth_service_instance.clear_user_profile.assert_not_called() + + +def test_auth_already_logged_in_relogin_yes( + runner, + mock_config, + mock_auth_service, + mock_feedback_service, + mock_selector, + mock_api_client, +): + """Test 'viu anilist auth' when already logged in and user chooses to relogin.""" + auth_service_instance = mock_auth_service.return_value + auth_profile_mock = MagicMock() + auth_profile_mock.user_profile.name = "testuser" + auth_service_instance.get_auth.return_value = auth_profile_mock + + selector_instance = mock_selector.return_value + selector_instance.confirm.return_value = True + selector_instance.ask.return_value = "new_token" + + api_client_instance = mock_api_client.return_value + new_profile_mock = MagicMock() + new_profile_mock.name = "newuser" + api_client_instance.authenticate.return_value = new_profile_mock + + result = runner.invoke(auth, [], obj=mock_config) + + assert result.exit_code == 0 + selector_instance.confirm.assert_called_with( + "You are already logged in as testuser. Would you like to relogin" + ) + auth_service_instance.save_user_profile.assert_called_with( + new_profile_mock, "new_token" + ) + feedback_instance = mock_feedback_service.return_value + feedback_instance.info.assert_called_with("Successfully logged in as newuser! ✨") + + +def test_auth_already_logged_in_relogin_no( + runner, mock_config, mock_auth_service, mock_feedback_service, mock_selector +): + """Test 'viu anilist auth' when already logged in and user chooses not to relogin.""" + auth_service_instance = mock_auth_service.return_value + auth_profile_mock = MagicMock() + auth_profile_mock.user_profile.name = "testuser" + auth_service_instance.get_auth.return_value = auth_profile_mock + + selector_instance = mock_selector.return_value + selector_instance.confirm.return_value = False + + result = runner.invoke(auth, [], obj=mock_config) + + assert result.exit_code == 0 + auth_service_instance.save_user_profile.assert_not_called() + feedback_instance = mock_feedback_service.return_value + feedback_instance.info.assert_not_called() diff --git a/tests/libs/__init__.py b/tests/libs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/libs/media_api/__init__.py b/tests/libs/media_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/libs/media_api/anilist/__init__.py b/tests/libs/media_api/anilist/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/libs/media_api/anilist/test_mapper.py b/tests/libs/media_api/anilist/test_mapper.py new file mode 100644 index 0000000..1aaa828 --- /dev/null +++ b/tests/libs/media_api/anilist/test_mapper.py @@ -0,0 +1,54 @@ +from typing import Any + +from viu_media.libs.media_api.anilist.mapper import to_generic_user_profile +from viu_media.libs.media_api.anilist.types import AnilistViewerData +from viu_media.libs.media_api.types import UserProfile + + +def test_to_generic_user_profile_success(): + data: AnilistViewerData = { + "data": { + "Viewer": { + "id": 123, + "name": "testuser", + "avatar": { + "large": "https://example.com/avatar.png", + "medium": "https://example.com/avatar_medium.png", + "extraLarge": "https://example.com/avatar_extraLarge.png", + "small": "https://example.com/avatar_small.png", + }, + "bannerImage": "https://example.com/banner.png", + "token": "test_token", + } + } + } + profile = to_generic_user_profile(data) + assert isinstance(profile, UserProfile) + assert profile.id == 123 + assert profile.name == "testuser" + assert profile.avatar_url == "https://example.com/avatar.png" + assert profile.banner_url == "https://example.com/banner.png" + + +def test_to_generic_user_profile_data_none(): + data: Any = {"data": None} + profile = to_generic_user_profile(data) + assert profile is None + + +def test_to_generic_user_profile_no_data_key(): + data: Any = {"errors": [{"message": "Invalid token"}]} + profile = to_generic_user_profile(data) + assert profile is None + + +def test_to_generic_user_profile_no_viewer_key(): + data: Any = {"data": {"Page": {}}} + profile = to_generic_user_profile(data) + assert profile is None + + +def test_to_generic_user_profile_viewer_none(): + data: Any = {"data": {"Viewer": None}} + profile = to_generic_user_profile(data) + assert profile is None diff --git a/viu_media/cli/cli.py b/viu_media/cli/cli.py index 60bbbf8..675aabc 100644 --- a/viu_media/cli/cli.py +++ b/viu_media/cli/cli.py @@ -189,7 +189,7 @@ You can disable this message by turning off the welcome_screen option in the con ): import subprocess - _cli_cmd_name="viu" if not shutil.which("viu-media") else "viu-media" + _cli_cmd_name = "viu" if not shutil.which("viu-media") else "viu-media" cmd = [_cli_cmd_name, "config", "--update"] print(f"running '{' '.join(cmd)}'...") subprocess.run(cmd) diff --git a/viu_media/cli/commands/anilist/commands/auth.py b/viu_media/cli/commands/anilist/commands/auth.py index d6060e1..ee77aba 100644 --- a/viu_media/cli/commands/anilist/commands/auth.py +++ b/viu_media/cli/commands/anilist/commands/auth.py @@ -1,25 +1,72 @@ -import click import webbrowser +from pathlib import Path +import click from .....core.config.model import AppConfig +def _get_token(feedback, selector, token_input: str | None) -> str | None: + """ + Retrieves the authentication token from a file path, a direct string, or an interactive prompt. + """ + if token_input: + path = Path(token_input) + if path.is_file(): + try: + token = path.read_text().strip() + if not token: + feedback.error(f"Token file is empty: {path}") + return None + return token + except Exception as e: + feedback.error(f"Error reading token from file: {e}") + return None + return token_input + + from .....core.constants import ANILIST_AUTH + + open_success = webbrowser.open(ANILIST_AUTH, new=2) + if open_success: + feedback.info("Your browser has been opened to obtain an AniList token.") + feedback.info( + f"Or you can visit the site manually [magenta][link={ANILIST_AUTH}]here[/link][/magenta]." + ) + else: + feedback.warning( + f"Failed to open the browser. Please visit the site manually [magenta][link={ANILIST_AUTH}]here[/link][/magenta]." + ) + feedback.info( + "After authorizing, copy the token from the address bar and paste it below." + ) + return selector.ask("Enter your AniList Access Token") + + @click.command(help="Login to your AniList account to enable progress tracking.") @click.option("--status", "-s", is_flag=True, help="Check current login status.") @click.option("--logout", "-l", is_flag=True, help="Log out and erase credentials.") +@click.argument("token_input", required=False, type=str) @click.pass_obj -def auth(config: AppConfig, status: bool, logout: bool): - """Handles user authentication and credential management.""" - from .....core.constants import ANILIST_AUTH +def auth(config: AppConfig, status: bool, logout: bool, token_input: str | None): + """ + Handles user authentication and credential management. + + This command allows you to log in to your AniList account to enable + progress tracking and other features. + + You can provide your authentication token in three ways: + 1. Interactively: Run the command without arguments to open a browser + and be prompted to paste the token. + 2. As an argument: Pass the token string directly to the command. + $ viu anilist auth "your_token_here" + 3. As a file: Pass the path to a text file containing the token. + $ viu anilist auth /path/to/token.txt + """ from .....libs.media_api.api import create_api_client - from .....libs.selectors.selector import create_selector from ....service.auth import AuthService from ....service.feedback import FeedbackService auth_service = AuthService("anilist") feedback = FeedbackService(config) - selector = create_selector(config) - feedback.clear_console() if status: user_data = auth_service.get_auth() @@ -29,6 +76,11 @@ def auth(config: AppConfig, status: bool, logout: bool): feedback.error("Not logged in.") return + from .....libs.selectors.selector import create_selector + + selector = create_selector(config) + feedback.clear_console() + if logout: if selector.confirm("Are you sure you want to log out and erase your token?"): auth_service.clear_user_profile() @@ -40,27 +92,14 @@ def auth(config: AppConfig, status: bool, logout: bool): f"You are already logged in as {auth_profile.user_profile.name}.Would you like to relogin" ): return - api_client = create_api_client("anilist", config) + token = _get_token(feedback, selector, token_input) - open_success = webbrowser.open(ANILIST_AUTH, new=2) - if open_success: - feedback.info("Your browser has been opened to obtain an AniList token.") - feedback.info( - f"or you can visit the site manually [magenta][link={ANILIST_AUTH}]here[/link][/magenta]." - ) - else: - feedback.warning( - f"Failed to open the browser. Please visit the site manually [magenta][link={ANILIST_AUTH}]here[/link][/magenta]." - ) - feedback.info( - "After authorizing, copy the token from the address bar and paste it below." - ) - - token = selector.ask("Enter your AniList Access Token") if not token: - feedback.error("Login cancelled.") + if not token_input: + feedback.error("Login cancelled.") return + api_client = create_api_client("anilist", config) # Use the API client to validate the token and get profile info profile = api_client.authenticate(token.strip()) diff --git a/viu_media/libs/media_api/anilist/mapper.py b/viu_media/libs/media_api/anilist/mapper.py index da731e2..d56f15d 100644 --- a/viu_media/libs/media_api/anilist/mapper.py +++ b/viu_media/libs/media_api/anilist/mapper.py @@ -323,7 +323,14 @@ def to_generic_user_list_result(data: AnilistMediaLists) -> Optional[MediaSearch def to_generic_user_profile(data: AnilistViewerData) -> Optional[UserProfile]: """Maps a raw AniList viewer response to a generic UserProfile.""" - viewer_data: Optional[AnilistCurrentlyLoggedInUser] = data["data"]["Viewer"] + data_node = data.get("data") + if not data_node: + return None + + viewer_data: Optional[AnilistCurrentlyLoggedInUser] = data_node.get("Viewer") + + if not viewer_data: + return None return UserProfile( id=viewer_data["id"], diff --git a/viu_media/libs/provider/anime/animepahe/constants.py b/viu_media/libs/provider/anime/animepahe/constants.py index 1973bb1..a7a27e0 100644 --- a/viu_media/libs/provider/anime/animepahe/constants.py +++ b/viu_media/libs/provider/anime/animepahe/constants.py @@ -27,7 +27,7 @@ SERVER_HEADERS = { "Accept-Encoding": "Utf-8", "DNT": "1", "Connection": "keep-alive", - "Referer": ANIMEPAHE_BASE + '/', + "Referer": ANIMEPAHE_BASE + "/", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "iframe", "Sec-Fetch-Mode": "navigate", @@ -44,7 +44,7 @@ STREAM_HEADERS = { "Origin": CDN_PROVIDER_BASE, "Sec-GPC": "1", "Connection": "keep-alive", - "Referer": CDN_PROVIDER_BASE + '/', + "Referer": CDN_PROVIDER_BASE + "/", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "cross-site", diff --git a/viu_media/libs/provider/anime/animepahe/mappers.py b/viu_media/libs/provider/anime/animepahe/mappers.py index ef59922..bc59dc7 100644 --- a/viu_media/libs/provider/anime/animepahe/mappers.py +++ b/viu_media/libs/provider/anime/animepahe/mappers.py @@ -98,4 +98,6 @@ def map_to_server( ) for link in stream_links ] - return Server(name="kwik", links=links, episode_title=episode.title, headers=headers) + return Server( + name="kwik", links=links, episode_title=episode.title, headers=headers + ) diff --git a/viu_media/libs/provider/anime/animepahe/provider.py b/viu_media/libs/provider/anime/animepahe/provider.py index 1e42d0a..3e0a432 100644 --- a/viu_media/libs/provider/anime/animepahe/provider.py +++ b/viu_media/libs/provider/anime/animepahe/provider.py @@ -184,9 +184,11 @@ class AnimePahe(BaseAnimeProvider): headers = { "User-Agent": self.client.headers["User-Agent"], "Host": stream_host or CDN_PROVIDER, - **STREAM_HEADERS + **STREAM_HEADERS, } - yield map_to_server(episode, translation_type, stream_links, headers=headers) + yield map_to_server( + episode, translation_type, stream_links, headers=headers + ) @lru_cache() def _get_episode_info(