feat: add folder naming service to fix missing years in anime folder names

Runs after NFO refresh during scheduled rescans. Renames folders that
are missing a year (e.g. 'Naruto' → 'Naruto (1999)') using the year
from the database record.

Safety: _build_target_folder() always strips any existing year suffix
first, preventing double/triple year accumulation like
'Naruto (1999) (1999) (1999)'.

Changes:
- New FolderNamingService (folder_naming_service.py) with safe target
  name construction, DB update, and in-memory cache update
- New SchedulerConfig field: folder_naming_after_nfo_scan (default True)
- Integrated as step 3 in scheduler _perform_rescan() after NFO scan
- Runtime UI: existing 'folder-scan-enabled' checkbox in index.html
  now wired to toggle the feature (app.js + scheduler-config.js)
- Setup screen: new checkbox in setup.html Scheduler Settings section
- API: scheduler config endpoint returns all scan toggles
- Tests: 39 unit tests covering static helpers, rename logic, safety
  guard, and integration cases (folder_naming_service.py)
- Docs: testing guide updated with FolderNamingService examples
This commit is contained in:
2026-07-26 21:45:08 +02:00
parent a384072901
commit 5f46d2e802
9 changed files with 2974 additions and 2447 deletions

View File

@@ -1,104 +1,33 @@
# Testing Documentation
## Document Purpose ### Testing FolderNamingService
This document describes the testing strategy, guidelines, and practices for the Aniworld project. FolderNamingService fixes missing years in anime folder names after NFO refresh. Test file: `tests/unit/test_folder_naming_service.py`.
### What This Document Contains
- **Testing Strategy**: Overall approach to quality assurance
- **Test Categories**: Unit, integration, API, performance, security tests
- **Test Structure**: Organization of test files and directories
- **Writing Tests**: Guidelines for writing effective tests
- **Fixtures and Mocking**: Shared test utilities and mock patterns
- **Running Tests**: Commands and configurations
- **Coverage Requirements**: Minimum coverage thresholds
- **CI/CD Integration**: How tests run in automation
- **Test Data Management**: Managing test fixtures and data
- **Best Practices**: Do's and don'ts for testing
### What This Document Does NOT Contain
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
- Security audit procedures (see [SECURITY.md](SECURITY.md))
- Bug tracking and issue management
- Performance benchmarking results
### Target Audience
- Developers writing tests
- QA Engineers
- CI/CD Engineers
- Code reviewers
---
## Sections to Document
1. Testing Philosophy
- Test pyramid approach
- Quality gates
2. Test Categories
- Unit Tests (`tests/unit/`)
- Integration Tests (`tests/integration/`)
- API Tests (`tests/api/`)
- Frontend Tests (`tests/frontend/`)
- Performance Tests (`tests/performance/`)
- Security Tests (`tests/security/`)
3. Test Structure and Naming
- File naming conventions
- Test function naming
- Test class organization
4. Running Tests
- pytest commands
- Running specific tests
- Verbose output
- Coverage reports
5. Fixtures and Conftest
- Shared fixtures
- Database fixtures
- Mock services
6. Mocking Guidelines
- What to mock
- Mock patterns
- External service mocks
### Mocking the Download Queue
Use `MockQueueRepository` for testing download queue functionality:
```python
from src.server.models.download import DownloadItem, EpisodeIdentifier
class MockQueueRepository:
def __init__(self):
self._items: Dict[str, DownloadItem] = {}
```
### Testing SetupService
SetupService handles series key resolution from folder names during library setup. Test file: `tests/unit/test_setup_service.py`.
Key methods tested: Key methods tested:
- `_build_target_folder()` — constructs safe "Title (YYYY)" names, strips existing year suffix first (prevents double-year like "Naruto (1999) (1999)")
- `_folder_has_year()` — detects existing `(YYYY)` pattern
- `_extract_year_from_folder_name()` — parses `(YYYY)` suffix - `_extract_year_from_folder_name()` — parses `(YYYY)` suffix
- `_extract_title_from_folder_name()` — strips year suffix - `_extract_title_from_folder_name()` — strips year suffix
- `_resolve_key_via_search()` — resolves provider key via fuzzy title matching
```python ```python
@pytest.mark.asyncio # Safe: repeated calls never accumulate years
async def test_returns_key_when_single_exact_match(self): r1 = FolderNamingService._build_target_folder("Naruto", 1999)
"""Search returns 1 result with same name → returns key.""" r2 = FolderNamingService._build_target_folder(r1, 1999)
mock_series_app = AsyncMock() assert r1 == r2 == "Naruto (1999)"
mock_series_app.search.return_value = [
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
]
with patch('src.server.services.setup_service.get_series_app', return_value=mock_series_app): # Safe: existing year is replaced, not appended
result = await SetupService._resolve_key_via_search("Attack on Titan") result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
assert result == "Naruto (1999)"
assert result == 'attack-on-titan' assert "2020" not in result
``` ```
The service is also tested end-to-end with mocked filesystem and database:
- Renames folder and updates DB when year is missing from folder name
- Skips rename when folder already has a year
- Skips rename when DB has no year
- Skips when target folder already exists on disk
- Safety guard detects and skips if target folder year mismatches DB year
### Mocking aiohttp Sessions ### Mocking aiohttp Sessions
When testing code that uses `aiohttp.ClientSession`: When testing code that uses `aiohttp.ClientSession`:

