feat: remove startup NFO repair, update docs and tests

- Remove NFO repair scan step from ARCHITECTURE.md startup sequence
- Update CHANGELOG.md: rephrase perform_nfo_repair_scan as scheduled scan
- Add test verifying perform_nfo_repair_scan is NOT called in lifespan
- Keep existing folder scan wiring tests and unit tests intact
- NFO_GUIDE.md already correctly describes scheduled scan behavior
This commit is contained in:
2026-05-13 09:23:21 +02:00
parent eb0e6e8ccb
commit 756731cd5d
9 changed files with 1226 additions and 16 deletions

View File

@@ -0,0 +1,109 @@
"""Integration tests for folder rename service wiring.
These tests verify that:
1. FolderScanService.run_folder_scan calls validate_and_rename_series_folders.
2. The rename logic is properly integrated into the scheduled folder scan.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestFolderRenameScanCalledInFolderScan:
"""Verify validate_and_rename_series_folders is invoked from FolderScanService."""
def test_validate_and_rename_imported_in_folder_scan_service(self):
"""folder_scan_service.py imports validate_and_rename_series_folders."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "validate_and_rename_series_folders" in content, (
"validate_and_rename_series_folders must be imported in folder_scan_service.py"
)
def test_validate_and_rename_called_in_run_folder_scan(self):
"""validate_and_rename_series_folders must be called inside run_folder_scan."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
run_folder_scan_pos = content.find("def run_folder_scan")
rename_call_pos = content.find("validate_and_rename_series_folders()")
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
assert rename_call_pos != -1, "validate_and_rename_series_folders call not found"
assert rename_call_pos > run_folder_scan_pos, (
"validate_and_rename_series_folders must be called INSIDE run_folder_scan"
)
class TestFolderRenameIntegration:
"""Integration test: folder rename is triggered during folder scan."""
@pytest.mark.asyncio
async def test_folder_rename_runs_during_scan(self, tmp_path):
"""When folder_scan_enabled is true, the scan renames mismatched folders."""
from src.server.services.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_rename_service.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
), patch(
"src.server.services.folder_rename_service._update_database_paths",
new_callable=AsyncMock,
):
service = FolderScanService()
await service.run_folder_scan()
assert not series_dir.exists()
assert (anime_dir / "Attack on Titan (2013)").is_dir()
@pytest.mark.asyncio
async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path):
"""If anime directory is missing, rename logic is skipped gracefully."""
from src.server.services.folder_scan_service import FolderScanService
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path / "nonexistent")
with patch(
"src.config.settings.settings", mock_settings
), 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"
) as mock_rename:
service = FolderScanService()
await service.run_folder_scan()
mock_rename.assert_not_called()

View File

@@ -1,14 +1,32 @@
"""Integration tests verifying perform_nfo_repair_scan is wired into folder scan.
"""Integration tests verifying perform_nfo_repair_scan is wired into folder scan
and NOT called during FastAPI lifespan startup.
These tests confirm that:
1. FolderScanService.run_folder_scan calls perform_nfo_repair_scan.
2. Series with incomplete NFO files are queued via asyncio.create_task.
2. perform_nfo_repair_scan is NOT imported or called in fastapi_app.py lifespan.
3. Series with incomplete NFO files are queued via asyncio.create_task.
"""
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
class TestNfoRepairScanNotCalledOnStartup:
"""Verify perform_nfo_repair_scan is NOT invoked during FastAPI lifespan startup."""
def test_perform_nfo_repair_scan_not_imported_in_lifespan(self):
"""fastapi_app.py lifespan must not import or call perform_nfo_repair_scan."""
import importlib
source = importlib.util.find_spec("src.server.fastapi_app").origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "perform_nfo_repair_scan" not in content, (
"perform_nfo_repair_scan must NOT be imported or called in fastapi_app.py"
)
class TestNfoRepairScanCalledInFolderScan:
"""Verify perform_nfo_repair_scan is invoked from FolderScanService."""

View File

