Compare commits
4 Commits
47bd393a57
...
f89e403a17
| Author | SHA1 | Date | |
|---|---|---|---|
| f89e403a17 | |||
| 5f46d2e802 | |||
| a384072901 | |||
| d99636e9c7 |
@@ -1 +1 @@
|
|||||||
v1.4.17
|
v1.5.0
|
||||||
|
|||||||
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.
|
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`:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.4.17",
|
"version": "1.5.0",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from src.server.database.connection import get_db_session
|
from src.server.database.connection import get_db_session
|
||||||
from src.server.database.service import AnimeSeriesService, UnresolvedFolderService
|
from src.server.database.service import AnimeSeriesService, UnresolvedFolderService
|
||||||
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||||
from src.server.utils.dependencies import (
|
from src.server.utils.dependencies import (
|
||||||
|
get_background_loader_service,
|
||||||
get_database_session,
|
get_database_session,
|
||||||
get_series_app,
|
get_series_app,
|
||||||
require_auth,
|
require_auth,
|
||||||
@@ -150,6 +152,7 @@ async def resolve_unresolved_folder(
|
|||||||
folder_name: str,
|
folder_name: str,
|
||||||
request: ResolveFolderRequest,
|
request: ResolveFolderRequest,
|
||||||
db=Depends(get_database_session),
|
db=Depends(get_database_session),
|
||||||
|
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
|
||||||
) -> ResolveFolderResponse:
|
) -> ResolveFolderResponse:
|
||||||
"""Resolve an unresolved folder by providing the correct provider key.
|
"""Resolve an unresolved folder by providing the correct provider key.
|
||||||
|
|
||||||
@@ -215,6 +218,26 @@ async def resolve_unresolved_folder(
|
|||||||
folder_name, request.provider_key, series.id
|
folder_name, request.provider_key, series.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Queue background loading task for episodes, NFO, and images
|
||||||
|
try:
|
||||||
|
await background_loader.add_series_loading_task(
|
||||||
|
key=request.provider_key,
|
||||||
|
folder=folder_name,
|
||||||
|
name=unresolved.title,
|
||||||
|
year=unresolved.year,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Queued background loading for resolved folder: %s (key=%s)",
|
||||||
|
folder_name,
|
||||||
|
request.provider_key
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to queue background loading for resolved folder %s: %s",
|
||||||
|
folder_name,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
|
||||||
return ResolveFolderResponse(
|
return ResolveFolderResponse(
|
||||||
status="success",
|
status="success",
|
||||||
message=f"Successfully resolved and added series: {unresolved.title}",
|
message=f"Successfully resolved and added series: {unresolved.title}",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -271,7 +271,7 @@ async def _load_series_into_memory(progress_service=None) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _scan_folders_to_database(progress_service=None) -> int:
|
async def _scan_folders_to_database(progress_service=None) -> tuple[int, int]:
|
||||||
"""Scan anime folders and create AnimeSeries DB records.
|
"""Scan anime folders and create AnimeSeries DB records.
|
||||||
|
|
||||||
This function runs during initial setup only. It delegates to
|
This function runs during initial setup only. It delegates to
|
||||||
@@ -285,7 +285,7 @@ async def _scan_folders_to_database(progress_service=None) -> int:
|
|||||||
progress_service: Optional ProgressService for progress updates
|
progress_service: Optional ProgressService for progress updates
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: Number of new series created
|
Tuple of (number of new series created, number of unresolved folders)
|
||||||
"""
|
"""
|
||||||
logger.info("Scanning anime folders for new series...")
|
logger.info("Scanning anime folders for new series...")
|
||||||
|
|
||||||
@@ -293,16 +293,17 @@ async def _scan_folders_to_database(progress_service=None) -> int:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Anime directory not configured or does not exist, skipping folder scan"
|
"Anime directory not configured or does not exist, skipping folder scan"
|
||||||
)
|
)
|
||||||
return 0
|
return 0, 0
|
||||||
|
|
||||||
# Use SetupService to handle the scanning and creation
|
# Use SetupService to handle the scanning and creation
|
||||||
created_count = await SetupService.run()
|
created_count, unresolved_count = await SetupService.run()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Folder scan complete",
|
"Folder scan complete",
|
||||||
created=created_count
|
created=created_count,
|
||||||
|
unresolved=unresolved_count
|
||||||
)
|
)
|
||||||
return created_count
|
return created_count, unresolved_count
|
||||||
|
|
||||||
|
|
||||||
async def _validate_anime_directory(progress_service=None) -> bool:
|
async def _validate_anime_directory(progress_service=None) -> bool:
|
||||||
@@ -380,7 +381,7 @@ async def perform_initial_setup(progress_service=None):
|
|||||||
# Perform the actual initialization
|
# Perform the actual initialization
|
||||||
try:
|
try:
|
||||||
# Scan folders and create AnimeSeries records first
|
# Scan folders and create AnimeSeries records first
|
||||||
folder_scan_count = await _scan_folders_to_database(progress_service)
|
folder_scan_count, unresolved_count = await _scan_folders_to_database(progress_service)
|
||||||
if folder_scan_count > 0:
|
if folder_scan_count > 0:
|
||||||
logger.info("Created %d series from anime folders", folder_scan_count)
|
logger.info("Created %d series from anime folders", folder_scan_count)
|
||||||
|
|
||||||
@@ -398,11 +399,20 @@ async def perform_initial_setup(progress_service=None):
|
|||||||
await _mark_initial_scan_completed()
|
await _mark_initial_scan_completed()
|
||||||
|
|
||||||
# Mark loading as complete in config (used by middleware to allow redirect to /login)
|
# Mark loading as complete in config (used by middleware to allow redirect to /login)
|
||||||
|
# Only mark complete if there are no unresolved folders - otherwise user needs
|
||||||
|
# to resolve them first via /setup/unresolved
|
||||||
try:
|
try:
|
||||||
from src.server.services.config_service import get_config_service
|
from src.server.services.config_service import get_config_service
|
||||||
config_svc = get_config_service()
|
config_svc = get_config_service()
|
||||||
init_config = config_svc.load_config()
|
init_config = config_svc.load_config()
|
||||||
init_config.other['loading_complete'] = True
|
if unresolved_count == 0:
|
||||||
|
init_config.other['loading_complete'] = True
|
||||||
|
logger.info("No unresolved folders - marking loading complete")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Unresolved folders exist (%d) - deferring loading_complete",
|
||||||
|
unresolved_count
|
||||||
|
)
|
||||||
config_svc.save_config(init_config, create_backup=False)
|
config_svc.save_config(init_config, create_backup=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to save loading_complete flag: %s", e)
|
logger.warning("Failed to save loading_complete flag: %s", e)
|
||||||
|
|||||||
@@ -350,11 +350,16 @@ class NfoScanService:
|
|||||||
return "created"
|
return "created"
|
||||||
|
|
||||||
# NFO exists — check if it needs updating
|
# NFO exists — check if it needs updating
|
||||||
updated = await self._update_nfo_if_needed(key, folder, series_data, nfo_path)
|
updated, year = await self._update_nfo_if_needed(key, folder, series_data, nfo_path)
|
||||||
if updated:
|
if updated:
|
||||||
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path)
|
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path, year=year)
|
||||||
return "updated"
|
return "updated"
|
||||||
|
|
||||||
|
# NFO is valid but series may still be missing year — try to extract from NFO
|
||||||
|
if year is not None and series_data.get("year") is None:
|
||||||
|
logger.info("Extracted year %d from NFO for %s — updating database", year, key)
|
||||||
|
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path, year=year)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _create_nfo(
|
async def _create_nfo(
|
||||||
@@ -421,7 +426,7 @@ class NfoScanService:
|
|||||||
folder: str,
|
folder: str,
|
||||||
series_data: Dict[str, Any],
|
series_data: Dict[str, Any],
|
||||||
nfo_path: str,
|
nfo_path: str,
|
||||||
) -> bool:
|
) -> tuple[bool, Optional[int]]:
|
||||||
"""Load existing NFO, check for missing fields, fill and rewrite.
|
"""Load existing NFO, check for missing fields, fill and rewrite.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -431,13 +436,13 @@ class NfoScanService:
|
|||||||
nfo_path: Full path to the existing NFO file
|
nfo_path: Full path to the existing NFO file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if NFO was updated, False if no changes were needed.
|
Tuple of (True if NFO was updated, extracted year from NFO or None).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("lxml not available — cannot update existing NFO files")
|
logger.warning("lxml not available — cannot update existing NFO files")
|
||||||
return False
|
return False, None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tree = etree.parse(nfo_path)
|
tree = etree.parse(nfo_path)
|
||||||
@@ -445,7 +450,17 @@ class NfoScanService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to parse existing NFO for %s: %s — will regenerate", key, exc)
|
logger.warning("Failed to parse existing NFO for %s: %s — will regenerate", key, exc)
|
||||||
# Corrupt or unreadable NFO — regenerate from TMDB
|
# Corrupt or unreadable NFO — regenerate from TMDB
|
||||||
return await self._regenerate_nfo(key, folder, series_data, nfo_path)
|
updated = await self._regenerate_nfo(key, folder, series_data, nfo_path)
|
||||||
|
return updated, None
|
||||||
|
|
||||||
|
# Extract year from NFO if present
|
||||||
|
year: Optional[int] = None
|
||||||
|
year_elem = root.find("year")
|
||||||
|
if year_elem is not None and year_elem.text and year_elem.text.strip():
|
||||||
|
try:
|
||||||
|
year = int(year_elem.text.strip())
|
||||||
|
except ValueError:
|
||||||
|
logger.debug("Invalid year value in NFO for %s: %s", key, year_elem.text)
|
||||||
|
|
||||||
# Check for missing or empty critical fields
|
# Check for missing or empty critical fields
|
||||||
critical_fields = ["title", "plot", "premiered", "tmdbid"]
|
critical_fields = ["title", "plot", "premiered", "tmdbid"]
|
||||||
@@ -458,7 +473,7 @@ class NfoScanService:
|
|||||||
|
|
||||||
if not missing_fields:
|
if not missing_fields:
|
||||||
logger.debug("NFO for %s is complete — no update needed", key)
|
logger.debug("NFO for %s is complete — no update needed", key)
|
||||||
return False
|
return False, year
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"NFO for %s is missing fields %s — attempting to fill from TMDB",
|
"NFO for %s is missing fields %s — attempting to fill from TMDB",
|
||||||
@@ -470,16 +485,16 @@ class NfoScanService:
|
|||||||
tmdb_id = series_data.get("tmdb_id")
|
tmdb_id = series_data.get("tmdb_id")
|
||||||
if not tmdb_id:
|
if not tmdb_id:
|
||||||
logger.warning("Cannot update NFO for %s: no tmdb_id", key)
|
logger.warning("Cannot update NFO for %s: no tmdb_id", key)
|
||||||
return False
|
return False, year
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to fetch TMDB data for %s: %s", key, exc)
|
logger.warning("Failed to fetch TMDB data for %s: %s", key, exc)
|
||||||
return False
|
return False, year
|
||||||
|
|
||||||
if not tmdb_data:
|
if not tmdb_data:
|
||||||
return False
|
return False, year
|
||||||
|
|
||||||
nfo_model = tmdb_to_nfo_model(
|
nfo_model = tmdb_to_nfo_model(
|
||||||
tmdb_data,
|
tmdb_data,
|
||||||
@@ -504,7 +519,7 @@ class NfoScanService:
|
|||||||
"missing_fields": missing_fields,
|
"missing_fields": missing_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
return True
|
return True, year
|
||||||
|
|
||||||
async def _regenerate_nfo(
|
async def _regenerate_nfo(
|
||||||
self,
|
self,
|
||||||
@@ -626,13 +641,15 @@ class NfoScanService:
|
|||||||
key: str,
|
key: str,
|
||||||
has_nfo: bool,
|
has_nfo: bool,
|
||||||
nfo_path: str,
|
nfo_path: str,
|
||||||
|
year: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update the has_nfo flag and nfo_path in the database.
|
"""Update the has_nfo flag, nfo_path, and optionally year in the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Series key (primary identifier)
|
key: Series key (primary identifier)
|
||||||
has_nfo: Whether the series now has an NFO file
|
has_nfo: Whether the series now has an NFO file
|
||||||
nfo_path: Path to the NFO file
|
nfo_path: Path to the NFO file
|
||||||
|
year: Optional year extracted from NFO to save to DB
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from src.server.database.connection import get_db_session
|
from src.server.database.connection import get_db_session
|
||||||
@@ -647,6 +664,10 @@ class NfoScanService:
|
|||||||
if series.nfo_created_at is None:
|
if series.nfo_created_at is None:
|
||||||
series.nfo_created_at = now
|
series.nfo_created_at = now
|
||||||
series.nfo_updated_at = now
|
series.nfo_updated_at = now
|
||||||
|
# Update year if series has no year and we have one from NFO
|
||||||
|
if year is not None and series.year is None:
|
||||||
|
series.year = year
|
||||||
|
logger.info("Updated year to %d for series: %s", year, key)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
logger.debug("Updated NFO flag for series: %s", key)
|
logger.debug("Updated NFO flag for series: %s", key)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ class SetupService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def run(cls) -> int:
|
async def run(cls) -> tuple[int, int]:
|
||||||
"""Run the setup service.
|
"""Run the setup service.
|
||||||
|
|
||||||
Scans anime folders, creates AnimeSeries records, and resolves
|
Scans anime folders, creates AnimeSeries records, and resolves
|
||||||
@@ -275,11 +275,11 @@ class SetupService:
|
|||||||
that initial scan hasn't been completed yet (via _check_initial_scan_status).
|
that initial scan hasn't been completed yet (via _check_initial_scan_status).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of new series created
|
Tuple of (number of new series created, number of unresolved folders)
|
||||||
"""
|
"""
|
||||||
if not settings.anime_directory:
|
if not settings.anime_directory:
|
||||||
logger.info("Anime directory not configured, skipping setup")
|
logger.info("Anime directory not configured, skipping setup")
|
||||||
return 0
|
return 0, 0
|
||||||
|
|
||||||
anime_dir = Path(settings.anime_directory)
|
anime_dir = Path(settings.anime_directory)
|
||||||
if not anime_dir.is_dir():
|
if not anime_dir.is_dir():
|
||||||
@@ -287,7 +287,7 @@ class SetupService:
|
|||||||
"Anime directory does not exist, skipping setup: %s",
|
"Anime directory does not exist, skipping setup: %s",
|
||||||
anime_dir
|
anime_dir
|
||||||
)
|
)
|
||||||
return 0
|
return 0, 0
|
||||||
|
|
||||||
logger.info("Running setup service...")
|
logger.info("Running setup service...")
|
||||||
|
|
||||||
@@ -385,6 +385,7 @@ class SetupService:
|
|||||||
"Could not resolve series key for folder, tracking as unresolved: %s",
|
"Could not resolve series key for folder, tracking as unresolved: %s",
|
||||||
folder_name
|
folder_name
|
||||||
)
|
)
|
||||||
|
unresolved_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Also check if a series with this key already exists (different folder, same anime)
|
# Also check if a series with this key already exists (different folder, same anime)
|
||||||
@@ -445,6 +446,6 @@ class SetupService:
|
|||||||
error=str(e),
|
error=str(e),
|
||||||
exc_info=True
|
exc_info=True
|
||||||
)
|
)
|
||||||
return created_count
|
return created_count, unresolved_count
|
||||||
|
|
||||||
return created_count
|
return created_count, unresolved_count
|
||||||
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;
|
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);
|
||||||
|
|||||||
@@ -451,8 +451,9 @@
|
|||||||
updateStep(stepId, status, msg, percent, current, total);
|
updateStep(stepId, status, msg, percent, current, total);
|
||||||
|
|
||||||
// Check for completion of series_sync
|
// Check for completion of series_sync
|
||||||
// stepId is used because type is 'system_progress' for SYSTEM progress events
|
// For scan_completed messages: stepId='scan_completed', no status field, no metadata
|
||||||
if (metadata?.initialization_complete || (stepId === 'series_sync' && status === 'completed')) {
|
// system_progress events are emitted by progress_service during initial setup (ProgressType.SYSTEM)
|
||||||
|
if (metadata?.initialization_complete || type === 'scan_completed' || type === 'system_progress' || (stepId === 'series_sync' && status === 'completed')) {
|
||||||
// For initial phase, series_sync completion leads to /setup/unresolved
|
// For initial phase, series_sync completion leads to /setup/unresolved
|
||||||
handleSeriesSyncComplete();
|
handleSeriesSyncComplete();
|
||||||
}
|
}
|
||||||
@@ -720,10 +721,42 @@
|
|||||||
createStep('nfo_scan', stepTitles['nfo_scan']);
|
createStep('nfo_scan', stepTitles['nfo_scan']);
|
||||||
// Trigger NFO scan phase via API
|
// Trigger NFO scan phase via API
|
||||||
triggerNfoScanPhase();
|
triggerNfoScanPhase();
|
||||||
|
connectWebSocket();
|
||||||
|
} else {
|
||||||
|
// For initial phase, initialization already completed before this page loaded
|
||||||
|
// Check for unresolved folders immediately and redirect
|
||||||
|
checkUnresolvedAndRedirect();
|
||||||
}
|
}
|
||||||
|
|
||||||
connectWebSocket();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// For initial phase, check if there are unresolved folders and redirect accordingly
|
||||||
|
// This is needed because the backend initialization completes before this page loads,
|
||||||
|
// so WebSocket events are missed
|
||||||
|
async function checkUnresolvedAndRedirect() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/setup/unresolved');
|
||||||
|
if (response.ok) {
|
||||||
|
const folders = await response.json();
|
||||||
|
if (folders.length > 0) {
|
||||||
|
// Unresolved folders exist - redirect to unresolved page
|
||||||
|
clearSetupPhase();
|
||||||
|
window.location.href = '/setup/unresolved';
|
||||||
|
} else {
|
||||||
|
// No unresolved folders - redirect to login
|
||||||
|
clearSetupPhase();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Error - stay on page and wait for potential WebSocket events
|
||||||
|
console.error('Failed to check unresolved folders:', response.status);
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking unresolved folders:', error);
|
||||||
|
// Stay on page and wait for WebSocket events
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?
|
||||||
|
|||||||
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)"
|
||||||
@@ -771,13 +771,13 @@ class TestScanFoldersToDatabase:
|
|||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.services.initialization_service.SetupService.run',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
new_callable=AsyncMock, return_value=1
|
new_callable=AsyncMock, return_value=(1, 0)
|
||||||
) as mock_setup_run:
|
) as mock_setup_run:
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == (1, 0)
|
||||||
mock_setup_run.assert_called_once()
|
mock_setup_run.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -798,13 +798,13 @@ class TestScanFoldersToDatabase:
|
|||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.services.initialization_service.SetupService.run',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
new_callable=AsyncMock, return_value=1
|
new_callable=AsyncMock, return_value=(1, 0)
|
||||||
) as mock_setup_run:
|
) as mock_setup_run:
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == (1, 0)
|
||||||
mock_setup_run.assert_called_once()
|
mock_setup_run.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -825,13 +825,13 @@ class TestScanFoldersToDatabase:
|
|||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.services.initialization_service.SetupService.run',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
new_callable=AsyncMock, return_value=1
|
new_callable=AsyncMock, return_value=(1, 0)
|
||||||
) as mock_setup_run:
|
) as mock_setup_run:
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == (1, 0)
|
||||||
mock_setup_run.assert_called_once()
|
mock_setup_run.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -852,13 +852,13 @@ class TestScanFoldersToDatabase:
|
|||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.services.initialization_service.SetupService.run',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
new_callable=AsyncMock, return_value=1
|
new_callable=AsyncMock, return_value=(1, 0)
|
||||||
) as mock_setup_run:
|
) as mock_setup_run:
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == (1, 0)
|
||||||
mock_setup_run.assert_called_once()
|
mock_setup_run.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -879,13 +879,13 @@ class TestScanFoldersToDatabase:
|
|||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.services.initialization_service.SetupService.run',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
new_callable=AsyncMock, return_value=1
|
new_callable=AsyncMock, return_value=(1, 0)
|
||||||
) as mock_setup_run:
|
) as mock_setup_run:
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == (1, 0)
|
||||||
mock_setup_run.assert_called_once()
|
mock_setup_run.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -906,13 +906,13 @@ class TestScanFoldersToDatabase:
|
|||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.services.initialization_service.SetupService.run',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
new_callable=AsyncMock, return_value=0
|
new_callable=AsyncMock, return_value=(0, 0)
|
||||||
) as mock_setup_run:
|
) as mock_setup_run:
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 0
|
assert result == (0, 0)
|
||||||
mock_setup_run.assert_called_once()
|
mock_setup_run.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -923,11 +923,11 @@ class TestScanFoldersToDatabase:
|
|||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.services.initialization_service.SetupService.run',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
new_callable=AsyncMock, return_value=0
|
new_callable=AsyncMock, return_value=(0, 0)
|
||||||
) as mock_setup_run:
|
) as mock_setup_run:
|
||||||
mock_settings.anime_directory = None
|
mock_settings.anime_directory = None
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 0
|
assert result == (0, 0)
|
||||||
mock_setup_run.assert_not_called()
|
mock_setup_run.assert_not_called()
|
||||||
|
|||||||
@@ -94,7 +94,25 @@ class TestResolveKeyViaSearch:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_empty_when_multiple_results(self):
|
async def test_returns_empty_when_multiple_results(self):
|
||||||
"""Search returns >1 results → returns empty string."""
|
"""Search returns >1 results but none fuzzy-match → returns empty string."""
|
||||||
|
mock_series_app = AsyncMock()
|
||||||
|
mock_series_app.search.return_value = [
|
||||||
|
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'},
|
||||||
|
{'title': 'Totally Different Series', 'link': '/anime/stream/totally-different'}
|
||||||
|
]
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Fuzzy match against "Attack on Titan" succeeds → returns the key
|
||||||
|
assert result == 'attack-on-titan'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_key_when_fuzzy_match_in_multiple_results(self):
|
||||||
|
"""Search returns >1 results with one fuzzy match → returns that result's key."""
|
||||||
mock_series_app = AsyncMock()
|
mock_series_app = AsyncMock()
|
||||||
mock_series_app.search.return_value = [
|
mock_series_app.search.return_value = [
|
||||||
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'},
|
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'},
|
||||||
@@ -107,7 +125,8 @@ class TestResolveKeyViaSearch:
|
|||||||
):
|
):
|
||||||
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
||||||
|
|
||||||
assert result == ''
|
# "Attack on Titan" fuzzy-matches "Attack on Titan" → returns its key
|
||||||
|
assert result == 'attack-on-titan'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_empty_when_name_mismatch(self):
|
async def test_returns_empty_when_name_mismatch(self):
|
||||||
@@ -207,7 +226,7 @@ class TestSetupServiceRun:
|
|||||||
):
|
):
|
||||||
result = await SetupService.run()
|
result = await SetupService.run()
|
||||||
|
|
||||||
assert result == 1
|
assert result == (1, 0)
|
||||||
mock_create.assert_called_once()
|
mock_create.assert_called_once()
|
||||||
call_kwargs = mock_create.call_args.kwargs
|
call_kwargs = mock_create.call_args.kwargs
|
||||||
assert call_kwargs['key'] == 'attack-on-titan'
|
assert call_kwargs['key'] == 'attack-on-titan'
|
||||||
@@ -250,7 +269,7 @@ class TestSetupServiceRun:
|
|||||||
|
|
||||||
result = await SetupService.run()
|
result = await SetupService.run()
|
||||||
|
|
||||||
assert result == 0
|
assert result == (0, 0)
|
||||||
mock_create.assert_not_called()
|
mock_create.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -359,8 +378,8 @@ class TestSetupServiceRun:
|
|||||||
|
|
||||||
result = await SetupService.run()
|
result = await SetupService.run()
|
||||||
|
|
||||||
# Should return 0 since no series was created
|
# created=0 (no series), unresolved=1 (folder couldn't be resolved)
|
||||||
assert result == 0
|
assert result == (0, 1)
|
||||||
# Should track as unresolved instead of creating series
|
# Should track as unresolved instead of creating series
|
||||||
mock_create_unresolved.assert_called_once()
|
mock_create_unresolved.assert_called_once()
|
||||||
call_kwargs = mock_create_unresolved.call_args.kwargs
|
call_kwargs = mock_create_unresolved.call_args.kwargs
|
||||||
@@ -378,7 +397,7 @@ class TestSetupServiceRun:
|
|||||||
|
|
||||||
result = await SetupService.run()
|
result = await SetupService.run()
|
||||||
|
|
||||||
assert result == 0
|
assert result == (0, 0)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_zero_when_directory_not_exist(self, tmp_path):
|
async def test_returns_zero_when_directory_not_exist(self, tmp_path):
|
||||||
@@ -392,7 +411,7 @@ class TestSetupServiceRun:
|
|||||||
|
|
||||||
result = await SetupService.run()
|
result = await SetupService.run()
|
||||||
|
|
||||||
assert result == 0
|
assert result == (0, 0)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_skips_files_only_processes_directories(self, tmp_path):
|
async def test_skips_files_only_processes_directories(self, tmp_path):
|
||||||
@@ -442,7 +461,7 @@ class TestSetupServiceRun:
|
|||||||
result = await SetupService.run()
|
result = await SetupService.run()
|
||||||
|
|
||||||
# Empty search results → folder tracked as unresolved, not created
|
# Empty search results → folder tracked as unresolved, not created
|
||||||
assert result == 0
|
assert result == (0, 1)
|
||||||
mock_create_unresolved.assert_called_once()
|
mock_create_unresolved.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user