View File

@@ -31,6 +31,9 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
"schedule_time": config.schedule_time, "schedule_time": config.schedule_time,
"schedule_days": config.schedule_days, "schedule_days": config.schedule_days,
"auto_download_after_rescan": config.auto_download_after_rescan, "auto_download_after_rescan": config.auto_download_after_rescan,
"nfo_scan_after_rescan": config.nfo_scan_after_rescan,
"image_scan_after_rescan": config.image_scan_after_rescan,
"folder_naming_after_nfo_scan": config.folder_naming_after_nfo_scan,
}, },
"status": { "status": {
"is_running": runtime.get("is_running", False), "is_running": runtime.get("is_running", False),

View File

@@ -52,6 +52,12 @@ class SchedulerConfig(BaseModel):
description="Download series images (poster.jpg, fanart.jpg, logo.png) " description="Download series images (poster.jpg, fanart.jpg, logo.png) "
"from TMDB after a scheduled rescan completes.", "from TMDB after a scheduled rescan completes.",
) )
folder_naming_after_nfo_scan: bool = Field(
default=True,
description="Fix missing years in folder names after NFO refresh. "
"Renames folders (e.g. 'Naruto' -> 'Naruto (1999)') using "
"the year from the database record.",
)
# Legacy alias fields — read via Pydantic alias # Legacy alias fields — read via Pydantic alias
auto_download: Optional[bool] = Field(default=None, alias="auto_download") auto_download: Optional[bool] = Field(default=None, alias="auto_download")

View File

