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:
109
Docs/TESTING.md
109
Docs/TESTING.md
@@ -1,104 +1,33 @@
|
||||
# Testing Documentation
|
||||
|
||||
## Document Purpose
|
||||
### Testing FolderNamingService
|
||||
|
||||
This document describes the testing strategy, guidelines, and practices for the Aniworld project.
|
||||
|
||||
### 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`.
|
||||
FolderNamingService fixes missing years in anime folder names after NFO refresh. Test file: `tests/unit/test_folder_naming_service.py`.
|
||||
|
||||
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_title_from_folder_name()` — strips year suffix
|
||||
- `_resolve_key_via_search()` — resolves provider key via fuzzy title matching
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_key_when_single_exact_match(self):
|
||||
"""Search returns 1 result with same name → returns key."""
|
||||
mock_series_app = AsyncMock()
|
||||
mock_series_app.search.return_value = [
|
||||
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
|
||||
]
|
||||
# Safe: repeated calls never accumulate years
|
||||
r1 = FolderNamingService._build_target_folder("Naruto", 1999)
|
||||
r2 = FolderNamingService._build_target_folder(r1, 1999)
|
||||
assert r1 == r2 == "Naruto (1999)"
|
||||
|
||||
with patch('src.server.services.setup_service.get_series_app', return_value=mock_series_app):
|
||||
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
||||
|
||||
assert result == 'attack-on-titan'
|
||||
# Safe: existing year is replaced, not appended
|
||||
result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
|
||||
assert result == "Naruto (1999)"
|
||||
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
|
||||
|
||||
When testing code that uses `aiohttp.ClientSession`:
|
||||
|
||||
@@ -31,6 +31,9 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
|
||||
"schedule_time": config.schedule_time,
|
||||
"schedule_days": config.schedule_days,
|
||||
"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": {
|
||||
"is_running": runtime.get("is_running", False),
|
||||
|
||||
@@ -52,6 +52,12 @@ class SchedulerConfig(BaseModel):
|
||||
description="Download series images (poster.jpg, fanart.jpg, logo.png) "
|
||||
"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
|
||||
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
||||
|
||||
|
||||
205
src/server/services/folder_naming_service.py
Normal file
205
src/server/services/folder_naming_service.py
Normal 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
|
||||
@@ -268,6 +268,9 @@ class SchedulerService:
|
||||
"image_scan_after_rescan": (
|
||||
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": (
|
||||
self._last_scan_time.isoformat()
|
||||
if self._last_scan_time
|
||||
@@ -404,7 +407,18 @@ class SchedulerService:
|
||||
logger.error("NFO scan failed: %s", exc, exc_info=True)
|
||||
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:
|
||||
try:
|
||||
queued = await self._run_auto_download()
|
||||
@@ -413,7 +427,7 @@ class SchedulerService:
|
||||
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
||||
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:
|
||||
try:
|
||||
image_result = await self._run_image_scan()
|
||||
@@ -536,6 +550,13 @@ class SchedulerService:
|
||||
)
|
||||
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:
|
||||
"""Queue and start downloads for all series with missing episodes."""
|
||||
from src.server.models.download import EpisodeIdentifier
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,11 @@ AniWorld.SchedulerConfig = (function() {
|
||||
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
|
||||
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
||||
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
||||
@@ -87,7 +92,8 @@ AniWorld.SchedulerConfig = (function() {
|
||||
enabled: enabled,
|
||||
schedule_time: scheduleTime,
|
||||
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);
|
||||
|
||||
@@ -479,6 +479,13 @@
|
||||
<span>Auto-download missing episodes after rescan</span>
|
||||
</label>
|
||||
</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>
|
||||
|
||||
@@ -761,6 +768,7 @@
|
||||
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_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_file: document.getElementById('logging_file').value.trim() || null,
|
||||
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
||||
|
||||
344
tests/unit/test_folder_naming_service.py
Normal file
344
tests/unit/test_folder_naming_service.py
Normal 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)"
|
||||
Reference in New Issue
Block a user