"""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,
perform_nfo_repair_scan,
)
# ---------------------------------------------------------------------------
# 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()