@@ -0,0 +1,205 @@
"""Folder naming service for fixing missing years in anime folder names."""
from __future__ import annotations
import asyncio
import os
import re
import shutil
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import structlog
from src.config.settings import settings
from src.server.database.connection import get_db_session as _get_db_session
from src.server.database.service import AnimeSeriesService
logger = structlog.get_logger(__name__)
@dataclass
class FolderRenameResult:
key: str
old_folder: str
new_folder: Optional[str]
success: bool
skipped: bool = False
reason: Optional[str] = None
@dataclass
class FolderNamingReport:
total: int
renamed: int
skipped: int
errors: int
results: List[FolderRenameResult]
def to_dict(self) -> Dict[str, Any]:
return {
"total": self.total,
"renamed": self.renamed,
"skipped": self.skipped,
"errors": self.errors,
"results": [
{
"key": r.key,
"old_folder": r.old_folder,
"new_folder": r.new_folder,
"success": r.success,
"skipped": r.skipped,
"reason": r.reason,
}
for r in self.results
],
}
class FolderNamingService:
def __init__(self) -> None:
self._is_running = False
self._lock = asyncio.Lock()
async def run(self) -> FolderNamingReport:
async with self._lock:
if self._is_running:
logger.warning("FolderNamingService.run() called while already running")
return FolderNamingReport(total=0, renamed=0, skipped=0, errors=0, results=[])
self._is_running = True
try:
logger.info("FolderNamingService: starting folder naming scan")
results: List[FolderRenameResult] = []
async with _get_db_session() as db:
all_series = await AnimeSeriesService.get_all(db)
for series in all_series:
result = await self._process_series(series)
results.append(result)
renamed = sum(1 for r in results if r.success and not r.skipped)
skipped = sum(1 for r in results if r.skipped)
errors = sum(1 for r in results if not r.skipped and not r.success)
report = FolderNamingReport(
total=len(results),
renamed=renamed,
skipped=skipped,
errors=errors,
results=results,
)
logger.info(
"FolderNamingService: scan complete — total=%d renamed=%d skipped=%d errors=%d",
report.total, report.renamed, report.skipped, report.errors,
)
return report
finally:
self._is_running = False
async def _process_series(self, series) -> FolderRenameResult:
key = series.key
folder = series.folder or ""
year = getattr(series, "year", None)
if year is None:
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="no year in DB record")
if self._folder_has_year(folder):
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="folder already has year")
target_folder = self._build_target_folder(folder, year)
# Safety: re-extract year from target to prevent double-year
if target_folder != folder:
extracted = self._extract_year_from_folder_name(target_folder)
if extracted != year:
logger.error("Safety guard for %s: target '%s' year=%s != DB year=%s — skipping", key, target_folder, extracted, year)
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=False, skipped=True, reason="safety guard: target year mismatch")
return await self._execute_rename(series, folder, target_folder)
async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
key = series.key
if old_folder == target_folder:
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=True, skipped=True, reason="same folder name")
anime_dir = settings.anime_directory
old_path = os.path.join(anime_dir, old_folder)
target_path = os.path.join(anime_dir, target_folder)
if not os.path.isdir(old_path):
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk")
if os.path.isdir(target_path):
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk")
try:
shutil.move(old_path, target_path)
logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
# Update in-memory cache
try:
from src.server.SeriesApp import get_series_app
series_app = get_series_app()
if key in series_app.list.keyDict:
series_app.list.keyDict[key].folder = target_folder
except Exception as exc:
logger.warning("Failed to update in-memory cache for %s: %s", key, exc)
# Update database
async with _get_db_session() as db:
db_series = await AnimeSeriesService.get_by_key(db, key)
if db_series:
await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder)
logger.debug("Updated DB folder for %s to %s", key, target_folder)
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=target_folder, success=True, skipped=False)
except Exception as exc:
logger.error("Failed to rename folder for %s (%s -> %s): %s", key, old_folder, target_folder, exc)
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason=str(exc))
# Static helpers — public for direct testing
@staticmethod
def _folder_has_year(folder_name: str) -> bool:
if not folder_name:
return False
return bool(re.search(r"\(\d{4}\)", folder_name))
@staticmethod
def _extract_year_from_folder_name(folder_name: str) -> Optional[int]:
if not folder_name:
return None
match = re.search(r"\((\d{4})\)", folder_name)
if match:
try:
year = int(match.group(1))
if 1900 <= year <= 2100:
return year
except ValueError:
pass
return None
@staticmethod
def _extract_title_from_folder_name(folder_name: str) -> str:
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
@staticmethod
def _build_target_folder(folder_name: str, year: int) -> str:
title = FolderNamingService._extract_title_from_folder_name(folder_name)
return f"{title} ({year})"
_folder_naming_service: Optional[FolderNamingService] = None
def get_folder_naming_service() -> FolderNamingService:
global _folder_naming_service
if _folder_naming_service is None:
_folder_naming_service = FolderNamingService()
return _folder_naming_service
def reset_folder_naming_service() -> None:
global _folder_naming_service
_folder_naming_service = None

View File

