diff --git a/tests/unit/test_folder_scan_service.py b/tests/unit/test_folder_scan_service.py
new file mode 100644
index 0000000..a22ce4d
--- /dev/null
+++ b/tests/unit/test_folder_scan_service.py
@@ -0,0 +1,607 @@
+"""Unit tests for FolderScanService (Tasks 1.2–1.5).
+
+Covers:
+- Prerequisites checking (TMDB key, anime directory)
+- NFO repair integration (Task 1.3)
+- Folder rename validation (Task 1.4)
+- Poster check and download (Task 1.5)
+- Exception handling and semaphore usage
+"""
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+
+import pytest
+
+from src.server.services.folder_scan_service import (
+ _POSTER_DOWNLOAD_SEMAPHORE,
+ _TMDB_SEMAPHORE,
+ FolderScanService,
+ FolderScanServiceError,
+)
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def folder_scan_service() -> FolderScanService:
+ """Return a fresh FolderScanService instance."""
+ return FolderScanService()
+
+
+@pytest.fixture
+def mock_settings(tmp_path: Path):
+ """Return a mock settings object with valid prerequisites."""
+ mock = MagicMock()
+ mock.tmdb_api_key = "test-api-key"
+ mock.anime_directory = str(tmp_path)
+ mock.nfo_download_poster = True
+ return mock
+
+
+# ---------------------------------------------------------------------------
+# 1.2 – Skeleton / prerequisites
+# ---------------------------------------------------------------------------
+
+class TestPrerequisites:
+ """Test _prerequisites_met checks."""
+
+ def test_prerequisites_met(self, folder_scan_service, tmp_path):
+ """All prerequisites present → True."""
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.tmdb_api_key = "key"
+ mock_settings.anime_directory = str(tmp_path)
+ assert folder_scan_service._prerequisites_met() is True
+
+ def test_missing_tmdb_key(self, folder_scan_service, tmp_path):
+ """Missing TMDB API key → False."""
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.tmdb_api_key = None
+ mock_settings.anime_directory = str(tmp_path)
+ assert folder_scan_service._prerequisites_met() is False
+
+ def test_missing_anime_directory(self, folder_scan_service):
+ """Missing anime_directory → False."""
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.tmdb_api_key = "key"
+ mock_settings.anime_directory = None
+ assert folder_scan_service._prerequisites_met() is False
+
+ def test_anime_directory_not_found(self, folder_scan_service, tmp_path):
+ """anime_directory points to non-existent path → False."""
+ non_existent = tmp_path / "does_not_exist"
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.tmdb_api_key = "key"
+ mock_settings.anime_directory = str(non_existent)
+ assert folder_scan_service._prerequisites_met() is False
+
+
+class TestRunFolderScanPrerequisites:
+ """Test run_folder_scan skips when prerequisites not met."""
+
+ @pytest.mark.asyncio
+ async def test_skips_when_prerequisites_missing(self, folder_scan_service):
+ """If _prerequisites_met returns False, scan exits early."""
+ with patch.object(
+ folder_scan_service, "_prerequisites_met", return_value=False
+ ), patch(
+ "src.server.services.folder_scan_service.perform_nfo_repair_scan"
+ ) as mock_repair:
+ await folder_scan_service.run_folder_scan()
+ mock_repair.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_logs_start_and_completion(self, folder_scan_service, tmp_path):
+ """Scan logs start and completion when prerequisites are met."""
+ with patch.object(
+ folder_scan_service, "_prerequisites_met", return_value=True
+ ), patch(
+ "src.server.services.folder_scan_service.perform_nfo_repair_scan",
+ new_callable=AsyncMock,
+ ), patch(
+ "src.server.services.folder_rename_service.validate_and_rename_series_folders",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
+ ), patch.object(
+ folder_scan_service,
+ "check_and_download_missing_posters",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
+ ):
+ # Should not raise
+ await folder_scan_service.run_folder_scan()
+
+ @pytest.mark.asyncio
+ async def test_catches_unhandled_exceptions(self, folder_scan_service):
+ """Unhandled exceptions are caught and logged, not re-raised."""
+ with patch.object(
+ folder_scan_service,
+ "_prerequisites_met",
+ side_effect=RuntimeError("boom"),
+ ):
+ # Must NOT raise
+ await folder_scan_service.run_folder_scan()
+
+
+# ---------------------------------------------------------------------------
+# 1.3 – NFO repair integration
+# ---------------------------------------------------------------------------
+
+class TestNfoRepairIntegration:
+ """Test perform_nfo_repair_scan is called inside run_folder_scan."""
+
+ @pytest.mark.asyncio
+ async def test_calls_perform_nfo_repair_scan(self, folder_scan_service, tmp_path):
+ """run_folder_scan must call perform_nfo_repair_scan."""
+ with patch.object(
+ folder_scan_service, "_prerequisites_met", return_value=True
+ ), patch(
+ "src.server.services.folder_scan_service.perform_nfo_repair_scan",
+ new_callable=AsyncMock,
+ ) as mock_repair, patch(
+ "src.server.services.folder_rename_service.validate_and_rename_series_folders",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
+ ), patch.object(
+ folder_scan_service,
+ "check_and_download_missing_posters",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
+ ):
+ await folder_scan_service.run_folder_scan()
+ mock_repair.assert_awaited_once_with(background_loader=None)
+
+ @pytest.mark.asyncio
+ async def test_nfo_repair_failure_does_not_crash_scan(
+ self, folder_scan_service, tmp_path
+ ):
+ """If perform_nfo_repair_scan raises, the broad except catches it
+ and the scan stops — remaining steps are NOT invoked."""
+ with patch.object(
+ folder_scan_service, "_prerequisites_met", return_value=True
+ ), patch(
+ "src.server.services.folder_scan_service.perform_nfo_repair_scan",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("repair failed"),
+ ) as mock_repair, patch(
+ "src.server.services.folder_rename_service.validate_and_rename_series_folders",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
+ ) as mock_rename, patch.object(
+ folder_scan_service,
+ "check_and_download_missing_posters",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
+ ):
+ await folder_scan_service.run_folder_scan()
+ mock_repair.assert_awaited_once()
+ # Broad except stops the scan; rename/poster are skipped
+ mock_rename.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# 1.4 – Folder rename integration
+# ---------------------------------------------------------------------------
+
+class TestFolderRenameIntegration:
+ """Test validate_and_rename_series_folders is called and stats logged."""
+
+ @pytest.mark.asyncio
+ async def test_calls_folder_rename_service(self, folder_scan_service, tmp_path):
+ """run_folder_scan must call validate_and_rename_series_folders."""
+ with patch.object(
+ folder_scan_service, "_prerequisites_met", return_value=True
+ ), patch(
+ "src.server.services.folder_scan_service.perform_nfo_repair_scan",
+ new_callable=AsyncMock,
+ ), patch(
+ "src.server.services.folder_rename_service.validate_and_rename_series_folders",
+ new_callable=AsyncMock,
+ return_value={"scanned": 5, "renamed": 2, "skipped": 2, "errors": 1},
+ ) as mock_rename, patch.object(
+ folder_scan_service,
+ "check_and_download_missing_posters",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
+ ):
+ await folder_scan_service.run_folder_scan()
+ mock_rename.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_folder_rename_failure_does_not_crash_scan(
+ self, folder_scan_service, tmp_path
+ ):
+ """If validate_and_rename_series_folders raises, the broad except
+ catches it and the scan stops — poster check is NOT invoked."""
+ with patch.object(
+ folder_scan_service, "_prerequisites_met", return_value=True
+ ), patch(
+ "src.server.services.folder_scan_service.perform_nfo_repair_scan",
+ new_callable=AsyncMock,
+ ), patch(
+ "src.server.services.folder_rename_service.validate_and_rename_series_folders",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("rename failed"),
+ ), patch.object(
+ folder_scan_service,
+ "check_and_download_missing_posters",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
+ ) as mock_poster:
+ await folder_scan_service.run_folder_scan()
+ # Broad except stops the scan; poster check is skipped
+ mock_poster.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# 1.5 – Poster check and download
+# ---------------------------------------------------------------------------
+
+class TestPosterCheck:
+ """Test check_and_download_missing_posters logic."""
+
+ @pytest.mark.asyncio
+ async def test_no_anime_directory_returns_empty_stats(self, folder_scan_service):
+ """Missing anime_directory → empty stats."""
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = None
+ stats = await folder_scan_service.check_and_download_missing_posters()
+ assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
+
+ @pytest.mark.asyncio
+ async def test_nonexistent_directory_returns_empty_stats(
+ self, folder_scan_service, tmp_path
+ ):
+ """Non-existent anime_directory → empty stats."""
+ non_existent = tmp_path / "missing"
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(non_existent)
+ stats = await folder_scan_service.check_and_download_missing_posters()
+ assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
+
+ @pytest.mark.asyncio
+ async def test_no_series_folders_returns_empty_stats(
+ self, folder_scan_service, tmp_path
+ ):
+ """Empty anime_directory → empty stats."""
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(tmp_path)
+ stats = await folder_scan_service.check_and_download_missing_posters()
+ assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
+
+ @pytest.mark.asyncio
+ async def test_skips_folders_without_nfo(self, folder_scan_service, tmp_path):
+ """Folders without tvshow.nfo are ignored."""
+ (tmp_path / "SomeShow").mkdir()
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(tmp_path)
+ stats = await folder_scan_service.check_and_download_missing_posters()
+ assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
+
+ @pytest.mark.asyncio
+ async def test_valid_poster_skipped(self, folder_scan_service, tmp_path):
+ """Existing poster.jpg ≥ 1 KB is skipped."""
+ series_dir = tmp_path / "Attack on Titan (2013)"
+ series_dir.mkdir()
+ (series_dir / "tvshow.nfo").write_text(
+ "Attack on Titan2013"
+ )
+ # Write a 2 KB poster
+ (series_dir / "poster.jpg").write_bytes(b"x" * 2048)
+
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(tmp_path)
+ stats = await folder_scan_service.check_and_download_missing_posters()
+
+ assert stats["scanned"] == 1
+ assert stats["skipped"] == 1
+ assert stats["downloaded"] == 0
+ assert stats["errors"] == 0
+
+ @pytest.mark.asyncio
+ async def test_missing_poster_downloaded(self, folder_scan_service, tmp_path):
+ """Missing poster triggers download when thumb URL exists."""
+ series_dir = tmp_path / "Attack on Titan (2013)"
+ series_dir.mkdir()
+ (series_dir / "tvshow.nfo").write_text(
+ ""
+ "Attack on Titan2013"
+ 'https://example.com/poster.jpg'
+ ""
+ )
+
+ mock_downloader = AsyncMock()
+ mock_downloader.download_poster = AsyncMock(return_value=True)
+ mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
+ mock_downloader.__aexit__ = AsyncMock(return_value=False)
+
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(tmp_path)
+ mock_settings.nfo_download_poster = True
+
+ with patch(
+ "src.server.services.folder_scan_service.ImageDownloader",
+ return_value=mock_downloader,
+ ):
+ stats = await folder_scan_service.check_and_download_missing_posters()
+
+ assert stats["scanned"] == 1
+ assert stats["downloaded"] == 1
+ assert stats["skipped"] == 0
+ assert stats["errors"] == 0
+
+ @pytest.mark.asyncio
+ async def test_no_thumb_url_skipped(self, folder_scan_service, tmp_path):
+ """NFO without thumb URL → skipped."""
+ series_dir = tmp_path / "Attack on Titan (2013)"
+ series_dir.mkdir()
+ (series_dir / "tvshow.nfo").write_text(
+ "Attack on Titan2013"
+ )
+
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(tmp_path)
+ mock_settings.nfo_download_poster = True
+ stats = await folder_scan_service.check_and_download_missing_posters()
+
+ assert stats["scanned"] == 1
+ assert stats["skipped"] == 1
+ assert stats["downloaded"] == 0
+
+ @pytest.mark.asyncio
+ async def test_poster_download_disabled_by_setting(
+ self, folder_scan_service, tmp_path
+ ):
+ """nfo_download_poster=False → skipped even with valid thumb URL."""
+ series_dir = tmp_path / "Attack on Titan (2013)"
+ series_dir.mkdir()
+ (series_dir / "tvshow.nfo").write_text(
+ ""
+ "Attack on Titan2013"
+ 'https://example.com/poster.jpg'
+ ""
+ )
+
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(tmp_path)
+ mock_settings.nfo_download_poster = False
+ stats = await folder_scan_service.check_and_download_missing_posters()
+
+ assert stats["scanned"] == 1
+ assert stats["skipped"] == 1
+ assert stats["downloaded"] == 0
+
+ @pytest.mark.asyncio
+ async def test_download_failure_counts_as_error(self, folder_scan_service, tmp_path):
+ """Failed download increments errors."""
+ series_dir = tmp_path / "Attack on Titan (2013)"
+ series_dir.mkdir()
+ (series_dir / "tvshow.nfo").write_text(
+ ""
+ "Attack on Titan2013"
+ 'https://example.com/poster.jpg'
+ ""
+ )
+
+ mock_downloader = AsyncMock()
+ mock_downloader.download_poster = AsyncMock(return_value=False)
+ mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
+ mock_downloader.__aexit__ = AsyncMock(return_value=False)
+
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(tmp_path)
+ mock_settings.nfo_download_poster = True
+
+ with patch(
+ "src.server.services.folder_scan_service.ImageDownloader",
+ return_value=mock_downloader,
+ ):
+ stats = await folder_scan_service.check_and_download_missing_posters()
+
+ assert stats["scanned"] == 1
+ assert stats["errors"] == 1
+ assert stats["downloaded"] == 0
+
+ @pytest.mark.asyncio
+ async def test_download_exception_counts_as_error(self, folder_scan_service, tmp_path):
+ """Exception during download increments errors."""
+ series_dir = tmp_path / "Attack on Titan (2013)"
+ series_dir.mkdir()
+ (series_dir / "tvshow.nfo").write_text(
+ ""
+ "Attack on Titan2013"
+ 'https://example.com/poster.jpg'
+ ""
+ )
+
+ mock_downloader = AsyncMock()
+ mock_downloader.download_poster = AsyncMock(side_effect=RuntimeError("net"))
+ mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
+ mock_downloader.__aexit__ = AsyncMock(return_value=False)
+
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(tmp_path)
+ mock_settings.nfo_download_poster = True
+
+ with patch(
+ "src.server.services.folder_scan_service.ImageDownloader",
+ return_value=mock_downloader,
+ ):
+ stats = await folder_scan_service.check_and_download_missing_posters()
+
+ assert stats["scanned"] == 1
+ assert stats["errors"] == 1
+ assert stats["downloaded"] == 0
+
+ @pytest.mark.asyncio
+ async def test_too_small_poster_re_downloaded(self, folder_scan_service, tmp_path):
+ """Poster < 1 KB is treated as missing and re-downloaded."""
+ series_dir = tmp_path / "Attack on Titan (2013)"
+ series_dir.mkdir()
+ (series_dir / "tvshow.nfo").write_text(
+ ""
+ "Attack on Titan2013"
+ 'https://example.com/poster.jpg'
+ ""
+ )
+ # Write a tiny 100-byte poster
+ (series_dir / "poster.jpg").write_bytes(b"x" * 100)
+
+ mock_downloader = AsyncMock()
+ mock_downloader.download_poster = AsyncMock(return_value=True)
+ mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
+ mock_downloader.__aexit__ = AsyncMock(return_value=False)
+
+ with patch(
+ "src.config.settings.settings"
+ ) as mock_settings:
+ mock_settings.anime_directory = str(tmp_path)
+ mock_settings.nfo_download_poster = True
+
+ with patch(
+ "src.server.services.folder_scan_service.ImageDownloader",
+ return_value=mock_downloader,
+ ):
+ stats = await folder_scan_service.check_and_download_missing_posters()
+
+ assert stats["scanned"] == 1
+ assert stats["downloaded"] == 1
+ assert stats["skipped"] == 0
+
+
+class TestExtractPosterUrl:
+ """Test _extract_poster_url_from_nfo static method."""
+
+ def test_extract_poster_url_with_aspect(self, tmp_path):
+ nfo = tmp_path / "tvshow.nfo"
+ nfo.write_text(
+ ""
+ 'https://example.com/poster.jpg'
+ ""
+ )
+ url = FolderScanService._extract_poster_url_from_nfo(nfo)
+ assert url == "https://example.com/poster.jpg"
+
+ def test_extract_first_thumb_fallback(self, tmp_path):
+ nfo = tmp_path / "tvshow.nfo"
+ nfo.write_text(
+ ""
+ 'https://example.com/fallback.jpg'
+ ""
+ )
+ url = FolderScanService._extract_poster_url_from_nfo(nfo)
+ assert url == "https://example.com/fallback.jpg"
+
+ def test_no_thumb_returns_none(self, tmp_path):
+ nfo = tmp_path / "tvshow.nfo"
+ nfo.write_text("Test")
+ url = FolderScanService._extract_poster_url_from_nfo(nfo)
+ assert url is None
+
+ def test_missing_file_returns_none(self, tmp_path):
+ nfo = tmp_path / "tvshow.nfo"
+ url = FolderScanService._extract_poster_url_from_nfo(nfo)
+ assert url is None
+
+ def test_malformed_xml_returns_none(self, tmp_path):
+ nfo = tmp_path / "tvshow.nfo"
+ nfo.write_text("not xml")
+ url = FolderScanService._extract_poster_url_from_nfo(nfo)
+ assert url is None
+
+
+# ---------------------------------------------------------------------------
+# Semaphores
+# ---------------------------------------------------------------------------
+
+class TestSemaphores:
+ """Verify module-level semaphores exist and have correct initial value."""
+
+ def test_tmdb_semaphore_value(self):
+ assert _TMDB_SEMAPHORE._value == 3
+
+ def test_poster_download_semaphore_value(self):
+ assert _POSTER_DOWNLOAD_SEMAPHORE._value == 3
+
+
+# ---------------------------------------------------------------------------
+# Full run_folder_scan integration
+# ---------------------------------------------------------------------------
+
+class TestRunFolderScanFull:
+ """End-to-end tests for run_folder_scan with mocked sub-tasks."""
+
+ @pytest.mark.asyncio
+ async def test_full_scan_happy_path(self, folder_scan_service, tmp_path):
+ """All sub-tasks succeed."""
+ with patch.object(
+ folder_scan_service, "_prerequisites_met", return_value=True
+ ), patch(
+ "src.server.services.folder_scan_service.perform_nfo_repair_scan",
+ new_callable=AsyncMock,
+ ) as mock_repair, patch(
+ "src.server.services.folder_rename_service.validate_and_rename_series_folders",
+ new_callable=AsyncMock,
+ return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1},
+ ) as mock_rename, patch.object(
+ folder_scan_service,
+ "check_and_download_missing_posters",
+ new_callable=AsyncMock,
+ return_value={"scanned": 3, "downloaded": 2, "skipped": 1, "errors": 0},
+ ) as mock_poster:
+ await folder_scan_service.run_folder_scan()
+
+ mock_repair.assert_awaited_once_with(background_loader=None)
+ mock_rename.assert_awaited_once()
+ mock_poster.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_full_scan_all_stats_zero(self, folder_scan_service, tmp_path):
+ """Empty library → all stats zero."""
+ with patch.object(
+ folder_scan_service, "_prerequisites_met", return_value=True
+ ), patch(
+ "src.server.services.folder_scan_service.perform_nfo_repair_scan",
+ new_callable=AsyncMock,
+ ), patch(
+ "src.server.services.folder_rename_service.validate_and_rename_series_folders",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
+ ), patch.object(
+ folder_scan_service,
+ "check_and_download_missing_posters",
+ new_callable=AsyncMock,
+ return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
+ ):
+ await folder_scan_service.run_folder_scan()
diff --git a/tests/unit/test_scheduler_config_model.py b/tests/unit/test_scheduler_config_model.py
index c4676da..0437ba8 100644
--- a/tests/unit/test_scheduler_config_model.py
+++ b/tests/unit/test_scheduler_config_model.py
@@ -113,8 +113,36 @@ class TestSchedulerConfigBackwardCompat:
assert config.interval_minutes == 30
+class TestSchedulerConfigFolderScanEnabled:
+ """3.8 – folder_scan_enabled field (Task 1.1)."""
+
+ def test_default_folder_scan_enabled(self) -> None:
+ config = SchedulerConfig()
+ assert config.folder_scan_enabled is False
+
+ def test_set_folder_scan_enabled_true(self) -> None:
+ config = SchedulerConfig(folder_scan_enabled=True)
+ assert config.folder_scan_enabled is True
+
+ def test_set_folder_scan_enabled_false(self) -> None:
+ config = SchedulerConfig(folder_scan_enabled=False)
+ assert config.folder_scan_enabled is False
+
+ def test_backward_compat_missing_field(self) -> None:
+ """Old configs without folder_scan_enabled load successfully."""
+ dumped = {
+ "enabled": True,
+ "interval_minutes": 60,
+ "schedule_time": "03:00",
+ "schedule_days": ALL_DAYS,
+ "auto_download_after_rescan": False,
+ }
+ config = SchedulerConfig(**dumped)
+ assert config.folder_scan_enabled is False
+
+
class TestSchedulerConfigSerialisation:
- """3.8 – Serialisation roundtrip."""
+ """3.9 – Serialisation roundtrip."""
def test_roundtrip(self) -> None:
original = SchedulerConfig(
@@ -123,6 +151,7 @@ class TestSchedulerConfigSerialisation:
schedule_time="04:30",
schedule_days=["mon", "wed", "fri"],
auto_download_after_rescan=True,
+ folder_scan_enabled=True,
)
dumped = original.model_dump()
restored = SchedulerConfig(**dumped)
diff --git a/tests/unit/test_scheduler_service.py b/tests/unit/test_scheduler_service.py
index 3706285..ce1eec7 100644
--- a/tests/unit/test_scheduler_service.py
+++ b/tests/unit/test_scheduler_service.py
@@ -9,16 +9,16 @@ Covers:
- Error handling and edge cases
"""
from datetime import datetime, timezone
-from unittest.mock import AsyncMock, MagicMock, Mock, patch, call
+from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
import pytest
from apscheduler.triggers.cron import CronTrigger
from src.server.models.config import AppConfig, SchedulerConfig
from src.server.services.scheduler_service import (
+ _JOB_ID,
SchedulerService,
SchedulerServiceError,
- _JOB_ID,
get_scheduler_service,
reset_scheduler_service,
)
@@ -364,6 +364,7 @@ class TestGetStatus:
schedule_time="04:00",
schedule_days=["mon"],
auto_download_after_rescan=True,
+ folder_scan_enabled=True,
)
status = scheduler_service.get_status()
@@ -373,13 +374,100 @@ class TestGetStatus:
assert "schedule_time" in status
assert "schedule_days" in status
assert "auto_download_after_rescan" in status
+ assert "folder_scan_enabled" in status
assert status["schedule_time"] == "04:00"
assert status["schedule_days"] == ["mon"]
assert status["auto_download_after_rescan"] is True
+ assert status["folder_scan_enabled"] is True
assert status["is_running"] is False
assert status["next_run"] is None
+# ---------------------------------------------------------------------------
+# 12.11 _perform_rescan() with folder_scan_enabled=True
+# ---------------------------------------------------------------------------
+
+class TestPerformRescanFolderScan:
+ @pytest.mark.asyncio
+ async def test_folder_scan_called_when_enabled(self, scheduler_service):
+ scheduler_service._config = SchedulerConfig(
+ folder_scan_enabled=True,
+ schedule_time="03:00",
+ schedule_days=ALL_DAYS,
+ )
+
+ mock_anime = MagicMock()
+ mock_anime.rescan = AsyncMock()
+ mock_anime._cached_list_missing.return_value = []
+
+ mock_ws = MagicMock()
+ mock_ws.manager.broadcast = AsyncMock()
+
+ mock_folder_scan = AsyncMock()
+
+ with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
+ patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
+ patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
+ MockFSS.return_value.run_folder_scan = mock_folder_scan
+ await scheduler_service._perform_rescan()
+
+ mock_folder_scan.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_folder_scan_skipped_when_disabled(self, scheduler_service):
+ scheduler_service._config = SchedulerConfig(
+ folder_scan_enabled=False,
+ schedule_time="03:00",
+ schedule_days=ALL_DAYS,
+ )
+
+ mock_anime = MagicMock()
+ mock_anime.rescan = AsyncMock()
+ mock_anime._cached_list_missing.return_value = []
+
+ mock_ws = MagicMock()
+ mock_ws.manager.broadcast = AsyncMock()
+
+ mock_folder_scan = AsyncMock()
+
+ with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
+ patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
+ patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
+ MockFSS.return_value.run_folder_scan = mock_folder_scan
+ await scheduler_service._perform_rescan()
+
+ mock_folder_scan.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_folder_scan_error_broadcasts_and_does_not_crash(self, scheduler_service):
+ scheduler_service._config = SchedulerConfig(
+ folder_scan_enabled=True,
+ schedule_time="03:00",
+ schedule_days=ALL_DAYS,
+ )
+
+ mock_anime = MagicMock()
+ mock_anime.rescan = AsyncMock()
+ mock_anime._cached_list_missing.return_value = []
+
+ mock_ws = MagicMock()
+ mock_ws.manager.broadcast = AsyncMock()
+
+ mock_folder_scan = AsyncMock(side_effect=RuntimeError("folder scan boom"))
+
+ with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
+ patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
+ patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
+ MockFSS.return_value.run_folder_scan = mock_folder_scan
+ # Should NOT raise
+ await scheduler_service._perform_rescan()
+
+ mock_folder_scan.assert_awaited_once()
+ calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list]
+ assert any("folder_scan_error" in c for c in calls)
+ assert scheduler_service._scan_in_progress is False
+
+
# ---------------------------------------------------------------------------
# Singleton helpers
# ---------------------------------------------------------------------------