"""Tests für den LLM-Parser (HTTP-Calls via Mock).""" from unittest.mock import MagicMock, patch import pytest from musiksammlung.llm_parser import _call_ollama, _call_openai_compatible, parse_tracklist VALID_JSON = ( '{"artist":"Karajan","album":"Beethoven","year":1963,' '"discs":[{"disc_number":1,"tracks":[{"track_number":1,"title":"Allegro"}]}]}' ) VALID_JSON_WITH_TRACK_ARTIST = ( '{"artist":"Various Artists","album":"Sampler","year":null,' '"discs":[{"disc_number":1,"tracks":[' '{"track_number":1,"title":"Song A","artist":"Artist X"},' '{"track_number":2,"title":"Song B","artist":"Artist Y"}' ']}]}' ) def _mock_response(content: str) -> MagicMock: """Erstellt eine Mock-httpx-Response mit dem gegebenen Content.""" resp = MagicMock() resp.raise_for_status = MagicMock() resp.json.return_value = {"message": {"content": content}} return resp def _mock_openai_response(content: str) -> MagicMock: resp = MagicMock() resp.raise_for_status = MagicMock() resp.json.return_value = {"choices": [{"message": {"content": content}}]} return resp class TestCallOllama: def test_returns_message_content(self) -> None: with patch("musiksammlung.llm_parser.httpx.post") as mock_post: mock_post.return_value = _mock_response("some response") result = _call_ollama("text", "gemma3:12b", "http://localhost:11434") assert result == "some response" def test_sends_correct_url(self) -> None: with patch("musiksammlung.llm_parser.httpx.post") as mock_post: mock_post.return_value = _mock_response("") _call_ollama("text", "gemma3:12b", "http://localhost:11434") called_url = mock_post.call_args[0][0] assert called_url == "http://localhost:11434/api/chat" def test_sends_model_and_text(self) -> None: with patch("musiksammlung.llm_parser.httpx.post") as mock_post: mock_post.return_value = _mock_response("") _call_ollama("mein text", "gemma3:12b", "http://localhost:11434") payload = mock_post.call_args[1]["json"] assert payload["model"] == "gemma3:12b" assert payload["messages"][1]["content"] == "mein text" class TestCallOpenaiCompatible: def test_returns_message_content(self) -> None: with patch("musiksammlung.llm_parser.httpx.post") as mock_post: mock_post.return_value = _mock_openai_response("openai reply") result = _call_openai_compatible("text", "gpt-4", "http://api.example.com") assert result == "openai reply" def test_sends_bearer_token_if_api_key(self) -> None: with patch("musiksammlung.llm_parser.httpx.post") as mock_post: mock_post.return_value = _mock_openai_response("") _call_openai_compatible("t", "m", "http://x", api_key="secret") headers = mock_post.call_args[1]["headers"] assert headers["Authorization"] == "Bearer secret" def test_no_auth_header_without_api_key(self) -> None: with patch("musiksammlung.llm_parser.httpx.post") as mock_post: mock_post.return_value = _mock_openai_response("") _call_openai_compatible("t", "m", "http://x") headers = mock_post.call_args[1]["headers"] assert "Authorization" not in headers class TestParseTracklist: def test_successful_parse_ollama(self) -> None: with patch("musiksammlung.llm_parser._call_ollama", return_value=VALID_JSON): album = parse_tracklist("tracklist text") assert album.artist == "Karajan" assert album.album == "Beethoven" assert album.year == 1963 assert len(album.discs[0].tracks) == 1 def test_successful_parse_openai(self) -> None: with patch( "musiksammlung.llm_parser._call_openai_compatible", return_value=VALID_JSON ): album = parse_tracklist("text", backend="openai") assert album.artist == "Karajan" def test_retries_on_invalid_json(self) -> None: responses = iter(["definitely not json", VALID_JSON]) with patch( "musiksammlung.llm_parser._call_ollama", side_effect=responses ): album = parse_tracklist("text", max_retries=2) assert album.artist == "Karajan" def test_raises_after_max_retries_exceeded(self) -> None: with patch( "musiksammlung.llm_parser._call_ollama", return_value="no json here" ): with pytest.raises(ValueError, match="kein valides JSON"): parse_tracklist("text", max_retries=1) def test_json_in_markdown_block(self) -> None: wrapped = f"```json\n{VALID_JSON}\n```" with patch("musiksammlung.llm_parser._call_ollama", return_value=wrapped): album = parse_tracklist("text") assert album.artist == "Karajan" def test_track_artist_field_parsed(self) -> None: with patch( "musiksammlung.llm_parser._call_ollama", return_value=VALID_JSON_WITH_TRACK_ARTIST, ): album = parse_tracklist("text") assert album.discs[0].tracks[0].artist == "Artist X" assert album.discs[0].tracks[1].artist == "Artist Y" def test_missing_year_defaults_to_none(self) -> None: json_no_year = ( '{"artist":"A","album":"B","year":null,' '"discs":[{"disc_number":1,"tracks":[{"track_number":1,"title":"T"}]}]}' ) with patch("musiksammlung.llm_parser._call_ollama", return_value=json_no_year): album = parse_tracklist("text") assert album.year is None