@@ -268,6 +268,9 @@ class SchedulerService:
"image_scan_after_rescan": ( "image_scan_after_rescan": (
self._config.image_scan_after_rescan if self._config else True self._config.image_scan_after_rescan if self._config else True
), ),
"folder_naming_after_nfo_scan": (
self._config.folder_naming_after_nfo_scan if self._config else True
),
"last_run": ( "last_run": (
self._last_scan_time.isoformat() self._last_scan_time.isoformat()
if self._last_scan_time if self._last_scan_time
@@ -404,7 +407,18 @@ class SchedulerService:
logger.error("NFO scan failed: %s", exc, exc_info=True) logger.error("NFO scan failed: %s", exc, exc_info=True)
await self._broadcast("nfo_scan_error", {"error": str(exc)}) await self._broadcast("nfo_scan_error", {"error": str(exc)})
# 3. Auto-download (if enabled) # 3. Folder naming (if enabled, runs after NFO scan)
if self._config and self._config.folder_naming_after_nfo_scan:
if self._config.nfo_scan_after_rescan:
# Only run if NFO scan was also enabled (depends on year in DB)
try:
naming_result = await self._run_folder_naming()
await self._broadcast("folder_naming_completed", naming_result.to_dict())
except Exception as exc:
logger.error("Folder naming failed: %s", exc, exc_info=True)
await self._broadcast("folder_naming_error", {"error": str(exc)})
# 4. Auto-download (if enabled)
if self._config and self._config.auto_download_after_rescan: if self._config and self._config.auto_download_after_rescan:
try: try:
queued = await self._run_auto_download() queued = await self._run_auto_download()
@@ -413,7 +427,7 @@ class SchedulerService:
logger.error("Auto-download failed: %s", exc, exc_info=True) logger.error("Auto-download failed: %s", exc, exc_info=True)
await self._broadcast("auto_download_error", {"error": str(exc)}) await self._broadcast("auto_download_error", {"error": str(exc)})
# 4. Image scan (if enabled) # 5. Image scan (if enabled)
if self._config and self._config.image_scan_after_rescan: if self._config and self._config.image_scan_after_rescan:
try: try:
image_result = await self._run_image_scan() image_result = await self._run_image_scan()
@@ -536,6 +550,13 @@ class SchedulerService:
) )
return result return result
async def _run_folder_naming(self) -> Any:
"""Run folder naming fix to add missing years to folder names."""
from src.server.services.folder_naming_service import get_folder_naming_service
service = get_folder_naming_service()
logger.info("Starting folder naming scan...")
return await service.run()
async def _run_auto_download(self) -> int: async def _run_auto_download(self) -> int:
"""Queue and start downloads for all series with missing episodes.""" """Queue and start downloads for all series with missing episodes."""
from src.server.models.download import EpisodeIdentifier from src.server.models.download import EpisodeIdentifier

View File

@@ -1574,6 +1574,10 @@ class AniWorldApp {
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled; document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00'; document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan; document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
const folderNaming = document.getElementById('folder-scan-enabled');
if (folderNaming) {
folderNaming.checked = !!config.folder_naming_after_nfo_scan;
}
// Update day-of-week checkboxes // Update day-of-week checkboxes
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun']; const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
@@ -1631,7 +1635,8 @@ class AniWorldApp {
enabled: enabled, enabled: enabled,
schedule_time: scheduleTime, schedule_time: scheduleTime,
schedule_days: scheduleDays, schedule_days: scheduleDays,
auto_download_after_rescan: autoDownload auto_download_after_rescan: autoDownload,
folder_naming_after_nfo_scan: document.getElementById('folder-scan-enabled') ? document.getElementById('folder-scan-enabled').checked : false
}) })
}); });

View File

