Files
Aniworld/tests/unit/test_folder_scan_service.py
Lukas 21af502184 refactor: simplify NFO handling, remove legacy services
- Drop nfo_factory, nfo_repair_service, nfo_service, series_manager_service
- Delete key_resolution_service, consolidate into folder_rename_service
- Remove bulk of NFO-related tests (coverage via integration tests)
- Streamline SeriesApp, background_loader, initialization services
- Add folder_rename_service to scheduler

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 18:54:31 +02:00

574 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for FolderScanService (Tasks 1.21.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.scheduler.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.scheduler.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.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.scheduler.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 NFO repair scan behavior - NFO service removed, now stub."""
@pytest.mark.asyncio
async def test_nfo_repair_skipped(self, folder_scan_service, tmp_path):
"""NFO repair scan is skipped since NFO service removed."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=True
), patch(
"src.server.services.scheduler.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()
# NFO repair is skipped - verify scan continues to folder rename
# No exception means the stub worked correctly
# ---------------------------------------------------------------------------
# 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.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.scheduler.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.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.scheduler.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(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
# 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(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
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.scheduler.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(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
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(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
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(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
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.scheduler.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(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
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.scheduler.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(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
# 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.scheduler.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(
"<tvshow>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
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(
"<tvshow>"
'<thumb>https://example.com/fallback.jpg</thumb>'
"</tvshow>"
)
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("<tvshow><title>Test</title></tvshow>")
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. NFO repair is now a stub."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=True
), patch(
"src.server.services.scheduler.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()
# NFO repair is now a stub - not awaited in code
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.scheduler.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()