Moves perform_nfo_repair_scan and its helpers (_repair_one_series, _NFO_REPAIR_SEMAPHORE) into folder_scan_service.py so NFO repair runs during the scheduled folder scan instead of on startup. - Removes NFO repair code from initialization_service.py - Updates all test imports and patch targets - Updates docs/NFO_GUIDE.md and docs/CHANGELOG.md references All 174 related tests pass.
609 lines
24 KiB
Python
609 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,
|
||
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(
|
||
"<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()
|