@@ -35,6 +35,11 @@ AniWorld.SchedulerConfig = (function() {
autoDownload.checked = config.auto_download_after_rescan || false; autoDownload.checked = config.auto_download_after_rescan || false;
} }
const folderNaming = document.getElementById('folder-scan-enabled');
if (folderNaming) {
folderNaming.checked = config.folder_naming_after_nfo_scan || false;
}
// Update schedule day checkboxes // Update schedule day checkboxes
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun']; const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) { ['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
@@ -87,7 +92,8 @@ AniWorld.SchedulerConfig = (function() {
enabled: enabled, enabled: enabled,
schedule_time: scheduleTime, schedule_time: scheduleTime,
schedule_days: scheduleDays, schedule_days: scheduleDays,
auto_download_after_rescan: autoDownload auto_download_after_rescan: autoDownload,
folder_naming_after_nfo_scan: document.getElementById('folder-scan-enabled') ? document.getElementById('folder-scan-enabled').checked : false
}; };
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload); const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);

View File

@@ -479,6 +479,13 @@
<span>Auto-download missing episodes after rescan</span> <span>Auto-download missing episodes after rescan</span>
</label> </label>
</div> </div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" id="scheduler_folder_naming" name="scheduler_folder_naming">
<span>Fix missing years in folder names after NFO refresh</span>
</label>
<div class="form-help">Renames folders (e.g. "Naruto" → "Naruto (1999)") using the year from the database.</div>
</div>
</div> </div>
</div> </div>
@@ -761,6 +768,7 @@
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00', scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value), scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked, scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
scheduler_folder_naming_after_nfo_scan: document.getElementById('scheduler_folder_naming').checked,
logging_level: document.getElementById('logging_level').value, logging_level: document.getElementById('logging_level').value,
logging_file: document.getElementById('logging_file').value.trim() || null, logging_file: document.getElementById('logging_file').value.trim() || null,
logging_max_bytes: document.getElementById('logging_max_bytes').value ? logging_max_bytes: document.getElementById('logging_max_bytes').value ?

View File

