diff --git a/tests/integration/test_cli_workflows.py b/tests/integration/test_cli_workflows.py new file mode 100644 index 0000000..b07cefe --- /dev/null +++ b/tests/integration/test_cli_workflows.py @@ -0,0 +1,184 @@ +"""Integration tests for CLI workflows. + +Tests end-to-end CLI command execution using subprocess-style invocation of +the nfo_cli main() function with mocked services. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.cli.nfo_cli import main, scan_and_create_nfo, update_nfo_files + + +def _mock_serie(name: str, has_nfo: bool = False): + """Create a mock serie object.""" + s = MagicMock() + s.name = name + s.folder = name + s.has_nfo.return_value = has_nfo + s.has_poster.return_value = False + s.has_logo.return_value = False + s.has_fanart.return_value = False + return s + + +class TestScanWorkflow: + """End-to-end scan workflow.""" + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.SeriesManagerService") + @patch("src.cli.nfo_cli.settings") + async def test_scan_creates_nfo_and_closes(self, mock_settings, mock_sms): + """Full scan workflow: init → scan → close.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_auto_create = True + mock_settings.nfo_update_on_scan = False + mock_settings.nfo_download_poster = False + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + + series = [_mock_serie("Naruto"), _mock_serie("Bleach")] + mock_serie_list = MagicMock() + mock_serie_list.get_all.return_value = series + mock_serie_list.load_series = MagicMock() + + manager = MagicMock() + manager.get_serie_list.return_value = mock_serie_list + manager.scan_and_process_nfo = AsyncMock() + manager.close = AsyncMock() + mock_sms.from_settings.return_value = manager + + result = await scan_and_create_nfo() + + assert result == 0 + manager.scan_and_process_nfo.assert_awaited_once() + manager.close.assert_awaited_once() + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.SeriesManagerService") + @patch("src.cli.nfo_cli.settings") + async def test_scan_all_have_nfo_and_no_update(self, mock_settings, mock_sms): + """When all series have NFO and update_on_scan is False, returns 0 early.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_auto_create = True + mock_settings.nfo_update_on_scan = False + mock_settings.nfo_download_poster = False + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + + series = [_mock_serie("Naruto", has_nfo=True)] + mock_serie_list = MagicMock() + mock_serie_list.get_all.return_value = series + + manager = MagicMock() + manager.get_serie_list.return_value = mock_serie_list + mock_sms.from_settings.return_value = manager + + result = await scan_and_create_nfo() + assert result == 0 + + +class TestUpdateWorkflow: + """End-to-end update workflow.""" + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.asyncio") + @patch("src.cli.nfo_cli.settings") + async def test_update_processes_each_serie(self, mock_settings, mock_sleeper): + """Update calls update_tvshow_nfo for every serie with NFO.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_download_poster = True + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + mock_sleeper.sleep = AsyncMock() + + series = [ + _mock_serie("A", has_nfo=True), + _mock_serie("B", has_nfo=True), + _mock_serie("C", has_nfo=False), + ] + + nfo_svc = MagicMock() + nfo_svc.update_tvshow_nfo = AsyncMock() + nfo_svc.close = AsyncMock() + + with patch("src.core.entities.SerieList.SerieList") as mock_sl: + mock_list = MagicMock() + mock_list.get_all.return_value = series + mock_sl.return_value = mock_list + with patch( + "src.core.services.nfo_factory.create_nfo_service", + return_value=nfo_svc, + ): + result = await update_nfo_files() + + assert result == 0 + # Only A and B have NFO + assert nfo_svc.update_tvshow_nfo.await_count == 2 + nfo_svc.close.assert_awaited_once() + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.asyncio") + @patch("src.cli.nfo_cli.settings") + async def test_update_continues_on_per_series_error( + self, mock_settings, mock_sleeper + ): + """An error updating one serie doesn't stop others.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_download_poster = False + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + mock_sleeper.sleep = AsyncMock() + + series = [ + _mock_serie("OK", has_nfo=True), + _mock_serie("Fail", has_nfo=True), + ] + + nfo_svc = MagicMock() + # First call succeeds, second raises + nfo_svc.update_tvshow_nfo = AsyncMock( + side_effect=[None, RuntimeError("api down")] + ) + nfo_svc.close = AsyncMock() + + with patch("src.core.entities.SerieList.SerieList") as mock_sl: + mock_list = MagicMock() + mock_list.get_all.return_value = series + mock_sl.return_value = mock_list + with patch( + "src.core.services.nfo_factory.create_nfo_service", + return_value=nfo_svc, + ): + result = await update_nfo_files() + + assert result == 0 + assert nfo_svc.update_tvshow_nfo.await_count == 2 + + +class TestErrorHandlingWorkflows: + """Test error paths in CLI workflows.""" + + @patch("src.cli.nfo_cli.sys") + def test_main_usage_printed_on_no_args(self, mock_sys, capsys): + """Shows usage and returns 1 with no args.""" + mock_sys.argv = ["nfo_cli"] + result = main() + assert result == 1 + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_missing_config_returns_1(self, mock_settings): + """Missing required settings yields exit code 1.""" + mock_settings.tmdb_api_key = None + assert await scan_and_create_nfo() == 1 + + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = None + assert await scan_and_create_nfo() == 1 diff --git a/tests/unit/test_nfo_cli.py b/tests/unit/test_nfo_cli.py new file mode 100644 index 0000000..c564e4d --- /dev/null +++ b/tests/unit/test_nfo_cli.py @@ -0,0 +1,357 @@ +"""Unit tests for the NFO CLI module. + +Tests the CLI entry point, command dispatch, and individual command functions +from src/cli/nfo_cli.py. +""" + +import asyncio +from io import StringIO +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.cli.nfo_cli import ( + check_nfo_status, + main, + scan_and_create_nfo, + update_nfo_files, +) + + +# --------------------------------------------------------------------------- +# main() dispatcher tests +# --------------------------------------------------------------------------- + +class TestMainDispatcher: + """Tests for the main() CLI entry point.""" + + @patch("src.cli.nfo_cli.sys") + def test_no_args_shows_usage(self, mock_sys, capsys): + """No arguments prints usage text and returns 1.""" + mock_sys.argv = ["nfo_cli"] + result = main() + assert result == 1 + + @patch("src.cli.nfo_cli.asyncio") + @patch("src.cli.nfo_cli.sys") + def test_scan_command_dispatches(self, mock_sys, mock_asyncio): + """'scan' command runs scan_and_create_nfo.""" + mock_sys.argv = ["nfo_cli", "scan"] + mock_asyncio.run.return_value = 0 + result = main() + mock_asyncio.run.assert_called_once() + + @patch("src.cli.nfo_cli.asyncio") + @patch("src.cli.nfo_cli.sys") + def test_status_command_dispatches(self, mock_sys, mock_asyncio): + """'status' command runs check_nfo_status.""" + mock_sys.argv = ["nfo_cli", "status"] + mock_asyncio.run.return_value = 0 + result = main() + mock_asyncio.run.assert_called_once() + + @patch("src.cli.nfo_cli.asyncio") + @patch("src.cli.nfo_cli.sys") + def test_update_command_dispatches(self, mock_sys, mock_asyncio): + """'update' command runs update_nfo_files.""" + mock_sys.argv = ["nfo_cli", "update"] + mock_asyncio.run.return_value = 0 + result = main() + mock_asyncio.run.assert_called_once() + + @patch("src.cli.nfo_cli.sys") + def test_unknown_command_returns_1(self, mock_sys): + """Unknown command returns exit code 1.""" + mock_sys.argv = ["nfo_cli", "bogus"] + result = main() + assert result == 1 + + @patch("src.cli.nfo_cli.asyncio") + @patch("src.cli.nfo_cli.sys") + def test_command_is_case_insensitive(self, mock_sys, mock_asyncio): + """Command matching is case-insensitive.""" + mock_sys.argv = ["nfo_cli", "SCAN"] + mock_asyncio.run.return_value = 0 + main() + mock_asyncio.run.assert_called_once() + + +# --------------------------------------------------------------------------- +# scan_and_create_nfo tests +# --------------------------------------------------------------------------- + +class TestScanAndCreateNfo: + """Tests for scan_and_create_nfo command.""" + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_returns_1_without_tmdb_key(self, mock_settings): + """Returns 1 when TMDB_API_KEY is missing.""" + mock_settings.tmdb_api_key = None + result = await scan_and_create_nfo() + assert result == 1 + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_returns_1_without_anime_directory(self, mock_settings): + """Returns 1 when ANIME_DIRECTORY is missing.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = None + result = await scan_and_create_nfo() + assert result == 1 + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.SeriesManagerService") + @patch("src.cli.nfo_cli.settings") + async def test_returns_0_when_no_series_found(self, mock_settings, mock_sms): + """Returns 0 when directory has no series.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_auto_create = True + mock_settings.nfo_update_on_scan = False + mock_settings.nfo_download_poster = False + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + + mock_manager = MagicMock() + mock_serie_list = MagicMock() + mock_serie_list.get_all.return_value = [] + mock_manager.get_serie_list.return_value = mock_serie_list + mock_sms.from_settings.return_value = mock_manager + + result = await scan_and_create_nfo() + assert result == 0 + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.SeriesManagerService") + @patch("src.cli.nfo_cli.settings") + async def test_calls_scan_and_process_nfo(self, mock_settings, mock_sms): + """Processing is invoked for found series.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_auto_create = True + mock_settings.nfo_update_on_scan = False + mock_settings.nfo_download_poster = False + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + + mock_serie = MagicMock() + mock_serie.has_nfo.return_value = False + mock_serie.name = "Naruto" + mock_serie.folder = "Naruto" + mock_serie.has_poster.return_value = False + mock_serie.has_logo.return_value = False + mock_serie.has_fanart.return_value = False + + mock_serie_list = MagicMock() + mock_serie_list.get_all.return_value = [mock_serie] + mock_serie_list.load_series = MagicMock() + + mock_manager = MagicMock() + mock_manager.get_serie_list.return_value = mock_serie_list + mock_manager.scan_and_process_nfo = AsyncMock() + mock_manager.close = AsyncMock() + mock_sms.from_settings.return_value = mock_manager + + result = await scan_and_create_nfo() + assert result == 0 + mock_manager.scan_and_process_nfo.assert_awaited_once() + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.SeriesManagerService") + @patch("src.cli.nfo_cli.settings") + async def test_returns_1_on_exception(self, mock_settings, mock_sms): + """Returns 1 when processing raises.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_auto_create = True + mock_settings.nfo_update_on_scan = False + mock_settings.nfo_download_poster = False + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + + mock_serie = MagicMock() + mock_serie.has_nfo.return_value = False + mock_serie.name = "Test" + mock_serie.folder = "Test" + mock_serie_list = MagicMock() + mock_serie_list.get_all.return_value = [mock_serie] + + mock_manager = MagicMock() + mock_manager.get_serie_list.return_value = mock_serie_list + mock_manager.scan_and_process_nfo = AsyncMock( + side_effect=RuntimeError("fail") + ) + mock_manager.close = AsyncMock() + mock_sms.from_settings.return_value = mock_manager + + result = await scan_and_create_nfo() + assert result == 1 + # close is called even on error (finally block) + mock_manager.close.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# check_nfo_status tests +# --------------------------------------------------------------------------- + +class TestCheckNfoStatus: + """Tests for check_nfo_status command.""" + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_returns_1_without_anime_directory(self, mock_settings): + """Returns 1 when ANIME_DIRECTORY is missing.""" + mock_settings.anime_directory = None + result = await check_nfo_status() + assert result == 1 + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_returns_0_when_no_series(self, mock_settings): + """Returns 0 when no series found.""" + mock_settings.anime_directory = "/anime" + with patch("src.core.entities.SerieList.SerieList") as mock_sl: + mock_list = MagicMock() + mock_list.get_all.return_value = [] + mock_sl.return_value = mock_list + result = await check_nfo_status() + assert result == 0 + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_reports_series_with_and_without_nfo(self, mock_settings, capsys): + """Status report categorises series correctly.""" + mock_settings.anime_directory = "/anime" + + serie_a = MagicMock() + serie_a.has_nfo.return_value = True + serie_a.has_poster.return_value = True + serie_a.has_logo.return_value = False + serie_a.has_fanart.return_value = False + serie_a.name = "A" + serie_a.folder = "A" + + serie_b = MagicMock() + serie_b.has_nfo.return_value = False + serie_b.has_poster.return_value = False + serie_b.has_logo.return_value = False + serie_b.has_fanart.return_value = False + serie_b.name = "B" + serie_b.folder = "B" + + with patch( + "src.core.entities.SerieList.SerieList" + ) as mock_sl: + mock_list = MagicMock() + mock_list.get_all.return_value = [serie_a, serie_b] + mock_sl.return_value = mock_list + result = await check_nfo_status() + + assert result == 0 + + +# --------------------------------------------------------------------------- +# update_nfo_files tests +# --------------------------------------------------------------------------- + +class TestUpdateNfoFiles: + """Tests for update_nfo_files command.""" + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_returns_1_without_tmdb_key(self, mock_settings): + """Returns 1 when TMDB_API_KEY is missing.""" + mock_settings.tmdb_api_key = None + result = await update_nfo_files() + assert result == 1 + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_returns_1_without_anime_directory(self, mock_settings): + """Returns 1 when ANIME_DIRECTORY is missing.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = None + result = await update_nfo_files() + assert result == 1 + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_returns_0_when_no_nfo_series(self, mock_settings): + """Returns 0 when no series have NFO files.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_download_poster = False + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + + serie = MagicMock() + serie.has_nfo.return_value = False + + with patch("src.core.entities.SerieList.SerieList") as mock_sl: + mock_list = MagicMock() + mock_list.get_all.return_value = [serie] + mock_sl.return_value = mock_list + result = await update_nfo_files() + + assert result == 0 + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.asyncio") + @patch("src.cli.nfo_cli.settings") + async def test_updates_series_with_nfo(self, mock_settings, mock_sleeper): + """Calls update_tvshow_nfo for each series with NFO.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_download_poster = True + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + mock_sleeper.sleep = AsyncMock() + + serie = MagicMock() + serie.has_nfo.return_value = True + serie.name = "Naruto" + serie.folder = "Naruto" + + mock_nfo_svc = MagicMock() + mock_nfo_svc.update_tvshow_nfo = AsyncMock() + mock_nfo_svc.close = AsyncMock() + + with patch("src.core.entities.SerieList.SerieList") as mock_sl: + mock_list = MagicMock() + mock_list.get_all.return_value = [serie] + mock_sl.return_value = mock_list + with patch( + "src.core.services.nfo_factory.create_nfo_service", + return_value=mock_nfo_svc, + ): + result = await update_nfo_files() + + assert result == 0 + mock_nfo_svc.update_tvshow_nfo.assert_awaited_once() + mock_nfo_svc.close.assert_awaited_once() + + @pytest.mark.asyncio + @patch("src.cli.nfo_cli.settings") + async def test_returns_1_on_factory_error(self, mock_settings): + """Returns 1 when create_nfo_service raises ValueError.""" + mock_settings.tmdb_api_key = "key" + mock_settings.anime_directory = "/anime" + mock_settings.nfo_download_poster = False + mock_settings.nfo_download_logo = False + mock_settings.nfo_download_fanart = False + + serie = MagicMock() + serie.has_nfo.return_value = True + + with patch("src.core.entities.SerieList.SerieList") as mock_sl: + mock_list = MagicMock() + mock_list.get_all.return_value = [serie] + mock_sl.return_value = mock_list + with patch( + "src.core.services.nfo_factory.create_nfo_service", + side_effect=ValueError("bad"), + ): + result = await update_nfo_files() + + assert result == 1