@@ -0,0 +1,294 @@
"""Integration tests for poster check service wiring.
These tests verify that:
1. FolderScanService.run_folder_scan calls check_and_download_missing_posters.
2. The poster check logic is properly integrated into the scheduled folder scan.
3. Missing posters are downloaded, valid posters are skipped, and errors are handled.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestPosterCheckScanCalledInFolderScan:
"""Verify check_and_download_missing_posters is invoked from FolderScanService."""
def test_check_and_download_missing_posters_imported_in_folder_scan_service(self):
"""folder_scan_service.py imports check_and_download_missing_posters."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "check_and_download_missing_posters" in content, (
"check_and_download_missing_posters must be imported in folder_scan_service.py"
)
def test_check_and_download_missing_posters_called_in_run_folder_scan(self):
"""check_and_download_missing_posters must be called inside run_folder_scan."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
run_folder_scan_pos = content.find("def run_folder_scan")
poster_call_pos = content.find("check_and_download_missing_posters()")
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
assert poster_call_pos != -1, "check_and_download_missing_posters call not found"
assert poster_call_pos > run_folder_scan_pos, (
"check_and_download_missing_posters must be called INSIDE run_folder_scan"
)
class TestPosterCheckIntegration:
"""Integration test: poster check is triggered during folder scan."""
@pytest.mark.asyncio
async def test_poster_check_downloads_missing_poster(self, tmp_path):
"""When poster.jpg is missing, the scan downloads it from the NFO thumb URL."""
from src.server.services.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<tvshow>\n"
" <title>Attack on Titan</title>\n"
" <year>2013</year>\n"
' <thumb aspect="poster">https://example.com/poster.jpg</thumb>\n'
"</tvshow>\n"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
call_log = []
class MockDownloader:
"""Fake ImageDownloader that records calls."""
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def download_poster(self, url, folder, skip_existing=True):
call_log.append({"url": url, "folder": folder, "skip_existing": skip_existing})
return True
with patch(
"src.config.settings.settings", mock_settings
), 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(
"src.server.services.folder_scan_service.ImageDownloader",
new=MockDownloader,
):
service = FolderScanService()
await service.run_folder_scan()
assert len(call_log) == 1, f"Expected 1 download call, got {len(call_log)}"
assert call_log[0]["url"] == "https://example.com/poster.jpg"
assert call_log[0]["folder"] == series_dir
assert call_log[0]["skip_existing"] is False
@pytest.mark.asyncio
async def test_poster_check_skips_valid_poster(self, tmp_path):
"""When poster.jpg exists and is large enough, the scan skips it."""
from src.server.services.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "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>"
)
# Create a valid poster.jpg (larger than 1 KB)
poster_path = series_dir / "poster.jpg"
poster_path.write_bytes(b"x" * 2048)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
with patch(
"src.config.settings.settings", mock_settings
), 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(
"src.server.services.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
mock_downloader_cls.assert_not_called()
@pytest.mark.asyncio
async def test_poster_check_skips_when_no_thumb_url(self, tmp_path):
"""When NFO has no thumb URL, the scan skips the folder."""
from src.server.services.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title>"
"<year>2013</year>"
"</tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
with patch(
"src.config.settings.settings", mock_settings
), 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(
"src.server.services.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
mock_downloader_cls.assert_not_called()
@pytest.mark.asyncio
async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path):
"""If anime directory is missing, poster check logic is skipped gracefully."""
from src.server.services.folder_scan_service import FolderScanService
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path / "nonexistent")
with patch(
"src.config.settings.settings", mock_settings
), 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"
) as mock_rename, patch(
"src.server.services.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
mock_downloader_cls.assert_not_called()
class TestPosterCheckSemaphore:
"""Verify the poster download semaphore limits concurrency."""
def test_poster_download_semaphore_defined(self):
"""_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "_POSTER_DOWNLOAD_SEMAPHORE" in content, (
"_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py"
)
@pytest.mark.asyncio
async def test_poster_download_uses_semaphore(self, tmp_path):
"""Poster downloads are gated by the semaphore."""
from src.server.services.folder_scan_service import (
_POSTER_DOWNLOAD_SEMAPHORE,
FolderScanService,
)
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
# Create multiple series folders
for i in range(5):
series_dir = anime_dir / f"Series {i}"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
f"<tvshow>"
f"<title>Series {i}</title>"
f"<year>202{i}</year>"
f"<thumb aspect='poster'>https://example.com/poster{i}.jpg</thumb>"
f"</tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
active_count = 0
max_active = 0
async def tracked_download(*args, **kwargs):
nonlocal active_count, max_active
active_count += 1
max_active = max(max_active, active_count)
await asyncio.sleep(0.05)
active_count -= 1
return True
with patch(
"src.config.settings.settings", mock_settings
), 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(
"src.server.services.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
mock_downloader = AsyncMock()
mock_downloader.download_poster = AsyncMock(side_effect=tracked_download)
mock_downloader_cls.return_value.__aenter__ = AsyncMock(
return_value=mock_downloader
)
mock_downloader_cls.return_value.__aexit__ = AsyncMock(return_value=False)
service = FolderScanService()
await service.run_folder_scan()
assert max_active <= 3, (
f"Expected max concurrent downloads <= 3, got {max_active}"
)

View File

@@ -0,0 +1,383 @@
"""Unit tests for folder_rename_service.py.
These tests verify the core logic of the folder rename service in
isolation, using temporary directories and mocked dependencies.
"""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.folder_rename_service import (
_compute_expected_folder_name,
_is_series_being_downloaded,
_parse_nfo_title_and_year,
_update_database_paths,
validate_and_rename_series_folders,
)
class TestParseNfoTitleAndYear:
"""Tests for _parse_nfo_title_and_year."""
def test_parses_title_and_year(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
title, year = _parse_nfo_title_and_year(nfo)
assert title == "Attack on Titan"
assert year == "2013"
def test_missing_title_returns_none(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text("<tvshow><year>2013</year></tvshow>")
title, year = _parse_nfo_title_and_year(nfo)
assert title is None
assert year == "2013"
def test_missing_year_returns_none(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text("<tvshow><title>Attack on Titan</title></tvshow>")
title, year = _parse_nfo_title_and_year(nfo)
assert title == "Attack on Titan"
assert year is None
def test_empty_title_returns_none(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text(
"<tvshow><title> </title><year>2013</year></tvshow>"
)
title, year = _parse_nfo_title_and_year(nfo)
assert title is None
assert year == "2013"
def test_malformed_xml_returns_none(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text("not xml at all")
title, year = _parse_nfo_title_and_year(nfo)
assert title is None
assert year is None
class TestComputeExpectedFolderName:
"""Tests for _compute_expected_folder_name."""
def test_simple_title_and_year(self) -> None:
result = _compute_expected_folder_name("Attack on Titan", "2013")
assert result == "Attack on Titan (2013)"
def test_sanitizes_invalid_chars(self) -> None:
result = _compute_expected_folder_name("Show: Subtitle", "2020")
assert result == "Show Subtitle (2020)"
def test_sanitizes_slashes(self) -> None:
result = _compute_expected_folder_name("A / B", "2021")
assert result == "A B (2021)"
class TestIsSeriesBeingDownloaded:
"""Tests for _is_series_being_downloaded."""
def test_no_active_download(self) -> None:
mock_service = MagicMock()
mock_service._active_download = None
mock_service._pending_queue = []
with patch(
"src.server.services.folder_rename_service.get_download_service",
return_value=mock_service,
):
assert _is_series_being_downloaded("Some Show") is False
def test_active_download_matches(self) -> None:
mock_item = MagicMock()
mock_item.serie_folder = "Some Show"
mock_service = MagicMock()
mock_service._active_download = mock_item
mock_service._pending_queue = []
with patch(
"src.server.services.folder_rename_service.get_download_service",
return_value=mock_service,
):
assert _is_series_being_downloaded("Some Show") is True
def test_pending_download_matches(self) -> None:
mock_item = MagicMock()
mock_item.serie_folder = "Some Show"
mock_service = MagicMock()
mock_service._active_download = None
mock_service._pending_queue = [mock_item]
with patch(
"src.server.services.folder_rename_service.get_download_service",
return_value=mock_service,
):
assert _is_series_being_downloaded("Some Show") is True
def test_exception_returns_true_for_safety(self) -> None:
with patch(
"src.server.services.folder_rename_service.get_download_service",
side_effect=RuntimeError("boom"),
):
assert _is_series_being_downloaded("Some Show") is True
class TestUpdateDatabasePaths:
"""Tests for _update_database_paths."""
@pytest.mark.asyncio
async def test_updates_series_folder(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
mock_series = MagicMock()
mock_series.id = 1
mock_series.folder = "Old Name"
with patch(
"src.server.services.folder_rename_service.get_db_session"
) as mock_get_db, patch(
"src.server.services.folder_rename_service.AnimeSeriesService"
) as mock_series_svc, patch(
"src.server.services.folder_rename_service.EpisodeService"
) as mock_episode_svc, patch(
"src.server.services.folder_rename_service.DownloadQueueService"
) as mock_queue_svc:
mock_db = AsyncMock()
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
mock_series_svc.get_all = AsyncMock(return_value=[])
mock_series_svc.update = AsyncMock(return_value=mock_series)
mock_episode_svc.get_by_series = AsyncMock(return_value=[])
mock_queue_svc.get_all = AsyncMock(return_value=[])
await _update_database_paths("Old Name", "New Name", anime_dir)
mock_series_svc.update.assert_awaited_once_with(
mock_db, 1, folder="New Name"
)
@pytest.mark.asyncio
async def test_updates_episode_file_paths(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
old_path = anime_dir / "Old Name" / "S01E01.mkv"
new_path = anime_dir / "New Name" / "S01E01.mkv"
mock_series = MagicMock()
mock_series.id = 1
mock_series.folder = "Old Name"
mock_episode = MagicMock()
mock_episode.file_path = str(old_path)
with patch(
"src.server.services.folder_rename_service.get_db_session"
) as mock_get_db, patch(
"src.server.services.folder_rename_service.AnimeSeriesService"
) as mock_series_svc, patch(
"src.server.services.folder_rename_service.EpisodeService"
) as mock_episode_svc, patch(
"src.server.services.folder_rename_service.DownloadQueueService"
) as mock_queue_svc:
mock_db = AsyncMock()
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
mock_series_svc.get_all = AsyncMock(return_value=[])
mock_series_svc.update = AsyncMock(return_value=mock_series)
mock_episode_svc.get_by_series = AsyncMock(return_value=[mock_episode])
mock_queue_svc.get_all = AsyncMock(return_value=[])
await _update_database_paths("Old Name", "New Name", anime_dir)
assert mock_episode.file_path == str(new_path)
class TestValidateAndRenameSeriesFolders:
"""Integration-style tests for validate_and_rename_series_folders."""
@pytest.mark.asyncio
async def test_no_anime_directory(self) -> None:
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
"",
):
stats = await validate_and_rename_series_folders()
assert stats == {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
@pytest.mark.asyncio
async def test_renames_folder_when_name_differs(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
), patch(
"src.server.services.folder_rename_service._update_database_paths",
new_callable=AsyncMock,
) as mock_update_db:
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 1
assert stats["skipped"] == 0
assert stats["errors"] == 0
assert not series_dir.exists()
assert (anime_dir / "Attack on Titan (2013)").is_dir()
mock_update_db.assert_awaited_once()
@pytest.mark.asyncio
async def test_skips_when_name_already_correct(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "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.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["skipped"] == 0
assert stats["errors"] == 0
assert series_dir.is_dir()
@pytest.mark.asyncio
async def test_skips_missing_title_or_year(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Incomplete"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Incomplete</title></tvshow>"
)
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["skipped"] == 1
assert stats["errors"] == 0
@pytest.mark.asyncio
async def test_skips_when_download_active(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=True,
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["skipped"] == 1
assert stats["errors"] == 0
assert series_dir.is_dir()
@pytest.mark.asyncio
async def test_errors_when_target_exists(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
# Pre-create the target folder to simulate a duplicate
(anime_dir / "Attack on Titan (2013)").mkdir()
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["skipped"] == 0
assert stats["errors"] == 1
assert series_dir.is_dir()
@pytest.mark.asyncio
async def test_counts_multiple_folders(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
# Folder 1: needs rename
d1 = anime_dir / "Show A"
d1.mkdir()
(d1 / "tvshow.nfo").write_text(
"<tvshow><title>Show A</title><year>2020</year></tvshow>"
)
# Folder 2: already correct
d2 = anime_dir / "Show B (2021)"
d2.mkdir()
(d2 / "tvshow.nfo").write_text(
"<tvshow><title>Show B</title><year>2021</year></tvshow>"
)
# Folder 3: missing year
d3 = anime_dir / "Show C"
d3.mkdir()
(d3 / "tvshow.nfo").write_text("<tvshow><title>Show C</title></tvshow>")
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
), patch(
"src.server.services.folder_rename_service._update_database_paths",
new_callable=AsyncMock,
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 3
assert stats["renamed"] == 1
assert stats["skipped"] == 1
assert stats["errors"] == 0
assert not d1.exists()
assert (anime_dir / "Show A (2020)").is_dir()
assert d2.is_dir()
assert d3.is_dir()