Add comprehensive test coverage for Tasks 1.1–1.5 and 2.1: - test_scheduler_config_model.py: folder_scan_enabled defaults, explicit values, backward compatibility with old configs, serialization roundtrip - test_folder_scan_service.py (new): prerequisites, NFO repair integration, folder rename integration, poster check/download, semaphore values, NFO thumb URL extraction, full end-to-end scan flow - test_scheduler_service.py: scheduler _perform_rescan integration with folder_scan_enabled (called when enabled, skipped when disabled, error handling and broadcasting), folder_scan_enabled in get_status output - test_nfo_repair_startup.py: verify perform_nfo_repair_scan is NOT called during FastAPI lifespan startup and IS called from FolderScanService All 90 tests pass.
608 lines
24 KiB
Python
608 lines
24 KiB
Python
"""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(
|
||
"<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.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.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.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.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."""
|
||
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()
|