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