backzup
This commit is contained in:
@@ -96,11 +96,11 @@ class TestConfigServiceLoadSave:
|
||||
assert loaded_config.other == sample_config.other
|
||||
|
||||
def test_save_and_load_scheduler_flags_roundtrip(self, config_service):
|
||||
"""Scheduler auto_download_after_rescan and folder_scan_enabled must
|
||||
"""Scheduler auto_download_after_rescan must
|
||||
survive a full save/load roundtrip through ConfigService.
|
||||
|
||||
Regression test for a bug where null legacy alias fields
|
||||
(auto_download=None, folder_scan=None) were written to config.json
|
||||
(auto_download=None) were written to config.json
|
||||
on save. On reload the alias mapping was skipped (because the keys
|
||||
were present), causing the primary boolean fields to reset to False.
|
||||
"""
|
||||
@@ -108,7 +108,6 @@ class TestConfigServiceLoadSave:
|
||||
scheduler=SchedulerConfig(
|
||||
enabled=True,
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
)
|
||||
config_service.save_config(original, create_backup=False)
|
||||
@@ -117,14 +116,11 @@ class TestConfigServiceLoadSave:
|
||||
with open(config_service.config_path, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
assert "auto_download" not in raw["scheduler"]
|
||||
assert "folder_scan" not in raw["scheduler"]
|
||||
assert raw["scheduler"]["auto_download_after_rescan"] is True
|
||||
assert raw["scheduler"]["folder_scan_enabled"] is True
|
||||
|
||||
# Verify loaded config preserves values
|
||||
loaded = config_service.load_config()
|
||||
assert loaded.scheduler.auto_download_after_rescan is True
|
||||
assert loaded.scheduler.folder_scan_enabled is True
|
||||
|
||||
def test_save_includes_version(self, config_service, sample_config):
|
||||
"""Test that saved config includes version field."""
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
"""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.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.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.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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1.4 – Folder rename (removed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFolderRenameRemoved:
|
||||
"""Folder rename validation was removed; scan continues to poster check."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_rename_skipped_poster_check_runs(
|
||||
self, folder_scan_service, tmp_path
|
||||
):
|
||||
"""Folder rename is skipped; scan continues to poster check."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch.object(
|
||||
folder_scan_service,
|
||||
"check_and_download_missing_posters",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 5, "downloaded": 2, "skipped": 2, "errors": 1},
|
||||
) as mock_poster:
|
||||
await folder_scan_service.run_folder_scan()
|
||||
mock_poster.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 and folder rename are stubs."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), 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_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.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()
|
||||
@@ -113,34 +113,6 @@ 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 TestSchedulerConfigLegacyAliases:
|
||||
"""3.10 – Legacy config key aliases (auto_download, folder_scan)."""
|
||||
|
||||
@@ -148,27 +120,6 @@ class TestSchedulerConfigLegacyAliases:
|
||||
"""Legacy auto_download=true maps to auto_download_after_rescan=True."""
|
||||
config = SchedulerConfig(auto_download=True)
|
||||
assert config.auto_download_after_rescan is True
|
||||
assert config.folder_scan_enabled is False
|
||||
|
||||
def test_legacy_auto_download_false(self) -> None:
|
||||
config = SchedulerConfig(auto_download=False)
|
||||
assert config.auto_download_after_rescan is False
|
||||
|
||||
def test_legacy_folder_scan_true(self) -> None:
|
||||
"""Legacy folder_scan=true maps to folder_scan_enabled=True."""
|
||||
config = SchedulerConfig(folder_scan=True)
|
||||
assert config.folder_scan_enabled is True
|
||||
assert config.auto_download_after_rescan is False
|
||||
|
||||
def test_legacy_folder_scan_false(self) -> None:
|
||||
config = SchedulerConfig(folder_scan=False)
|
||||
assert config.folder_scan_enabled is False
|
||||
|
||||
def test_legacy_both_set(self) -> None:
|
||||
"""Both legacy keys can be set simultaneously."""
|
||||
config = SchedulerConfig(auto_download=True, folder_scan=True)
|
||||
assert config.auto_download_after_rescan is True
|
||||
assert config.folder_scan_enabled is True
|
||||
|
||||
def test_explicit_primary_overrides_legacy(self) -> None:
|
||||
"""Primary field explicitly set to False still wins over legacy True.
|
||||
@@ -180,12 +131,9 @@ class TestSchedulerConfigLegacyAliases:
|
||||
config = SchedulerConfig(
|
||||
auto_download=True,
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
# Both set to True — no conflict possible when both agree
|
||||
assert config.auto_download_after_rescan is True
|
||||
assert config.folder_scan_enabled is True
|
||||
|
||||
def test_explicit_primary_false_wins_over_legacy_true(self) -> None:
|
||||
"""Primary=False explicitly set wins over legacy=True.
|
||||
@@ -214,11 +162,9 @@ class TestSchedulerConfigLegacyAliases:
|
||||
"schedule_time": "03:00",
|
||||
"schedule_days": ALL_DAYS,
|
||||
"auto_download": True,
|
||||
"folder_scan": True,
|
||||
}
|
||||
config = SchedulerConfig(**data)
|
||||
assert config.auto_download_after_rescan is True
|
||||
assert config.folder_scan_enabled is True
|
||||
|
||||
|
||||
class TestSchedulerConfigSerialisation:
|
||||
@@ -231,7 +177,6 @@ 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)
|
||||
@@ -247,13 +192,10 @@ class TestSchedulerConfigSerialisation:
|
||||
"""
|
||||
original = SchedulerConfig(
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
dumped = original.model_dump()
|
||||
# Alias fields must not appear when None
|
||||
assert "auto_download" not in dumped
|
||||
assert "folder_scan" not in dumped
|
||||
# Primary fields roundtrip correctly
|
||||
restored = SchedulerConfig(**dumped)
|
||||
assert restored.auto_download_after_rescan is True
|
||||
assert restored.folder_scan_enabled is True
|
||||
|
||||
@@ -366,7 +366,6 @@ class TestGetStatus:
|
||||
schedule_time="04:00",
|
||||
schedule_days=["mon"],
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
status = scheduler_service.get_status()
|
||||
|
||||
@@ -376,100 +375,13 @@ 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.scheduler.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.scheduler.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.scheduler.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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user