"""Tests for ``alfred.infrastructure.api.tmdb.client.TMDBClient``. Exercises the public surface without any real HTTP traffic: - ``TestInit`` — configuration via constructor args vs. ``Settings``; enforcement of the ``api_key``/``base_url`` invariants. - ``TestMakeRequest`` — error translation for timeouts, HTTP 401/404/5xx, and generic ``RequestException``. - ``TestSearchMulti`` — query validation, success path, empty-results → ``TMDBNotFoundError``. - ``TestGetExternalIds`` — ``media_type`` whitelist enforcement. - ``TestSearchMedia`` — happy path (movie/tv), media_type fallthrough to the next result, structural-validation error, and the case where external-ID resolution fails but the search still succeeds. - ``TestDetailsEndpoints`` — ``get_movie_details`` / ``get_tv_details``. - ``TestIsConfigured`` — reports ``True`` only when both api_key & url set. All HTTP is mocked at ``alfred.infrastructure.api.tmdb.client.requests``. """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from requests.exceptions import HTTPError, RequestException, Timeout from alfred.infrastructure.api.tmdb.client import TMDBClient from alfred.infrastructure.api.tmdb.dto import MediaResult from alfred.infrastructure.api.tmdb.exceptions import ( TMDBAPIError, TMDBConfigurationError, TMDBNotFoundError, ) # --------------------------------------------------------------------------- # # Helpers # # --------------------------------------------------------------------------- # def _ok_response(json_body): """Return a Mock that mimics a successful requests.Response.""" r = MagicMock() r.status_code = 200 r.json.return_value = json_body r.raise_for_status.return_value = None return r def _http_error_response(status_code): r = MagicMock() r.status_code = status_code err = HTTPError(f"{status_code}") err.response = r r.raise_for_status.side_effect = err return r @pytest.fixture def client(): return TMDBClient( api_key="fake-key", base_url="https://api.example.com/3", timeout=5, ) # --------------------------------------------------------------------------- # # Init / configuration # # --------------------------------------------------------------------------- # class TestInit: def test_explicit_args_win_over_settings(self): c = TMDBClient(api_key="explicit", base_url="https://x", timeout=99) assert c.api_key == "explicit" assert c.base_url == "https://x" assert c.timeout == 99 def test_missing_api_key_raises(self): from alfred.settings import Settings cfg = Settings(tmdb_api_key="", tmdb_base_url="https://x") with pytest.raises(TMDBConfigurationError, match="API key"): TMDBClient(api_key="", config=cfg) def test_missing_base_url_raises(self): # Pass api_key but force empty base_url. Need a config with empty URL too. from alfred.settings import Settings cfg = Settings(tmdb_api_key="fake", tmdb_base_url="") with pytest.raises(TMDBConfigurationError, match="base URL"): TMDBClient(config=cfg, base_url="") # --------------------------------------------------------------------------- # # _make_request — error translation # # --------------------------------------------------------------------------- # class TestMakeRequest: @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_timeout_translated(self, mock_get, client): mock_get.side_effect = Timeout("slow") with pytest.raises(TMDBAPIError, match="timeout"): client._make_request("/x") @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_http_401_invalid_key(self, mock_get, client): mock_get.return_value = _http_error_response(401) with pytest.raises(TMDBAPIError, match="Invalid"): client._make_request("/x") @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_http_404_not_found(self, mock_get, client): mock_get.return_value = _http_error_response(404) with pytest.raises(TMDBNotFoundError): client._make_request("/x") @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_http_500_generic(self, mock_get, client): mock_get.return_value = _http_error_response(500) with pytest.raises(TMDBAPIError, match="500"): client._make_request("/x") @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_request_exception_translated(self, mock_get, client): mock_get.side_effect = RequestException("network down") with pytest.raises(TMDBAPIError, match="connect"): client._make_request("/x") @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_api_key_added_to_params(self, mock_get, client): mock_get.return_value = _ok_response({"ok": True}) client._make_request("/path", {"q": "foo"}) called_kwargs = mock_get.call_args.kwargs assert called_kwargs["params"]["api_key"] == "fake-key" assert called_kwargs["params"]["q"] == "foo" assert called_kwargs["timeout"] == 5 # --------------------------------------------------------------------------- # # search_multi # # --------------------------------------------------------------------------- # class TestSearchMulti: @pytest.mark.parametrize("bad", ["", None, 123]) def test_invalid_query_raises_value_error(self, client, bad): with pytest.raises(ValueError): client.search_multi(bad) def test_query_too_long(self, client): with pytest.raises(ValueError, match="too long"): client.search_multi("a" * 501) @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_success(self, mock_get, client): mock_get.return_value = _ok_response( {"results": [{"id": 1, "media_type": "movie"}]} ) results = client.search_multi("Inception") assert len(results) == 1 assert results[0]["id"] == 1 @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_empty_results_raise_not_found(self, mock_get, client): mock_get.return_value = _ok_response({"results": []}) with pytest.raises(TMDBNotFoundError): client.search_multi("nothing") # --------------------------------------------------------------------------- # # get_external_ids # # --------------------------------------------------------------------------- # class TestGetExternalIds: def test_invalid_media_type(self, client): with pytest.raises(ValueError, match="media_type"): client.get_external_ids("game", 42) @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_movie(self, mock_get, client): mock_get.return_value = _ok_response({"imdb_id": "tt1375666"}) result = client.get_external_ids("movie", 27205) assert result["imdb_id"] == "tt1375666" @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_tv(self, mock_get, client): mock_get.return_value = _ok_response({"imdb_id": "tt0903747"}) result = client.get_external_ids("tv", 1396) assert result["imdb_id"] == "tt0903747" # --------------------------------------------------------------------------- # # search_media (composite) # # --------------------------------------------------------------------------- # class TestSearchMedia: @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_happy_path_movie(self, mock_get, client): # First call → /search/multi ; second → /movie/X/external_ids mock_get.side_effect = [ _ok_response( { "results": [ { "id": 27205, "media_type": "movie", "title": "Inception", "overview": "...", "release_date": "2010-07-15", "poster_path": "/x.jpg", "vote_average": 8.4, } ] } ), _ok_response({"imdb_id": "tt1375666"}), ] result = client.search_media("Inception") assert isinstance(result, MediaResult) assert result.title == "Inception" assert result.imdb_id == "tt1375666" assert result.media_type == "movie" assert result.vote_average == 8.4 @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_tv_uses_name_field(self, mock_get, client): mock_get.side_effect = [ _ok_response( {"results": [{"id": 1396, "media_type": "tv", "name": "Breaking Bad"}]} ), _ok_response({"imdb_id": "tt0903747"}), ] result = client.search_media("Breaking Bad") assert result.title == "Breaking Bad" assert result.media_type == "tv" @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_person_result_skipped_uses_next(self, mock_get, client): # First result is a person → falls through to second result. mock_get.side_effect = [ _ok_response( { "results": [ {"id": 1, "media_type": "person", "name": "X"}, {"id": 2, "media_type": "movie", "title": "Y"}, ] } ), _ok_response({"imdb_id": "tt7654321"}), ] result = client.search_media("Y") assert result.title == "Y" assert result.media_type == "movie" @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_only_person_result_raises_not_found(self, mock_get, client): mock_get.return_value = _ok_response( {"results": [{"id": 1, "media_type": "person", "name": "X"}]} ) with pytest.raises(TMDBNotFoundError): client.search_media("X") @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_malformed_top_result_raises(self, mock_get, client): mock_get.return_value = _ok_response( {"results": [{"title": "no id or media_type"}]} ) with pytest.raises(TMDBAPIError, match="Invalid"): client.search_media("X") @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_external_ids_failure_returns_result_without_imdb(self, mock_get, client): # Second call (external IDs) fails — the search should still succeed. mock_get.side_effect = [ _ok_response({"results": [{"id": 1, "media_type": "movie", "title": "X"}]}), Timeout("slow"), ] result = client.search_media("X") assert result.imdb_id is None # --------------------------------------------------------------------------- # # Details endpoints # # --------------------------------------------------------------------------- # class TestDetailsEndpoints: @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_movie_details(self, mock_get, client): mock_get.return_value = _ok_response({"id": 27205, "runtime": 148}) result = client.get_movie_details(27205) assert result["runtime"] == 148 @patch("alfred.infrastructure.api.tmdb.client.requests.get") def test_tv_details(self, mock_get, client): mock_get.return_value = _ok_response({"id": 1396, "number_of_seasons": 5}) result = client.get_tv_details(1396) assert result["number_of_seasons"] == 5 class TestIsConfigured: def test_true_when_complete(self, client): assert client.is_configured() is True