@@ -0,0 +1,344 @@
"""Tests for FolderNamingService."""
import os
import re
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.folder_naming_service import (
FolderNamingService,
FolderRenameResult,
FolderNamingReport,
)
# -----------------------------------------------------------------------
# Static method tests
# -----------------------------------------------------------------------
class TestFolderHasYear:
"""Tests for _folder_has_year."""
@pytest.mark.parametrize(
("folder", "expected"),
[
("Naruto", False),
("Naruto (2020)", True),
("Attack on Titan (2013)", True),
("", False),
("One Piece", False),
("(2020)", True),
("Naruto 2020", False), # no parentheses
("Naruto (20)", False), # too short
("Naruto (abcd)", False), # not digits
],
)
def test_folder_has_year(self, folder, expected):
assert FolderNamingService._folder_has_year(folder) == expected
class TestExtractYearFromFolderName:
"""Tests for _extract_year_from_folder_name."""
@pytest.mark.parametrize(
("folder", "expected"),
[
("Naruto", None),
("Naruto (2020)", 2020),
("Attack on Titan (2013)", 2013),
("", None),
("(2020)", 2020),
("Title (1800)", None), # out of range
("Title (2200)", None), # out of range
("Title (2020) - Extra", 2020), # year in middle
],
)
def test_extract_year(self, folder, expected):
assert FolderNamingService._extract_year_from_folder_name(folder) == expected
class TestExtractTitleFromFolderName:
"""Tests for _extract_title_from_folder_name."""
@pytest.mark.parametrize(
("folder", "expected"),
[
("Naruto", "Naruto"),
("Naruto (2020)", "Naruto"),
("Attack on Titan (2013)", "Attack on Titan"),
("", ""),
("Naruto (2020) ", "Naruto"), # extra spaces
("Title (2020) - Extra", "Title (2020) - Extra"), # year in middle not removed
],
)
def test_extract_title(self, folder, expected):
assert FolderNamingService._extract_title_from_folder_name(folder) == expected
class TestBuildTargetFolder:
"""Tests for _build_target_folder — the critical double-year prevention."""
@pytest.mark.parametrize(
("folder", "year", "expected"),
[
# No year → add year
("Naruto", 1999, "Naruto (1999)"),
("One Piece", 1999, "One Piece (1999)"),
# Has year → strip and replace
("Naruto (2020)", 1999, "Naruto (1999)"),
("Naruto (2020)", 2020, "Naruto (2020)"),
# Has year with extra spaces
("Naruto (2020)", 1999, "Naruto (1999)"),
# Title with inner parens (not a year) → preserved
("Dragon Ball (Super)", 1989, "Dragon Ball (Super) (1989)"),
],
)
def test_build_target_folder(self, folder, year, expected):
assert FolderNamingService._build_target_folder(folder, year) == expected
def test_repeated_calls_do_not_accumulate_years(self):
"""Ensures safe: Naruto -> (1999) -> (1999) stays (1999), not (1999)(1999)."""
folder = "Naruto"
year = 1999
step1 = FolderNamingService._build_target_folder(folder, year)
step2 = FolderNamingService._build_target_folder(step1, year)
step3 = FolderNamingService._build_target_folder(step2, year)
assert step1 == "Naruto (1999)"
assert step2 == "Naruto (1999)"
assert step3 == "Naruto (1999)"
def test_already_yeared_folder_year_replaced_not_appended(self):
"""Folder 'Naruto (2020)' with year=1999 → 'Naruto (1999)', not 'Naruto (2020) (1999)'."""
result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
assert result == "Naruto (1999)"
assert "2020" not in result
def test_different_years_each_call_is_safe(self):
"""Multiple different years applied sequentially always produce clean name."""
folder = "Naruto"
r1 = FolderNamingService._build_target_folder(folder, 1999)
r2 = FolderNamingService._build_target_folder(r1, 1997)
r3 = FolderNamingService._build_target_folder(r2, 1999)
assert r1 == "Naruto (1999)"
assert r2 == "Naruto (1997)"
assert r3 == "Naruto (1999)"
# -----------------------------------------------------------------------
# Service tests (need mock DB + filesystem)
# -----------------------------------------------------------------------
@pytest.fixture
def mock_db_session():
"""Mock async DB session."""
with patch("src.server.services.folder_naming_service._get_db_session") as mock:
session = AsyncMock()
mock.return_value.__aenter__.return_value = session
mock.return_value.__aexit__.return_value = None
yield session
@pytest.fixture
def mock_series(flash=False):
"""Factory for mock anime series objects."""
def _make(key, folder, year):
series = MagicMock()
series.key = key
series.folder = folder
series.year = year
return series
return _make
@pytest.fixture
def mock_settings(tmp_path):
"""Mock settings with a temp anime directory."""
with patch("src.server.services.folder_naming_service.settings") as mock:
mock.anime_directory = str(tmp_path)
yield mock
class TestFolderNamingServiceIntegration:
"""Integration tests with mocked filesystem and DB."""
@pytest.mark.asyncio
async def test_skips_when_folder_already_has_year(
self, mock_db_session, mock_series, mock_settings
):
"""Folder 'Naruto (1999)' with DB year=1999 → skipped."""
series = mock_series("key1", "Naruto (1999)", 1999)
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
# Mock AnimeSeriesService.get_all
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
mock_get_all.return_value = [series]
service = FolderNamingService()
report = await service.run()
assert report.total == 1
assert report.skipped == 1
assert report.renamed == 0
assert report.errors == 0
assert report.results[0].reason == "folder already has year"
@pytest.mark.asyncio
async def test_skips_when_db_has_no_year(
self, mock_db_session, mock_series, mock_settings
):
"""Folder 'Naruto' with DB year=None → skipped."""
series = mock_series("key1", "Naruto", None)
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
mock_get_all.return_value = [series]
service = FolderNamingService()
report = await service.run()
assert report.skipped == 1
assert report.results[0].reason == "no year in DB record"
@pytest.mark.asyncio
async def test_renames_folder_and_updates_db(
self, tmp_path, mock_db_session, mock_series, mock_settings
):
"""Folder 'Naruto' with DB year=1999 → renames to 'Naruto (1999)'."""
anime_dir = tmp_path
(anime_dir / "Naruto").mkdir()
(anime_dir / "Naruto" / "episode1.mp4").touch()
series = mock_series("key1", "Naruto", 1999)
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
mock_get_all.return_value = [series]
db_series = MagicMock()
db_series.id = 42
mock_get_by_key.return_value = db_series
app_instance = MagicMock()
app_instance.list.keyDict = {"key1": MagicMock()}
mock_get_app.return_value = app_instance
mock_settings.anime_directory = str(anime_dir)
service = FolderNamingService()
report = await service.run()
assert report.renamed == 1
assert report.skipped == 0
assert report.errors == 0
assert report.results[0].new_folder == "Naruto (1999)"
assert (anime_dir / "Naruto (1999)").exists()
assert not (anime_dir / "Naruto").exists()
mock_update.assert_called_once()
call_kwargs = mock_update.call_args.kwargs
assert call_kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio
async def test_skips_when_target_folder_already_exists(
self, tmp_path, mock_db_session, mock_series, mock_settings
):
"""If 'Naruto (1999)' already exists, rename is skipped."""
anime_dir = tmp_path
(anime_dir / "Naruto").mkdir()
(anime_dir / "Naruto (1999)").mkdir() # target already exists
series = mock_series("key1", "Naruto", 1999)
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
mock_get_all.return_value = [series]
mock_settings.anime_directory = str(anime_dir)
service = FolderNamingService()
report = await service.run()
assert report.errors == 1
assert report.renamed == 0
assert report.results[0].reason == "target folder already exists on disk"
assert (anime_dir / "Naruto").exists() # source not moved
@pytest.mark.asyncio
async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings):
"""Safety guard triggers when _build_target_folder produces wrong year.
Uses a folder without a detectible year so _process_series proceeds to
build a target, then the safety re-check finds a mismatch.
"""
anime_dir = tmp_path
(anime_dir / "Naruto").mkdir()
series = mock_series("key1", "Naruto", 1999)
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
# Patch _build_target_folder to return a folder whose extracted year != DB year
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
patch.object(FolderNamingService, "_build_target_folder", return_value="Naruto (2020)"):
mock_get_all.return_value = [series]
mock_settings.anime_directory = str(anime_dir)
service = FolderNamingService()
report = await service.run()
# Should be skipped by safety guard
assert report.results[0].skipped is True
assert "safety guard" in report.results[0].reason.lower()
# Folder should NOT have been renamed
assert (anime_dir / "Naruto").exists()
assert not (anime_dir / "Naruto (2020)").exists()
@pytest.mark.asyncio
async def test_multiple_series_mixed_results(
self, tmp_path, mock_db_session, mock_series, mock_settings
):
"""Series 1 needs rename, series 2 is skipped (has year), series 3 has no year."""
anime_dir = tmp_path
(anime_dir / "Naruto").mkdir()
(anime_dir / "One Piece (1999)").mkdir()
(anime_dir / "Bleach").mkdir()
series = [
mock_series("key1", "Naruto", 1999),
mock_series("key2", "One Piece (1999)", 1999),
mock_series("key3", "Bleach", None),
]
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock), \
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
mock_get_all.return_value = series
db_series = MagicMock()
db_series.id = 1
mock_get_by_key.return_value = db_series
app_instance = MagicMock()
app_instance.list.keyDict = {"key1": MagicMock()}
mock_get_app.return_value = app_instance
mock_settings.anime_directory = str(anime_dir)
service = FolderNamingService()
report = await service.run()
assert report.total == 3
assert report.renamed == 1 # Naruto → Naruto (1999)
assert report.skipped == 2 # One Piece (has year), Bleach (no year)
assert report.errors == 0
class TestFolderNamingReport:
def test_to_dict(self):
result = FolderRenameResult(key="k1", old_folder="Naruto", new_folder="Naruto (1999)", success=True, skipped=False)
report = FolderNamingReport(total=1, renamed=1, skipped=0, errors=0, results=[result])
d = report.to_dict()
assert d["total"] == 1
assert d["renamed"] == 1
assert d["results"][0]["new_folder"] == "Naruto (1999)"