diff --git a/docs/API.md b/docs/API.md index 2f2bff4..529de09 100644 --- a/docs/API.md +++ b/docs/API.md @@ -660,7 +660,10 @@ Return current application configuration. "data_dir": "data", "scheduler": { "enabled": true, - "interval_minutes": 60 + "interval_minutes": 60, + "schedule_time": "03:00", + "schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "auto_download_after_rescan": false }, "logging": { "level": "INFO", @@ -691,7 +694,9 @@ Apply an update to the configuration. { "scheduler": { "enabled": true, - "interval_minutes": 30 + "interval_minutes": 60, + "schedule_time": "06:30", + "schedule_days": ["mon", "wed", "fri"] }, "logging": { "level": "DEBUG" @@ -1177,47 +1182,21 @@ Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L637-L684) Prefix: `/api/scheduler` -Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py#L1-L122) +All GET/POST config responses share the same envelope: + +```json +{ + "success": true, + "config": { ... }, + "status": { ... } +} +``` + +Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py) ### GET /api/scheduler/config -Get current scheduler configuration. - -**Authentication:** Required - -**Response (200 OK):** - -```json -{ - "enabled": true, - "interval_minutes": 60 -} -``` - -Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py#L22-L42) - -### POST /api/scheduler/config - -Update scheduler configuration. - -**Authentication:** Required - -**Request Body:** - -```json -{ - "enabled": true, - "interval_minutes": 30 -} -``` - -**Response (200 OK):** Updated scheduler configuration - -Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py#L45-L75) - -### POST /api/scheduler/trigger-rescan - -Manually trigger a library rescan. +Get current scheduler configuration and runtime status. **Authentication:** Required @@ -1226,11 +1205,65 @@ Manually trigger a library rescan. ```json { "success": true, + "config": { + "enabled": true, + "interval_minutes": 60, + "schedule_time": "03:00", + "schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "auto_download_after_rescan": false + }, + "status": { + "is_running": true, + "next_run": "2025-07-15T03:00:00+00:00", + "last_run": null, + "scan_in_progress": false + } +} +``` + +### POST /api/scheduler/config + +Update scheduler configuration and apply changes immediately. + +**Authentication:** Required + +**Request Body (all fields optional, uses model defaults):** + +```json +{ + "enabled": true, + "schedule_time": "06:30", + "schedule_days": ["mon", "wed", "fri"], + "auto_download_after_rescan": true +} +``` + +**Response (200 OK):** Same envelope as GET, reflecting saved values. + +**Validation errors (422):** + +- `schedule_time` must match `HH:MM` (00:00–23:59) +- `schedule_days` entries must be one of `mon tue wed thu fri sat sun` +- `interval_minutes` must be ≥ 1 + +### POST /api/scheduler/trigger-rescan + +Manually trigger a library rescan (and auto-download if configured). + +**Authentication:** Required + +**Response (200 OK):** + +```json +{ "message": "Rescan started successfully" } ``` -Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py#L78-L122) +**Error responses:** + +- `503` — SeriesApp not yet initialised +- `500` — Rescan failed unexpectedly --- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 82752bb..5355f6c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -39,6 +39,23 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle ## [Unreleased] - 2026-01-18 +### Added + +- **Cron-based Scheduler**: Replaced the asyncio sleep-loop with APScheduler's `AsyncIOScheduler + CronTrigger` + - Schedule rescans at a specific **time of day** (`HH:MM`) on selected **days of the week** + - New `SchedulerConfig` fields: `schedule_time` (default `"03:00"`), `schedule_days` (default all 7), `auto_download_after_rescan` (default `false`) + - Old `interval_minutes` field retained for backward compatibility +- **Auto-download after rescan**: When `auto_download_after_rescan` is enabled, missing episodes are automatically queued for download after each scheduled rescan +- **Day-of-week UI**: New day-of-week pill toggles (Mon–Sun) in the Settings → Scheduler section +- **Live config reload**: POST `/api/scheduler/config` reschedules the APScheduler job without restarting the application +- **Enriched API response**: GET/POST `/api/scheduler/config` now returns `{"success", "config", "status"}` envelope including `next_run`, `last_run`, and `scan_in_progress` + +### Changed + +- Scheduler API response format: previously returned flat config; now returns `{"success": true, "config": {...}, "status": {...}}` +- `reload_config()` is now a synchronous method accepting a `SchedulerConfig` argument (previously async, no arguments) +- Dependencies: added `APScheduler>=3.10.4` to `requirements.txt` + ### Fixed - **Series Visibility**: Fixed issue where series added to the database weren't appearing in the API/UI diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 910086e..b37a12c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -114,7 +114,10 @@ Location: `data/config.json` "data_dir": "data", "scheduler": { "enabled": true, - "interval_minutes": 60 + "interval_minutes": 60, + "schedule_time": "03:00", + "schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "auto_download_after_rescan": false }, "logging": { "level": "INFO", @@ -161,12 +164,17 @@ Source: [src/server/models/config.py](../src/server/models/config.py#L62-L66) ### 4.2 Scheduler Settings -Controls automatic library rescanning. +Controls automatic cron-based library rescanning (powered by APScheduler). -| Field | Type | Default | Description | -| ---------------------------- | ---- | ------- | -------------------------------------------- | -| `scheduler.enabled` | bool | `true` | Enable/disable automatic scans. | -| `scheduler.interval_minutes` | int | `60` | Minutes between automatic scans. Minimum: 1. | +| Field | Type | Default | Description | +| -------------------------------------- | ------------ | --------------------------------------------- | -------------------------------------------------------------------- | +| `scheduler.enabled` | bool | `true` | Enable/disable automatic scans. | +| `scheduler.interval_minutes` | int | `60` | Legacy field kept for backward compatibility. Minimum: 1. | +| `scheduler.schedule_time` | string | `"03:00"` | Daily run time in 24-h `HH:MM` format. | +| `scheduler.schedule_days` | list[string] | `["mon","tue","wed","thu","fri","sat","sun"]` | Days of the week to run the scan. Empty list disables the cron job. | +| `scheduler.auto_download_after_rescan` | bool | `false` | Automatically queue missing episodes for download after each rescan. | + +Valid day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`. Source: [src/server/models/config.py](../src/server/models/config.py#L5-L12) diff --git a/docs/features.md b/docs/features.md index 5c15282..45dcc16 100644 --- a/docs/features.md +++ b/docs/features.md @@ -19,7 +19,7 @@ The application now features a comprehensive configuration system that allows us - Organized into logical sections with clear labels and help text - Real-time saving with immediate feedback - Configuration validation to prevent invalid settings -- Full control over scheduler interval, logging options, and backup settings +- Full control over cron-based scheduler (time, days of week, auto-download), logging options, and backup settings --- @@ -35,14 +35,14 @@ The application now features a comprehensive configuration system that allows us - General Settings: Application name and data directory configuration - Security Settings: Master password setup with strength indicator - Anime Directory: Primary directory path for anime storage - - Scheduler Settings: Enable/disable scheduler and configure check interval (in minutes) + - Scheduler Settings: Enable/disable scheduler, configure daily run time, select days of week, and optionally auto-download missing episodes after rescan - Logging Settings: Configure log level, file path, file size limits, and backup count - Backup Settings: Enable automatic backups with configurable path and retention period - NFO Settings: TMDB API key, auto-creation options, and media file download preferences - **Enhanced Settings/Config Modal**: Comprehensive configuration interface accessible from main page: - General Settings: Edit application name and data directory - Anime Directory: Modify anime storage location with browse functionality - - Scheduler Configuration: Enable/disable and configure check interval for automated operations + - Scheduler Configuration: Enable/disable, set cron run time (`HH:MM`), select active days of the week, and toggle auto-download after rescan - Logging Configuration: Full control over logging level, file rotation, and backup count - Backup Configuration: Configure automatic backup settings including path and retention - NFO Settings: Complete control over TMDB integration and media file downloads diff --git a/requirements.txt b/requirements.txt index 4513817..322817a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,5 @@ sqlalchemy>=2.0.35 aiosqlite>=0.19.0 aiohttp>=3.9.0 lxml>=5.0.0 -pillow>=10.0.0 \ No newline at end of file +pillow>=10.0.0 +APScheduler>=3.10.4 \ No newline at end of file diff --git a/src/server/api/scheduler.py b/src/server/api/scheduler.py index 8b5cf2c..e0e4f8d 100644 --- a/src/server/api/scheduler.py +++ b/src/server/api/scheduler.py @@ -4,12 +4,13 @@ This module provides endpoints for managing scheduled tasks such as automatic anime library rescans. """ import logging -from typing import Dict, Optional +from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException, status from src.server.models.config import SchedulerConfig from src.server.services.config_service import ConfigServiceError, get_config_service +from src.server.services.scheduler_service import get_scheduler_service from src.server.utils.dependencies import require_auth logger = logging.getLogger(__name__) @@ -17,78 +18,105 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/scheduler", tags=["scheduler"]) -@router.get("/config", response_model=SchedulerConfig) -def get_scheduler_config( - auth: Optional[dict] = Depends(require_auth) -) -> SchedulerConfig: - """Get current scheduler configuration. +def _build_response(config: SchedulerConfig) -> Dict[str, Any]: + """Build a standardised GET/POST response combining config + runtime status.""" + scheduler_service = get_scheduler_service() + runtime = scheduler_service.get_status() - Args: - auth: Authentication token (optional for read operations) + return { + "success": True, + "config": { + "enabled": config.enabled, + "interval_minutes": config.interval_minutes, + "schedule_time": config.schedule_time, + "schedule_days": config.schedule_days, + "auto_download_after_rescan": config.auto_download_after_rescan, + }, + "status": { + "is_running": runtime.get("is_running", False), + "next_run": runtime.get("next_run"), + "last_run": runtime.get("last_run"), + "scan_in_progress": runtime.get("scan_in_progress", False), + }, + } + + +@router.get("/config") +def get_scheduler_config( + auth: Optional[dict] = Depends(require_auth), +) -> Dict[str, Any]: + """Get current scheduler configuration along with runtime status. Returns: - SchedulerConfig: Current scheduler configuration + Combined config and status response. Raises: - HTTPException: If configuration cannot be loaded + HTTPException: 500 if configuration cannot be loaded. """ try: config_service = get_config_service() app_config = config_service.load_config() - return app_config.scheduler - except ConfigServiceError as e: - logger.error(f"Failed to load scheduler config: {e}") + return _build_response(app_config.scheduler) + except ConfigServiceError as exc: + logger.error("Failed to load scheduler config: %s", exc) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to load scheduler configuration: {e}", - ) from e + detail=f"Failed to load scheduler configuration: {exc}", + ) from exc -@router.post("/config", response_model=SchedulerConfig) +@router.post("/config") def update_scheduler_config( scheduler_config: SchedulerConfig, auth: dict = Depends(require_auth), -) -> SchedulerConfig: - """Update scheduler configuration. +) -> Dict[str, Any]: + """Update scheduler configuration and apply changes immediately. - Args: - scheduler_config: New scheduler configuration - auth: Authentication token (required) + Accepts the full SchedulerConfig body; any fields not supplied default + to their model defaults (backward compatible). Returns: - SchedulerConfig: Updated scheduler configuration + Combined config and status response reflecting the saved config. Raises: - HTTPException: If configuration update fails + HTTPException: 422 on validation errors (handled by FastAPI/Pydantic), + 500 on save or scheduler failure. """ try: config_service = get_config_service() app_config = config_service.load_config() - - # Update scheduler section app_config.scheduler = scheduler_config - - # Save and return config_service.save_config(app_config) + logger.info( - f"Scheduler config updated by {auth.get('username', 'unknown')}" + "Scheduler config updated by %s: time=%s days=%s auto_dl=%s", + auth.get("username", "unknown"), + scheduler_config.schedule_time, + scheduler_config.schedule_days, + scheduler_config.auto_download_after_rescan, ) - return scheduler_config - except ConfigServiceError as e: - logger.error(f"Failed to update scheduler config: {e}") + # Apply changes to the running scheduler without restart + try: + sched_svc = get_scheduler_service() + sched_svc.reload_config(scheduler_config) + except Exception as sched_exc: # pylint: disable=broad-exception-caught + logger.error("Scheduler reload after config update failed: %s", sched_exc) + # Config was saved — don't fail the request, just warn + + return _build_response(scheduler_config) + + except ConfigServiceError as exc: + logger.error("Failed to update scheduler config: %s", exc) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update scheduler configuration: {e}", - ) from e + detail=f"Failed to update scheduler configuration: {exc}", + ) from exc @router.post("/trigger-rescan", response_model=Dict[str, str]) async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]: - """Manually trigger a library rescan. - - This endpoint triggers an immediate anime library rescan, bypassing - the scheduler interval. + """Manually trigger a library rescan (and auto-download if configured). Args: auth: Authentication token (required) @@ -100,8 +128,7 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]: HTTPException: If rescan cannot be triggered """ try: - # Import here to avoid circular dependency - from src.server.utils.dependencies import get_series_app + from src.server.utils.dependencies import get_series_app # noqa: PLC0415 series_app = get_series_app() if not series_app: @@ -110,21 +137,19 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]: detail="SeriesApp not initialized", ) - # Trigger the rescan logger.info( - f"Manual rescan triggered by {auth.get('username', 'unknown')}" + "Manual rescan triggered by %s", auth.get("username", "unknown") ) - # Use existing rescan logic from anime API - from src.server.api.anime import trigger_rescan as do_rescan + from src.server.api.anime import trigger_rescan as do_rescan # noqa: PLC0415 return await do_rescan() except HTTPException: raise - except Exception as e: + except Exception as exc: logger.exception("Failed to trigger manual rescan") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to trigger rescan: {str(e)}", - ) from e + detail=f"Failed to trigger rescan: {exc}", + ) from exc diff --git a/src/server/models/config.py b/src/server/models/config.py index f16b79e..a910003 100644 --- a/src/server/models/config.py +++ b/src/server/models/config.py @@ -2,16 +2,67 @@ from typing import Dict, List, Optional from pydantic import BaseModel, Field, ValidationError, field_validator +_VALID_DAYS = frozenset(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]) +_ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] + class SchedulerConfig(BaseModel): - """Scheduler related configuration.""" + """Scheduler related configuration. + + Cron-based scheduling is configured via ``schedule_time`` and + ``schedule_days``. The legacy ``interval_minutes`` field is kept for + backward compatibility but is **deprecated** and ignored when + ``schedule_time`` is set. + """ enabled: bool = Field( default=True, description="Whether the scheduler is enabled" ) interval_minutes: int = Field( - default=60, ge=1, description="Scheduler interval in minutes" + default=60, + ge=1, + description="[Deprecated] Scheduler interval in minutes. " + "Use schedule_time + schedule_days instead.", ) + schedule_time: str = Field( + default="03:00", + description="Daily run time in 24-hour HH:MM format (e.g. '03:00')", + ) + schedule_days: List[str] = Field( + default_factory=lambda: list(_ALL_DAYS), + description="Days of week to run the scheduler (3-letter lowercase " + "abbreviations: mon, tue, wed, thu, fri, sat, sun). " + "Empty list means disabled.", + ) + auto_download_after_rescan: bool = Field( + default=False, + description="Automatically queue and start downloads for all missing " + "episodes after a scheduled rescan completes.", + ) + + @field_validator("schedule_time") + @classmethod + def validate_schedule_time(cls, v: str) -> str: + """Validate HH:MM format within 00:00–23:59.""" + import re + if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", v or ""): + raise ValueError( + f"Invalid schedule_time '{v}'. " + "Expected HH:MM in 24-hour format (00:00–23:59)." + ) + return v + + @field_validator("schedule_days") + @classmethod + def validate_schedule_days(cls, v: List[str]) -> List[str]: + """Validate each entry is a valid 3-letter lowercase day abbreviation.""" + invalid = [d for d in v if d not in _VALID_DAYS] + if invalid: + raise ValueError( + f"Invalid day(s) in schedule_days: {invalid}. " + f"Allowed values: {sorted(_VALID_DAYS)}" + ) + return v class BackupConfig(BaseModel): diff --git a/src/server/services/scheduler_service.py b/src/server/services/scheduler_service.py index 5c94ab2..f409c11 100644 --- a/src/server/services/scheduler_service.py +++ b/src/server/services/scheduler_service.py @@ -1,305 +1,377 @@ """Scheduler service for automatic library rescans. -This module provides a background scheduler that performs periodic library rescans -according to the configured interval. It handles conflict resolution with manual -scans and persists scheduler state. +Uses APScheduler's AsyncIOScheduler with CronTrigger for precise +cron-based scheduling. The legacy interval-based loop has been removed +in favour of the cron approach. """ -import asyncio +from __future__ import annotations + from datetime import datetime, timezone -from typing import Optional +from typing import List, Optional import structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger from src.server.models.config import SchedulerConfig from src.server.services.config_service import ConfigServiceError, get_config_service logger = structlog.get_logger(__name__) +_JOB_ID = "scheduled_rescan" + class SchedulerServiceError(Exception): """Service-level exception for scheduler operations.""" class SchedulerService: - """Manages automatic library rescans on a configurable schedule. - - Features: - - Periodic library rescans based on configured interval - - Conflict resolution (prevents concurrent scans) - - State persistence across restarts - - Manual trigger capability - - Enable/disable functionality - - The scheduler uses a simple interval-based approach where rescans - are triggered every N minutes as configured. + """Manages automatic library rescans on a cron-based schedule. + + Uses APScheduler's AsyncIOScheduler so scheduling integrates cleanly + with the running asyncio event loop. Supports: + + - Cron-based scheduling (time of day + days of week) + - Immediate manual trigger + - Live config reloading without app restart + - Auto-queueing downloads of missing episodes after rescan """ - def __init__(self): - """Initialize the scheduler service.""" + def __init__(self) -> None: + """Initialise the scheduler service.""" self._is_running: bool = False - self._task: Optional[asyncio.Task] = None + self._scheduler: Optional[AsyncIOScheduler] = None self._config: Optional[SchedulerConfig] = None self._last_scan_time: Optional[datetime] = None - self._next_scan_time: Optional[datetime] = None self._scan_in_progress: bool = False - - logger.info("SchedulerService initialized") + logger.info("SchedulerService initialised") + + # ------------------------------------------------------------------ + # Public lifecycle methods + # ------------------------------------------------------------------ async def start(self) -> None: - """Start the scheduler background task. - + """Start the APScheduler with the configured cron trigger. + Raises: - SchedulerServiceError: If scheduler is already running + SchedulerServiceError: If the scheduler is already running or + config cannot be loaded. """ if self._is_running: raise SchedulerServiceError("Scheduler is already running") - - # Load configuration + try: config_service = get_config_service() config = config_service.load_config() self._config = config.scheduler - except ConfigServiceError as e: - logger.error("Failed to load scheduler configuration", error=str(e)) - raise SchedulerServiceError(f"Failed to load config: {e}") from e - + except ConfigServiceError as exc: + logger.error("Failed to load scheduler configuration", error=str(exc)) + raise SchedulerServiceError(f"Failed to load config: {exc}") from exc + + self._scheduler = AsyncIOScheduler() + if not self._config.enabled: - logger.info("Scheduler is disabled in configuration") + logger.info("Scheduler is disabled in configuration — not adding jobs") + self._is_running = True return - + + trigger = self._build_cron_trigger() + if trigger is None: + logger.warning( + "schedule_days is empty — scheduler started but no job scheduled" + ) + else: + self._scheduler.add_job( + self._perform_rescan, + trigger=trigger, + id=_JOB_ID, + replace_existing=True, + misfire_grace_time=300, + ) + logger.info( + "Scheduler started with cron trigger", + schedule_time=self._config.schedule_time, + schedule_days=self._config.schedule_days, + ) + + self._scheduler.start() self._is_running = True - self._task = asyncio.create_task(self._scheduler_loop()) - logger.info( - "Scheduler started", - interval_minutes=self._config.interval_minutes - ) async def stop(self) -> None: - """Stop the scheduler background task gracefully. - - Cancels the running scheduler task and waits for it to complete. - """ + """Stop the APScheduler gracefully.""" if not self._is_running: logger.debug("Scheduler stop called but not running") return - + + if self._scheduler and self._scheduler.running: + self._scheduler.shutdown(wait=False) + logger.info("Scheduler stopped") + self._is_running = False - - if self._task and not self._task.done(): - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - logger.info("Scheduler task cancelled successfully") - - logger.info("Scheduler stopped") async def trigger_rescan(self) -> bool: """Manually trigger a library rescan. - + Returns: - True if rescan was triggered, False if scan already in progress - + True if rescan was started; False if a scan is already running. + Raises: - SchedulerServiceError: If scheduler is not running + SchedulerServiceError: If the scheduler service is not started. """ if not self._is_running: raise SchedulerServiceError("Scheduler is not running") - + if self._scan_in_progress: logger.warning("Cannot trigger rescan: scan already in progress") return False - + logger.info("Manual rescan triggered") await self._perform_rescan() return True - async def reload_config(self) -> None: - """Reload scheduler configuration from config service. - - The scheduler will restart with the new configuration if it's running. - - Raises: - SchedulerServiceError: If config reload fails + def reload_config(self, config: SchedulerConfig) -> None: + """Apply a new SchedulerConfig immediately. + + If the scheduler is already running the job is rescheduled (or + removed) without stopping the scheduler. + + Args: + config: New scheduler configuration to apply. """ - try: - config_service = get_config_service() - config = config_service.load_config() - old_config = self._config - self._config = config.scheduler - - logger.info( - "Scheduler configuration reloaded", - old_enabled=old_config.enabled if old_config else None, - new_enabled=self._config.enabled, - old_interval=old_config.interval_minutes if old_config else None, - new_interval=self._config.interval_minutes - ) - - # Restart scheduler if it's running and config changed - if self._is_running: - if not self._config.enabled: - logger.info("Scheduler disabled, stopping...") - await self.stop() - elif old_config and old_config.interval_minutes != self._config.interval_minutes: - logger.info("Interval changed, restarting scheduler...") - await self.stop() - await self.start() - elif self._config.enabled and not self._is_running: - logger.info("Scheduler enabled, starting...") - await self.start() - - except ConfigServiceError as e: - logger.error("Failed to reload scheduler configuration", error=str(e)) - raise SchedulerServiceError(f"Failed to reload config: {e}") from e + self._config = config + logger.info( + "Scheduler config reloaded", + enabled=config.enabled, + schedule_time=config.schedule_time, + schedule_days=config.schedule_days, + auto_download=config.auto_download_after_rescan, + ) + + if not self._scheduler or not self._scheduler.running: + return + + if not config.enabled: + if self._scheduler.get_job(_JOB_ID): + self._scheduler.remove_job(_JOB_ID) + logger.info("Scheduler job removed (disabled)") + return + + trigger = self._build_cron_trigger() + if trigger is None: + if self._scheduler.get_job(_JOB_ID): + self._scheduler.remove_job(_JOB_ID) + logger.warning("Scheduler job removed — schedule_days is empty") + else: + if self._scheduler.get_job(_JOB_ID): + self._scheduler.reschedule_job(_JOB_ID, trigger=trigger) + logger.info( + "Scheduler rescheduled with cron trigger", + schedule_time=config.schedule_time, + schedule_days=config.schedule_days, + ) + else: + self._scheduler.add_job( + self._perform_rescan, + trigger=trigger, + id=_JOB_ID, + replace_existing=True, + misfire_grace_time=300, + ) + logger.info( + "Scheduler job added with cron trigger", + schedule_time=config.schedule_time, + schedule_days=config.schedule_days, + ) def get_status(self) -> dict: - """Get current scheduler status. - + """Return current scheduler status including cron configuration. + Returns: - Dict containing scheduler state information + Dict containing scheduler state and config fields. """ + next_run: Optional[str] = None + if self._scheduler and self._scheduler.running: + job = self._scheduler.get_job(_JOB_ID) + if job and job.next_run_time: + next_run = job.next_run_time.isoformat() + return { "is_running": self._is_running, "enabled": self._config.enabled if self._config else False, "interval_minutes": self._config.interval_minutes if self._config else None, - "last_scan_time": self._last_scan_time.isoformat() if self._last_scan_time else None, - "next_scan_time": self._next_scan_time.isoformat() if self._next_scan_time else None, + "schedule_time": self._config.schedule_time if self._config else None, + "schedule_days": self._config.schedule_days if self._config else [], + "auto_download_after_rescan": ( + self._config.auto_download_after_rescan if self._config else False + ), + "last_run": self._last_scan_time.isoformat() if self._last_scan_time else None, + "next_run": next_run, "scan_in_progress": self._scan_in_progress, } - async def _scheduler_loop(self) -> None: - """Main scheduler loop that runs periodic rescans. - - This coroutine runs indefinitely until cancelled, sleeping for the - configured interval between rescans. + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _build_cron_trigger(self) -> Optional[CronTrigger]: + """Convert config fields into an APScheduler CronTrigger. + + Returns: + CronTrigger instance or None if schedule_days is empty. """ - logger.info("Scheduler loop started") - - while self._is_running: - try: - if not self._config or not self._config.enabled: - logger.debug("Scheduler disabled, exiting loop") - break - - # Calculate next scan time - interval_seconds = self._config.interval_minutes * 60 - self._next_scan_time = datetime.now(timezone.utc) - self._next_scan_time = self._next_scan_time.replace( - second=0, microsecond=0 - ) - - # Wait for the interval - logger.debug( - "Waiting for next scan", - interval_minutes=self._config.interval_minutes, - next_scan=self._next_scan_time.isoformat() - ) - await asyncio.sleep(interval_seconds) - - # Perform the rescan - if self._is_running: # Check again after sleep - await self._perform_rescan() - - except asyncio.CancelledError: - logger.info("Scheduler loop cancelled") - break - except Exception as e: # pylint: disable=broad-exception-caught - logger.error( - "Error in scheduler loop", - error=str(e), - exc_info=True - ) - # Continue loop despite errors - await asyncio.sleep(60) # Wait 1 minute before retrying - - logger.info("Scheduler loop exited") + if not self._config or not self._config.schedule_days: + return None + + hour_str, minute_str = self._config.schedule_time.split(":") + day_of_week = ",".join(self._config.schedule_days) + + trigger = CronTrigger( + hour=int(hour_str), + minute=int(minute_str), + day_of_week=day_of_week, + ) + logger.debug( + "CronTrigger built", + hour=hour_str, + minute=minute_str, + day_of_week=day_of_week, + ) + return trigger + + async def _broadcast(self, event_type: str, data: dict) -> None: + """Broadcast a WebSocket event to all connected clients.""" + try: + from src.server.services.websocket_service import ( # noqa: PLC0415 + get_websocket_service, + ) + + ws_service = get_websocket_service() + await ws_service.manager.broadcast({"type": event_type, "data": data}) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.warning("WebSocket broadcast failed", event=event_type, error=str(exc)) + + async def _auto_download_missing(self) -> None: + """Queue and start downloads for all series with missing episodes.""" + from src.server.models.download import EpisodeIdentifier # noqa: PLC0415 + from src.server.utils.dependencies import ( # noqa: PLC0415 + get_anime_service, + get_download_service, + ) + + anime_service = get_anime_service() + download_service = get_download_service() + + series_list = anime_service._cached_list_missing() + queued_count = 0 + + for series in series_list: + episode_dict: dict = series.get("episodeDict") or {} + if not episode_dict: + continue + + episodes: List[EpisodeIdentifier] = [] + for season_str, ep_numbers in episode_dict.items(): + for ep_num in ep_numbers: + episodes.append( + EpisodeIdentifier(season=int(season_str), episode=int(ep_num)) + ) + + if not episodes: + continue + + await download_service.add_to_queue( + serie_id=series.get("key", ""), + serie_folder=series.get("folder", series.get("name", "")), + serie_name=series.get("name", ""), + episodes=episodes, + ) + queued_count += len(episodes) + logger.info( + "Auto-download queued episodes", + series=series.get("key"), + count=len(episodes), + ) + + if queued_count: + await download_service.start_queue_processing() + logger.info("Auto-download queue processing started", queued=queued_count) + + await self._broadcast("auto_download_started", {"queued_count": queued_count}) + logger.info("Auto-download completed", queued_count=queued_count) async def _perform_rescan(self) -> None: - """Execute a library rescan. - - This method calls the anime service to perform the actual rescan. - It includes conflict detection to prevent concurrent scans. - """ + """Execute a library rescan and optionally trigger auto-download.""" if self._scan_in_progress: logger.warning("Skipping rescan: previous scan still in progress") return - + self._scan_in_progress = True scan_start = datetime.now(timezone.utc) - + try: logger.info("Starting scheduled library rescan") - - # Import here to avoid circular dependency - from src.server.services.websocket_service import get_websocket_service - from src.server.utils.dependencies import get_anime_service - + + from src.server.utils.dependencies import get_anime_service # noqa: PLC0415 + anime_service = get_anime_service() - ws_service = get_websocket_service() - - # Notify clients that scheduled rescan started - await ws_service.manager.broadcast({ - "type": "scheduled_rescan_started", - "data": { - "timestamp": scan_start.isoformat() - } - }) - - # Perform the rescan + + await self._broadcast( + "scheduled_rescan_started", + {"timestamp": scan_start.isoformat()}, + ) + await anime_service.rescan() - + self._last_scan_time = datetime.now(timezone.utc) - - logger.info( - "Scheduled library rescan completed", - duration_seconds=(self._last_scan_time - scan_start).total_seconds() - ) - - # Notify clients that rescan completed - await ws_service.manager.broadcast({ - "type": "scheduled_rescan_completed", - "data": { + duration = (self._last_scan_time - scan_start).total_seconds() + + logger.info("Scheduled library rescan completed", duration_seconds=duration) + + await self._broadcast( + "scheduled_rescan_completed", + { "timestamp": self._last_scan_time.isoformat(), - "duration_seconds": (self._last_scan_time - scan_start).total_seconds() - } - }) - - except Exception as e: # pylint: disable=broad-exception-caught - logger.error( - "Scheduled rescan failed", - error=str(e), - exc_info=True + "duration_seconds": duration, + }, ) - - # Notify clients of error - try: - from src.server.services.websocket_service import get_websocket_service - ws_service = get_websocket_service() - await ws_service.manager.broadcast({ - "type": "scheduled_rescan_error", - "data": { - "error": str(e), - "timestamp": datetime.now(timezone.utc).isoformat() - } - }) - except Exception: # pylint: disable=broad-exception-caught - pass # Don't fail if WebSocket notification fails - + + # Auto-download after rescan + if self._config and self._config.auto_download_after_rescan: + logger.info("Auto-download after rescan is enabled — starting") + try: + await self._auto_download_missing() + except Exception as dl_exc: # pylint: disable=broad-exception-caught + logger.error( + "Auto-download after rescan failed", + error=str(dl_exc), + exc_info=True, + ) + await self._broadcast( + "auto_download_error", {"error": str(dl_exc)} + ) + else: + logger.debug("Auto-download after rescan is disabled — skipping") + + except Exception as exc: # pylint: disable=broad-exception-caught + logger.error("Scheduled rescan failed", error=str(exc), exc_info=True) + await self._broadcast( + "scheduled_rescan_error", + {"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()}, + ) + finally: self._scan_in_progress = False -# Module-level singleton instance +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + _scheduler_service: Optional[SchedulerService] = None def get_scheduler_service() -> SchedulerService: - """Get the singleton scheduler service instance. - - Returns: - SchedulerService singleton - """ + """Return the singleton SchedulerService instance.""" global _scheduler_service if _scheduler_service is None: _scheduler_service = SchedulerService() @@ -307,6 +379,6 @@ def get_scheduler_service() -> SchedulerService: def reset_scheduler_service() -> None: - """Reset the scheduler service singleton (for testing).""" + """Reset the singleton (used in tests).""" global _scheduler_service _scheduler_service = None diff --git a/src/server/web/static/css/pages/index.css b/src/server/web/static/css/pages/index.css index ab3a40e..ab5ba35 100644 --- a/src/server/web/static/css/pages/index.css +++ b/src/server/web/static/css/pages/index.css @@ -228,3 +228,122 @@ font-size: var(--font-size-title); } } + +/* ============================================================ + Scheduler day-of-week toggle pills + ============================================================ */ + +.scheduler-days-container { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); +} + +.scheduler-day-toggle-label { + display: inline-flex; + align-items: center; + cursor: pointer; + user-select: none; +} + +/* Hide the raw checkbox visually */ +.scheduler-day-toggle-label .scheduler-day-checkbox { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +/* Pill styling */ +.scheduler-day-label { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.6rem; + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-xl); + font-size: var(--font-size-caption); + font-weight: 600; + color: var(--color-text-secondary); + background-color: var(--color-bg-secondary); + transition: background-color var(--transition-duration) var(--transition-easing), + color var(--transition-duration) var(--transition-easing), + border-color var(--transition-duration) var(--transition-easing); + cursor: pointer; +} + +/* Checked state – filled accent */ +.scheduler-day-checkbox:checked + .scheduler-day-label { + background-color: var(--color-accent); + border-color: var(--color-accent); + color: #ffffff; +} + +/* Hover for unchecked */ +.scheduler-day-toggle-label:hover .scheduler-day-label { + border-color: var(--color-accent); + color: var(--color-accent); +} + +/* Hover for checked */ +.scheduler-day-toggle-label:hover .scheduler-day-checkbox:checked + .scheduler-day-label { + background-color: var(--color-accent-hover); + border-color: var(--color-accent-hover); + color: #ffffff; +} + +/* Dark theme overrides */ +[data-theme="dark"] .scheduler-day-label { + border-color: var(--color-border-dark); + color: var(--color-text-secondary-dark); + background-color: var(--color-bg-secondary-dark); +} + +[data-theme="dark"] .scheduler-day-checkbox:checked + .scheduler-day-label { + background-color: var(--color-accent-dark); + border-color: var(--color-accent-dark); + color: var(--color-bg-primary-dark); +} + +[data-theme="dark"] .scheduler-day-toggle-label:hover .scheduler-day-label { + border-color: var(--color-accent-dark); + color: var(--color-accent-dark); +} + +/* Next run display */ +#scheduler-next-run { + font-style: italic; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); +} + +[data-theme="dark"] #scheduler-next-run { + color: var(--color-text-tertiary-dark); +} + +/* Advanced/collapsible section */ +.config-advanced { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + margin-top: var(--spacing-sm); +} + +.config-advanced summary { + cursor: pointer; + padding: var(--spacing-xs) 0; + font-weight: 500; +} + +/* Responsive: wrap day pills to 2 rows on mobile */ +@media (max-width: 480px) { + .scheduler-days-container { + gap: var(--spacing-xs); + } + + .scheduler-day-label { + min-width: 2.2rem; + padding: var(--spacing-xs); + } +} diff --git a/src/server/web/static/js/app.js b/src/server/web/static/js/app.js index 0397515..2ddced2 100644 --- a/src/server/web/static/js/app.js +++ b/src/server/web/static/js/app.js @@ -1554,24 +1554,42 @@ class AniWorldApp { const data = await response.json(); if (data.success) { - const config = data.config; + const config = data.config || {}; + const schedulerStatus = data.status || {}; // Update UI elements - document.getElementById('scheduled-rescan-enabled').checked = config.enabled; - document.getElementById('scheduled-rescan-time').value = config.time || '03:00'; - document.getElementById('auto-download-after-rescan').checked = config.auto_download_after_rescan; + document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled; + document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00'; + document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan; + + // Update day-of-week checkboxes + const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun']; + ['mon','tue','wed','thu','fri','sat','sun'].forEach(day => { + const cb = document.getElementById(`scheduler-day-${day}`); + if (cb) cb.checked = days.includes(day); + }); // Update status display - document.getElementById('next-rescan-time').textContent = - config.next_run ? new Date(config.next_run).toLocaleString() : 'Not scheduled'; - document.getElementById('last-rescan-time').textContent = - config.last_run ? new Date(config.last_run).toLocaleString() : 'Never'; + const nextRunEl = document.getElementById('scheduler-next-run'); + if (nextRunEl) { + nextRunEl.textContent = schedulerStatus.next_run + ? new Date(schedulerStatus.next_run).toLocaleString() + : 'Not scheduled'; + } + const lastRunEl = document.getElementById('last-rescan-time'); + if (lastRunEl) { + lastRunEl.textContent = schedulerStatus.last_run + ? new Date(schedulerStatus.last_run).toLocaleString() + : 'Never'; + } const statusBadge = document.getElementById('scheduler-running-status'); - statusBadge.textContent = config.is_running ? 'Running' : 'Stopped'; - statusBadge.className = `info-value status-badge ${config.is_running ? 'running' : 'stopped'}`; + if (statusBadge) { + statusBadge.textContent = schedulerStatus.is_running ? 'Running' : 'Stopped'; + statusBadge.className = `info-value status-badge ${schedulerStatus.is_running ? 'running' : 'stopped'}`; + } - // Enable/disable time input based on checkbox + // Enable/disable time/day inputs based on checkbox this.toggleSchedulerTimeInput(); } } catch (error) { @@ -1583,17 +1601,23 @@ class AniWorldApp { async saveSchedulerConfig() { try { const enabled = document.getElementById('scheduled-rescan-enabled').checked; - const time = document.getElementById('scheduled-rescan-time').value; + const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00'; const autoDownload = document.getElementById('auto-download-after-rescan').checked; + // Collect checked day-of-week values + const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun'] + .filter(day => { + const cb = document.getElementById(`scheduler-day-${day}`); + return cb ? cb.checked : true; + }); + const response = await this.makeAuthenticatedRequest('/api/scheduler/config', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enabled, - time: time, + schedule_time: scheduleTime, + schedule_days: scheduleDays, auto_download_after_rescan: autoDownload }) }); @@ -1603,7 +1627,12 @@ class AniWorldApp { if (data.success) { this.showToast('Scheduler configuration saved successfully', 'success'); - // Reload config to update display + // Update next-run display from response + const nextRunEl = document.getElementById('scheduler-next-run'); + if (nextRunEl && data.status && data.status.next_run) { + nextRunEl.textContent = new Date(data.status.next_run).toLocaleString(); + } + // Reload config to sync the full UI await this.loadSchedulerConfig(); } else { this.showToast(`Failed to save config: ${data.error}`, 'error'); @@ -1637,11 +1666,19 @@ class AniWorldApp { toggleSchedulerTimeInput() { const enabled = document.getElementById('scheduled-rescan-enabled').checked; const timeConfig = document.getElementById('rescan-time-config'); + const daysConfig = document.getElementById('rescan-days-config'); + const nextRunEl = document.getElementById('scheduler-next-run'); - if (enabled) { - timeConfig.classList.add('enabled'); - } else { - timeConfig.classList.remove('enabled'); + if (timeConfig) { + timeConfig.classList.toggle('enabled', enabled); + } + if (daysConfig) { + daysConfig.classList.toggle('enabled', enabled); + } + if (nextRunEl) { + nextRunEl.parentElement && nextRunEl.parentElement.parentElement + ? nextRunEl.parentElement.parentElement.classList.toggle('hidden', !enabled) + : null; } } diff --git a/src/server/web/templates/index.html b/src/server/web/templates/index.html index fd91f78..994ca16 100644 --- a/src/server/web/templates/index.html +++ b/src/server/web/templates/index.html @@ -254,17 +254,46 @@ -
- - - - How often to check for new episodes (minimum 1 minute) - +
+ +
-
- - +
+ +
+ + + + + + + +
+ + Scheduler runs at the selected time on checked days. Uncheck all to disable scheduling. +
@@ -276,11 +305,23 @@
+ +
+ Advanced +
+ + + + Deprecated: only used if cron scheduling is not configured + +
+
+
Next Scheduled Rescan: - - + -
Last Scheduled Rescan: diff --git a/tests/api/test_scheduler_endpoints.py b/tests/api/test_scheduler_endpoints.py index 1fcf07e..ce225cd 100644 --- a/tests/api/test_scheduler_endpoints.py +++ b/tests/api/test_scheduler_endpoints.py @@ -36,393 +36,409 @@ async def client(): @pytest.fixture async def authenticated_client(client): """Create an authenticated test client with token.""" - # Login to get token response = await client.post( "/api/auth/login", json={"password": "TestPass123!"} ) assert response.status_code == 200 token = response.json()["access_token"] - - # Add token to default headers client.headers.update({"Authorization": f"Bearer {token}"}) yield client @pytest.fixture def mock_config_service(): - """Create mock configuration service.""" + """Create mock configuration service with default SchedulerConfig.""" service = Mock() - - # Mock config object with scheduler section config = Mock() config.scheduler = SchedulerConfig( enabled=True, - interval_minutes=60 + interval_minutes=60, + schedule_time="03:00", + schedule_days=["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + auto_download_after_rescan=False, ) - + def save_config_side_effect(new_config): - """Update the scheduler config when save is called.""" config.scheduler = new_config.scheduler - + service.load_config = Mock(return_value=config) service.save_config = Mock(side_effect=save_config_side_effect) - return service +@pytest.fixture +def mock_scheduler_service(): + """Create a mock scheduler service returning a basic status.""" + svc = Mock() + svc.get_status = Mock(return_value={ + "is_running": True, + "next_run": None, + "last_run": None, + "scan_in_progress": False, + }) + svc.reload_config = Mock() + return svc + + +# --------------------------------------------------------------------------- +# GET /api/scheduler/config +# --------------------------------------------------------------------------- class TestGetSchedulerConfig: """Tests for GET /api/scheduler/config endpoint.""" @pytest.mark.asyncio - async def test_get_scheduler_config_success( - self, - authenticated_client, - mock_config_service + async def test_returns_success_envelope( + self, authenticated_client, mock_config_service, mock_scheduler_service ): - """Test successful scheduler configuration retrieval.""" - with patch( - 'src.server.api.scheduler.get_config_service', - return_value=mock_config_service - ): + """Response carries the top-level success/config/status envelope.""" + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): response = await authenticated_client.get("/api/scheduler/config") - - assert response.status_code == 200 - data = response.json() - assert data["enabled"] is True - assert data["interval_minutes"] == 60 - mock_config_service.load_config.assert_called_once() + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "config" in data + assert "status" in data @pytest.mark.asyncio - async def test_get_scheduler_config_unauthorized(self, client): - """Test scheduler config retrieval without authentication.""" + async def test_config_contains_all_fields( + self, authenticated_client, mock_config_service, mock_scheduler_service + ): + """Config block includes all SchedulerConfig fields.""" + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + response = await authenticated_client.get("/api/scheduler/config") + + cfg = response.json()["config"] + assert cfg["enabled"] is True + assert cfg["interval_minutes"] == 60 + assert cfg["schedule_time"] == "03:00" + assert cfg["schedule_days"] == ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] + assert cfg["auto_download_after_rescan"] is False + + @pytest.mark.asyncio + async def test_status_block_present( + self, authenticated_client, mock_config_service, mock_scheduler_service + ): + """Status block includes runtime keys.""" + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + response = await authenticated_client.get("/api/scheduler/config") + + st = response.json()["status"] + for key in ("is_running", "next_run", "last_run", "scan_in_progress"): + assert key in st + + @pytest.mark.asyncio + async def test_unauthorized(self, client): + """GET without auth token returns 401.""" response = await client.get("/api/scheduler/config") assert response.status_code == 401 @pytest.mark.asyncio - async def test_get_scheduler_config_load_failure( - self, - authenticated_client, - mock_config_service + async def test_config_load_failure_returns_500( + self, authenticated_client, mock_config_service, mock_scheduler_service ): - """Test scheduler config retrieval when config loading fails.""" + """500 when config_service.load_config raises ConfigServiceError.""" from src.server.services.config_service import ConfigServiceError - - mock_config_service.load_config.side_effect = ConfigServiceError( - "Failed to load config" - ) - - with patch( - 'src.server.api.scheduler.get_config_service', - return_value=mock_config_service - ): + + mock_config_service.load_config.side_effect = ConfigServiceError("disk error") + + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): response = await authenticated_client.get("/api/scheduler/config") - - assert response.status_code == 500 - assert "Failed to load scheduler configuration" in response.text + + assert response.status_code == 500 + assert "Failed to load scheduler configuration" in response.text +# --------------------------------------------------------------------------- +# POST /api/scheduler/config +# --------------------------------------------------------------------------- class TestUpdateSchedulerConfig: """Tests for POST /api/scheduler/config endpoint.""" @pytest.mark.asyncio - async def test_update_scheduler_config_success( - self, - authenticated_client, - mock_config_service + async def test_update_returns_success_envelope( + self, authenticated_client, mock_config_service, mock_scheduler_service ): - """Test successful scheduler configuration update.""" - new_config = { + """POST returns success envelope with saved values.""" + payload = { "enabled": False, - "interval_minutes": 120 + "interval_minutes": 120, + "schedule_time": "06:30", + "schedule_days": ["mon", "wed", "fri"], + "auto_download_after_rescan": True, } - - with patch( - 'src.server.api.scheduler.get_config_service', - return_value=mock_config_service - ): - response = await authenticated_client.post( - "/api/scheduler/config", - json=new_config - ) - - assert response.status_code == 200 - data = response.json() - assert data["enabled"] is False - assert data["interval_minutes"] == 120 - - mock_config_service.load_config.assert_called_once() - mock_config_service.save_config.assert_called_once() + + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + response = await authenticated_client.post("/api/scheduler/config", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["config"]["enabled"] is False + assert data["config"]["schedule_time"] == "06:30" + assert data["config"]["schedule_days"] == ["mon", "wed", "fri"] + assert data["config"]["auto_download_after_rescan"] is True @pytest.mark.asyncio - async def test_update_scheduler_config_unauthorized(self, client): - """Test scheduler config update without authentication.""" - new_config = { - "enabled": False, - "interval_minutes": 120 - } - + async def test_update_persists_to_config_service( + self, authenticated_client, mock_config_service, mock_scheduler_service + ): + """POST calls save_config exactly once.""" + payload = {"enabled": True, "interval_minutes": 30} + + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + response = await authenticated_client.post("/api/scheduler/config", json=payload) + + assert response.status_code == 200 + mock_config_service.save_config.assert_called_once() + + @pytest.mark.asyncio + async def test_reload_config_called_after_save( + self, authenticated_client, mock_config_service, mock_scheduler_service + ): + """POST calls scheduler_service.reload_config(SchedulerConfig) after save.""" + payload = {"enabled": True, "schedule_time": "10:00"} + + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + await authenticated_client.post("/api/scheduler/config", json=payload) + + mock_scheduler_service.reload_config.assert_called_once() + call_arg = mock_scheduler_service.reload_config.call_args[0][0] + assert isinstance(call_arg, SchedulerConfig) + assert call_arg.schedule_time == "10:00" + + @pytest.mark.asyncio + async def test_update_unauthorized(self, client): + """POST without auth token returns 401.""" response = await client.post( "/api/scheduler/config", - json=new_config + json={"enabled": False, "interval_minutes": 120}, ) assert response.status_code == 401 @pytest.mark.asyncio - async def test_update_scheduler_config_invalid_data( - self, - authenticated_client - ): - """Test scheduler config update with invalid data.""" - invalid_config = { - "enabled": "not_a_boolean", # Should be boolean - "interval_minutes": -1 # Should be positive (>= 1) - } - + async def test_invalid_interval_returns_422(self, authenticated_client): + """interval_minutes < 1 triggers Pydantic validation error (422).""" response = await authenticated_client.post( "/api/scheduler/config", - json=invalid_config + json={"enabled": True, "interval_minutes": 0}, ) - # Pydantic validation should fail with 422 assert response.status_code == 422 @pytest.mark.asyncio - async def test_update_scheduler_config_save_failure( - self, - authenticated_client, - mock_config_service - ): - """Test scheduler config update when save fails.""" - from src.server.services.config_service import ConfigServiceError - - mock_config_service.save_config.side_effect = ConfigServiceError( - "Failed to save config" + async def test_invalid_schedule_time_returns_422(self, authenticated_client): + """Bad schedule_time format triggers validation error (422).""" + response = await authenticated_client.post( + "/api/scheduler/config", + json={"enabled": True, "schedule_time": "25:00"}, ) - - new_config = { - "enabled": False, - "rescan_interval_hours": 48, - "rescan_on_startup": True - } - - with patch( - 'src.server.api.scheduler.get_config_service', - return_value=mock_config_service - ): - response = await authenticated_client.post( - "/api/scheduler/config", - json=new_config - ) - - assert response.status_code == 500 - assert "Failed to update scheduler configuration" in response.text + assert response.status_code == 422 @pytest.mark.asyncio - async def test_update_scheduler_enable_disable_toggle( - self, - authenticated_client, - mock_config_service - ): - """Test toggling scheduler enabled state.""" - # First enable - with patch( - 'src.server.api.scheduler.get_config_service', - return_value=mock_config_service - ): - response = await authenticated_client.post( - "/api/scheduler/config", - json={ - "enabled": True, - "interval_minutes": 60 - } - ) - assert response.status_code == 200 - assert response.json()["enabled"] is True - - # Then disable - response = await authenticated_client.post( - "/api/scheduler/config", - json={ - "enabled": False, - "interval_minutes": 60 - } - ) - assert response.status_code == 200 - assert response.json()["enabled"] is False + async def test_invalid_schedule_days_returns_422(self, authenticated_client): + """Unknown day abbreviation triggers validation error (422).""" + response = await authenticated_client.post( + "/api/scheduler/config", + json={"enabled": True, "schedule_days": ["monday"]}, + ) + assert response.status_code == 422 @pytest.mark.asyncio - async def test_update_scheduler_interval_validation( - self, - authenticated_client, - mock_config_service + async def test_empty_schedule_days_accepted( + self, authenticated_client, mock_config_service, mock_scheduler_service ): - """Test scheduler interval value validation.""" - with patch( - 'src.server.api.scheduler.get_config_service', - return_value=mock_config_service - ): - # Test minimum interval (1 minute) + """Empty schedule_days list is valid (disables the cron job).""" + payload = {"enabled": True, "schedule_days": []} + + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + response = await authenticated_client.post("/api/scheduler/config", json=payload) + + assert response.status_code == 200 + assert response.json()["config"]["schedule_days"] == [] + + @pytest.mark.asyncio + async def test_update_enable_disable_toggle( + self, authenticated_client, mock_config_service, mock_scheduler_service + ): + """Toggling enabled is reflected in the returned config.""" + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + r1 = await authenticated_client.post( + "/api/scheduler/config", + json={"enabled": True, "interval_minutes": 60}, + ) + assert r1.json()["config"]["enabled"] is True + + r2 = await authenticated_client.post( + "/api/scheduler/config", + json={"enabled": False, "interval_minutes": 60}, + ) + assert r2.json()["config"]["enabled"] is False + + @pytest.mark.asyncio + async def test_save_failure_returns_500( + self, authenticated_client, mock_config_service, mock_scheduler_service + ): + """500 when config_service.save_config raises ConfigServiceError.""" + from src.server.services.config_service import ConfigServiceError + + mock_config_service.save_config.side_effect = ConfigServiceError("disk full") + + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): response = await authenticated_client.post( "/api/scheduler/config", - json={ - "enabled": True, - "interval_minutes": 1 - } + json={"enabled": False}, ) - assert response.status_code == 200 - - # Test large interval (7 days = 10080 minutes) - response = await authenticated_client.post( - "/api/scheduler/config", - json={ - "enabled": True, - "interval_minutes": 10080 - } - ) - assert response.status_code == 200 + + assert response.status_code == 500 + assert "Failed to update scheduler configuration" in response.text + + @pytest.mark.asyncio + async def test_backward_compat_minimal_payload( + self, authenticated_client, mock_config_service, mock_scheduler_service + ): + """Payload with only legacy fields fills new fields with model defaults.""" + payload = {"enabled": True, "interval_minutes": 60} + + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + response = await authenticated_client.post("/api/scheduler/config", json=payload) + + assert response.status_code == 200 + cfg = response.json()["config"] + assert cfg["schedule_time"] == "03:00" + assert cfg["auto_download_after_rescan"] is False + assert len(cfg["schedule_days"]) == 7 + + @pytest.mark.asyncio + async def test_interval_boundary_values( + self, authenticated_client, mock_config_service, mock_scheduler_service + ): + """interval_minutes = 1 and 10080 (1 week) are both valid.""" + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + for minutes in (1, 10080): + r = await authenticated_client.post( + "/api/scheduler/config", + json={"enabled": True, "interval_minutes": minutes}, + ) + assert r.status_code == 200 +# --------------------------------------------------------------------------- +# POST /api/scheduler/trigger-rescan +# --------------------------------------------------------------------------- class TestTriggerRescan: """Tests for POST /api/scheduler/trigger-rescan endpoint.""" @pytest.mark.asyncio async def test_trigger_rescan_success(self, authenticated_client): - """Test successful manual rescan trigger.""" + """Successful trigger returns 200 with a message.""" mock_trigger = AsyncMock(return_value={"message": "Rescan triggered"}) mock_series_app = Mock() - - with patch( - 'src.server.utils.dependencies.get_series_app', - return_value=mock_series_app - ), patch( - 'src.server.api.anime.trigger_rescan', - mock_trigger - ): - response = await authenticated_client.post( - "/api/scheduler/trigger-rescan" - ) - - assert response.status_code == 200 - data = response.json() - assert "message" in data - mock_trigger.assert_called_once() + + with patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \ + patch("src.server.api.anime.trigger_rescan", mock_trigger): + response = await authenticated_client.post("/api/scheduler/trigger-rescan") + + assert response.status_code == 200 + assert "message" in response.json() + mock_trigger.assert_called_once() @pytest.mark.asyncio async def test_trigger_rescan_unauthorized(self, client): - """Test manual rescan trigger without authentication.""" + """Trigger without auth token returns 401.""" response = await client.post("/api/scheduler/trigger-rescan") assert response.status_code == 401 @pytest.mark.asyncio async def test_trigger_rescan_series_app_not_initialized( - self, - authenticated_client + self, authenticated_client ): - """Test manual rescan trigger when SeriesApp not initialized.""" - with patch( - 'src.server.utils.dependencies.get_series_app', - return_value=None - ): - response = await authenticated_client.post( - "/api/scheduler/trigger-rescan" - ) - - assert response.status_code == 503 - assert "SeriesApp not initialized" in response.text + """503 when SeriesApp is not yet initialised.""" + with patch("src.server.utils.dependencies.get_series_app", return_value=None): + response = await authenticated_client.post("/api/scheduler/trigger-rescan") + + assert response.status_code == 503 + assert "SeriesApp not initialized" in response.text @pytest.mark.asyncio async def test_trigger_rescan_failure(self, authenticated_client): - """Test manual rescan trigger when rescan fails.""" - mock_trigger = AsyncMock( - side_effect=Exception("Rescan failed") - ) + """500 when underlying rescan call raises an exception.""" + mock_trigger = AsyncMock(side_effect=Exception("Rescan failed")) mock_series_app = Mock() - - with patch( - 'src.server.utils.dependencies.get_series_app', - return_value=mock_series_app - ), patch( - 'src.server.api.anime.trigger_rescan', - mock_trigger - ): - response = await authenticated_client.post( - "/api/scheduler/trigger-rescan" - ) - - assert response.status_code == 500 - assert "Failed to trigger rescan" in response.text + + with patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \ + patch("src.server.api.anime.trigger_rescan", mock_trigger): + response = await authenticated_client.post("/api/scheduler/trigger-rescan") + + assert response.status_code == 500 + assert "Failed to trigger rescan" in response.text +# --------------------------------------------------------------------------- +# Multi-step integration tests +# --------------------------------------------------------------------------- class TestSchedulerEndpointsIntegration: - """Integration tests for scheduler endpoints.""" + """Multi-step integration tests for scheduler endpoints.""" @pytest.mark.asyncio async def test_full_config_workflow( - self, - authenticated_client, - mock_config_service + self, authenticated_client, mock_config_service, mock_scheduler_service ): - """Test complete workflow: get config, update, get again.""" - with patch( - 'src.server.api.scheduler.get_config_service', - return_value=mock_config_service - ): - # Get initial config - response = await authenticated_client.get("/api/scheduler/config") - assert response.status_code == 200 - initial_config = response.json() - assert initial_config["enabled"] is True - - # Update config - new_config = { - "enabled": False, - "interval_minutes": 30 - } - response = await authenticated_client.post( + """GET → POST → verify save called and response consistent.""" + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service): + r = await authenticated_client.get("/api/scheduler/config") + assert r.status_code == 200 + assert r.json()["config"]["enabled"] is True + + r = await authenticated_client.post( "/api/scheduler/config", - json=new_config + json={ + "enabled": False, + "interval_minutes": 30, + "schedule_time": "12:00", + "schedule_days": ["mon", "fri"], + }, ) - assert response.status_code == 200 - updated_config = response.json() - assert updated_config["enabled"] is False - assert updated_config["interval_minutes"] == 30 - - # Verify config persisted - mock_config_service.save_config.assert_called_once() + assert r.status_code == 200 + cfg = r.json()["config"] + assert cfg["enabled"] is False + assert cfg["interval_minutes"] == 30 + assert cfg["schedule_time"] == "12:00" + assert cfg["schedule_days"] == ["mon", "fri"] + + mock_config_service.save_config.assert_called_once() @pytest.mark.asyncio async def test_trigger_rescan_after_config_update( - self, - authenticated_client, - mock_config_service + self, authenticated_client, mock_config_service, mock_scheduler_service ): - """Test triggering rescan after updating config.""" + """POST config then POST trigger-rescan both succeed.""" mock_trigger = AsyncMock(return_value={"message": "Rescan triggered"}) mock_series_app = Mock() - - with patch( - 'src.server.api.scheduler.get_config_service', - return_value=mock_config_service - ), patch( - 'src.server.utils.dependencies.get_series_app', - return_value=mock_series_app - ), patch( - 'src.server.api.anime.trigger_rescan', - mock_trigger - ): - # Update config to enable scheduler - response = await authenticated_client.post( + + with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \ + patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service), \ + patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \ + patch("src.server.api.anime.trigger_rescan", mock_trigger): + r = await authenticated_client.post( "/api/scheduler/config", - json={ - "enabled": True, - "interval_minutes": 360 - } + json={"enabled": True, "interval_minutes": 360}, ) - assert response.status_code == 200 - - # Trigger manual rescan - response = await authenticated_client.post( - "/api/scheduler/trigger-rescan" - ) - assert response.status_code == 200 + assert r.status_code == 200 + + r = await authenticated_client.post("/api/scheduler/trigger-rescan") + assert r.status_code == 200 mock_trigger.assert_called_once() diff --git a/tests/integration/test_scheduler_workflow.py b/tests/integration/test_scheduler_workflow.py index 1a1f4b4..cda0ccc 100644 --- a/tests/integration/test_scheduler_workflow.py +++ b/tests/integration/test_scheduler_workflow.py @@ -1,10 +1,8 @@ """Integration tests for scheduler workflow. -This module tests end-to-end scheduler workflows including: -- Scheduler trigger → library rescan → database update workflow -- Configuration changes apply immediately -- Scheduler persistence after application restart -- Concurrent manual and automated scan handling +Tests end-to-end scheduler workflows with the APScheduler-based +SchedulerService, covering lifecycle, manual triggers, config reloading, +WebSocket broadcasting, auto-download, and concurrency protection. """ import asyncio from datetime import datetime, timezone @@ -15,499 +13,511 @@ import pytest from src.server.models.config import AppConfig, SchedulerConfig from src.server.services.scheduler_service import ( SchedulerService, + SchedulerServiceError, + _JOB_ID, get_scheduler_service, reset_scheduler_service, ) +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + @pytest.fixture def mock_config_service(): - """Create a mock configuration service.""" + """Patch get_config_service used by SchedulerService.start().""" with patch("src.server.services.scheduler_service.get_config_service") as mock: config_service = Mock() - - # Default configuration app_config = AppConfig( scheduler=SchedulerConfig( enabled=True, - interval_minutes=1 # Short interval for testing + schedule_time="03:00", + schedule_days=["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + auto_download_after_rescan=False, ) ) config_service.load_config.return_value = app_config - config_service.update_config = Mock() - mock.return_value = config_service yield config_service @pytest.fixture def mock_anime_service(): - """Create a mock anime service that simulates database updates.""" + """Patch get_anime_service used inside _perform_rescan.""" with patch("src.server.utils.dependencies.get_anime_service") as mock: service = Mock() service.rescan = AsyncMock() - service.series_list = [] - - # Simulate database update during rescan - async def rescan_side_effect(): - # Simulate finding new series - service.series_list = [ - {"key": "series1", "name": "New Series 1"}, - {"key": "series2", "name": "New Series 2"} - ] - await asyncio.sleep(0.1) # Simulate work - - service.rescan.side_effect = rescan_side_effect mock.return_value = service yield service @pytest.fixture def mock_websocket_service(): - """Create a mock WebSocket service that tracks broadcasts.""" + """Patch get_websocket_service to capture broadcasts.""" with patch("src.server.services.websocket_service.get_websocket_service") as mock: service = Mock() service.manager = Mock() - service.broadcasts = [] # Track all broadcasts - + service.broadcasts = [] + async def broadcast_side_effect(message): service.broadcasts.append(message) - + service.manager.broadcast = AsyncMock(side_effect=broadcast_side_effect) mock.return_value = service yield service @pytest.fixture -async def scheduler_service(): - """Create a fresh scheduler service instance for each test.""" +async def scheduler_service(mock_config_service): + """Fresh SchedulerService instance; stopped automatically after each test.""" reset_scheduler_service() - service = SchedulerService() - yield service - # Cleanup - if service._is_running: - await service.stop() + svc = SchedulerService() + yield svc + if svc._is_running: + await svc.stop() -class TestSchedulerWorkflow: - """Tests for end-to-end scheduler workflows.""" +# --------------------------------------------------------------------------- +# TestSchedulerLifecycle +# --------------------------------------------------------------------------- + +class TestSchedulerLifecycle: + """Tests for SchedulerService start/stop lifecycle.""" @pytest.mark.asyncio - async def test_scheduled_rescan_updates_database( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test that scheduled rescan updates the database with new series.""" - # Start scheduler - await scheduler_service.start() - - # Wait for at least one scan cycle (1 minute + buffer) - await asyncio.sleep(65) - - # Verify database was updated - assert mock_anime_service.rescan.call_count >= 1 - assert len(mock_anime_service.series_list) == 2 - - # Verify WebSocket notifications were sent - assert len(mock_websocket_service.broadcasts) >= 2 - - # Check for rescan events - event_types = [b["type"] for b in mock_websocket_service.broadcasts] - assert "scheduled_rescan_started" in event_types - assert "scheduled_rescan_completed" in event_types - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_configuration_change_applies_immediately( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test that configuration changes are applied immediately.""" - # Start with 1 minute interval - await scheduler_service.start() - original_interval = scheduler_service._config.interval_minutes - assert original_interval == 1 - - # Change interval to 2 minutes - new_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - interval_minutes=2 - ) - ) - mock_config_service.load_config.return_value = new_config - - # Reload configuration - await scheduler_service.reload_config() - - # Verify new interval is applied - assert scheduler_service._config.interval_minutes == 2 - assert scheduler_service._is_running is True # Should still be running - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_disable_scheduler_stops_execution( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test that disabling scheduler stops future rescans.""" - # Start scheduler + async def test_start_sets_is_running(self, scheduler_service): + """start() sets _is_running to True.""" await scheduler_service.start() assert scheduler_service._is_running is True - - # Wait for one scan to complete - await asyncio.sleep(65) - initial_scan_count = mock_anime_service.rescan.call_count - assert initial_scan_count >= 1 - - # Disable scheduler - disabled_config = AppConfig( - scheduler=SchedulerConfig( - enabled=False, - interval_minutes=1 - ) - ) - mock_config_service.load_config.return_value = disabled_config - await scheduler_service.reload_config() - - # Verify scheduler stopped + + @pytest.mark.asyncio + async def test_stop_clears_is_running(self, scheduler_service): + """stop() sets _is_running to False.""" + await scheduler_service.start() + await scheduler_service.stop() assert scheduler_service._is_running is False - - # Wait another scan cycle - await asyncio.sleep(65) - - # Verify no additional scans occurred - assert mock_anime_service.rescan.call_count == initial_scan_count @pytest.mark.asyncio - async def test_manual_scan_blocks_scheduled_scan( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test that manual scan prevents concurrent scheduled scan.""" + async def test_start_twice_raises(self, scheduler_service): + """Calling start() when already running raises SchedulerServiceError.""" await scheduler_service.start() - - # Make rescan slow to simulate long-running operation - async def slow_rescan(): - await asyncio.sleep(2) - - mock_anime_service.rescan.side_effect = slow_rescan - - # Trigger manual scan - task1 = asyncio.create_task(scheduler_service._perform_rescan()) - - # Wait a bit to ensure manual scan is in progress - await asyncio.sleep(0.5) - assert scheduler_service._scan_in_progress is True - - # Try to trigger another scan (simulating scheduled trigger) - result = await scheduler_service.trigger_rescan() - - # Second scan should be blocked - assert result is False - - # Wait for first scan to complete - await task1 - - # Verify only one scan executed - assert mock_anime_service.rescan.call_count == 1 - - # Cleanup - await scheduler_service.stop() + with pytest.raises(SchedulerServiceError, match="already running"): + await scheduler_service.start() @pytest.mark.asyncio - async def test_scheduler_state_persists_across_restart( - self, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test that scheduler can restart with same configuration.""" - # Create and start first scheduler instance + async def test_stop_when_not_running_is_noop(self, scheduler_service): + """stop() when not started does not raise.""" + await scheduler_service.stop() # should not raise + assert scheduler_service._is_running is False + + @pytest.mark.asyncio + async def test_start_loads_config(self, scheduler_service, mock_config_service): + """start() loads configuration via config_service.""" + await scheduler_service.start() + mock_config_service.load_config.assert_called_once() + + @pytest.mark.asyncio + async def test_start_disabled_scheduler_no_job(self, mock_config_service): + """Disabled scheduler starts but does not add an APScheduler job.""" + mock_config_service.load_config.return_value = AppConfig( + scheduler=SchedulerConfig(enabled=False) + ) reset_scheduler_service() - scheduler1 = SchedulerService() - await scheduler1.start() - - # Record configuration - original_config = scheduler1._config - assert scheduler1._is_running is True - - # Stop scheduler (simulating app shutdown) - await scheduler1.stop() - assert scheduler1._is_running is False - - # Create new scheduler instance (simulating app restart) - reset_scheduler_service() - scheduler2 = SchedulerService() - - # Start new scheduler with same configuration - await scheduler2.start() - - # Verify it has same configuration and is running - assert scheduler2._is_running is True - assert scheduler2._config.enabled == original_config.enabled - assert scheduler2._config.interval_minutes == original_config.interval_minutes - - # Cleanup - await scheduler2.stop() + svc = SchedulerService() + await svc.start() + assert svc._is_running is True + # No job should be registered + assert svc._scheduler.get_job(_JOB_ID) is None + await svc.stop() @pytest.mark.asyncio - async def test_scheduler_recovers_from_rescan_failure( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test that scheduler continues after rescan failure.""" - # Make first rescan fail, subsequent rescans succeed - call_count = {"count": 0} - - async def failing_rescan(): - call_count["count"] += 1 - if call_count["count"] == 1: - raise Exception("Database connection error") - # Subsequent calls succeed - - mock_anime_service.rescan.side_effect = failing_rescan - + async def test_start_registers_apscheduler_job(self, scheduler_service): + """Enabled scheduler registers a job with _JOB_ID.""" + await scheduler_service.start() + job = scheduler_service._scheduler.get_job(_JOB_ID) + assert job is not None + + @pytest.mark.asyncio + async def test_restart_after_stop(self, scheduler_service): + """Service can be started again after being stopped.""" await scheduler_service.start() - - # Wait for multiple scan cycles (2 minutes + buffer) - await asyncio.sleep(130) - - # Verify multiple scans were attempted despite failure - assert mock_anime_service.rescan.call_count >= 2 - - # Verify error was broadcast - error_broadcasts = [ - b for b in mock_websocket_service.broadcasts - if b.get("type") == "scheduled_rescan_error" - ] - assert len(error_broadcasts) >= 1 - - # Cleanup await scheduler_service.stop() + await scheduler_service.start() + assert scheduler_service._is_running is True + + +# --------------------------------------------------------------------------- +# TestSchedulerTriggerRescan +# --------------------------------------------------------------------------- + +class TestSchedulerTriggerRescan: + """Tests for manual trigger_rescan workflow.""" @pytest.mark.asyncio - async def test_full_workflow_trigger_rescan_update_notify( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service + async def test_trigger_rescan_calls_anime_service( + self, scheduler_service, mock_anime_service, mock_websocket_service ): - """Test complete workflow: trigger → rescan → update → notify.""" + """trigger_rescan() calls anime_service.rescan().""" await scheduler_service.start() - - # Trigger manual rescan result = await scheduler_service.trigger_rescan() assert result is True - - # Verify workflow steps - # 1. Rescan was performed - assert mock_anime_service.rescan.call_count == 1 - - # 2. Database was updated with new series - assert len(mock_anime_service.series_list) == 2 - - # 3. WebSocket notifications were sent - assert len(mock_websocket_service.broadcasts) >= 2 - - # 4. Verify event sequence - event_types = [b["type"] for b in mock_websocket_service.broadcasts] - start_index = event_types.index("scheduled_rescan_started") - complete_index = event_types.index("scheduled_rescan_completed") - assert complete_index > start_index # Complete comes after start - - # 5. Verify scan time was recorded + mock_anime_service.rescan.assert_called_once() + + @pytest.mark.asyncio + async def test_trigger_rescan_records_last_run( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """trigger_rescan() updates _last_scan_time.""" + await scheduler_service.start() + await scheduler_service.trigger_rescan() assert scheduler_service._last_scan_time is not None assert isinstance(scheduler_service._last_scan_time, datetime) - - # 6. Scan is no longer in progress + + @pytest.mark.asyncio + async def test_trigger_rescan_when_not_running_raises(self, scheduler_service): + """trigger_rescan() without start() raises SchedulerServiceError.""" + with pytest.raises(SchedulerServiceError, match="not running"): + await scheduler_service.trigger_rescan() + + @pytest.mark.asyncio + async def test_trigger_rescan_blocked_during_scan( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """Second trigger_rescan() returns False while a scan is in progress.""" + async def slow_rescan(): + await asyncio.sleep(0.3) + + mock_anime_service.rescan.side_effect = slow_rescan + await scheduler_service.start() + + task = asyncio.create_task(scheduler_service._perform_rescan()) + await asyncio.sleep(0.05) + assert scheduler_service._scan_in_progress is True + + result = await scheduler_service.trigger_rescan() + assert result is False + + await task + + @pytest.mark.asyncio + async def test_trigger_rescan_scan_in_progress_false_after_completion( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """scan_in_progress returns to False after trigger_rescan completes.""" + await scheduler_service.start() + await scheduler_service.trigger_rescan() assert scheduler_service._scan_in_progress is False - - # Cleanup - await scheduler_service.stop() @pytest.mark.asyncio async def test_multiple_sequential_rescans( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service + self, scheduler_service, mock_anime_service, mock_websocket_service ): - """Test multiple sequential rescans execute successfully.""" + """Three sequential manual rescans all execute successfully.""" await scheduler_service.start() - - # Trigger 3 manual rescans sequentially - for i in range(3): + for _ in range(3): result = await scheduler_service.trigger_rescan() assert result is True - # Small delay between rescans - await asyncio.sleep(0.1) - - # Verify all 3 rescans executed assert mock_anime_service.rescan.call_count == 3 - - # Verify 6 WebSocket broadcasts (start + complete for each scan) - assert len(mock_websocket_service.broadcasts) >= 6 - - # Cleanup - await scheduler_service.stop() + + +# --------------------------------------------------------------------------- +# TestSchedulerWebSocketBroadcasts +# --------------------------------------------------------------------------- + +class TestSchedulerWebSocketBroadcasts: + """Tests for WebSocket event emission during rescan.""" @pytest.mark.asyncio - async def test_scheduler_status_accuracy_during_workflow( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service + async def test_rescan_broadcasts_started_event( + self, scheduler_service, mock_anime_service, mock_websocket_service ): - """Test that status accurately reflects scheduler state during workflow.""" - # Initial status + """_perform_rescan() broadcasts 'scheduled_rescan_started'.""" + await scheduler_service.start() + await scheduler_service.trigger_rescan() + + event_types = [b["type"] for b in mock_websocket_service.broadcasts] + assert "scheduled_rescan_started" in event_types + + @pytest.mark.asyncio + async def test_rescan_broadcasts_completed_event( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """_perform_rescan() broadcasts 'scheduled_rescan_completed'.""" + await scheduler_service.start() + await scheduler_service.trigger_rescan() + + event_types = [b["type"] for b in mock_websocket_service.broadcasts] + assert "scheduled_rescan_completed" in event_types + + @pytest.mark.asyncio + async def test_rescan_broadcasts_error_on_failure( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """_perform_rescan() broadcasts 'scheduled_rescan_error' when rescan raises.""" + mock_anime_service.rescan.side_effect = RuntimeError("DB failure") + await scheduler_service.start() + await scheduler_service._perform_rescan() + + error_events = [ + b for b in mock_websocket_service.broadcasts + if b["type"] == "scheduled_rescan_error" + ] + assert len(error_events) >= 1 + + @pytest.mark.asyncio + async def test_rescan_completed_event_order( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """'started' event precedes 'completed' event in broadcast sequence.""" + await scheduler_service.start() + await scheduler_service.trigger_rescan() + + types = [b["type"] for b in mock_websocket_service.broadcasts] + started_idx = types.index("scheduled_rescan_started") + completed_idx = types.index("scheduled_rescan_completed") + assert completed_idx > started_idx + + +# --------------------------------------------------------------------------- +# TestSchedulerGetStatus +# --------------------------------------------------------------------------- + +class TestSchedulerGetStatus: + """Tests for get_status() accuracy.""" + + @pytest.mark.asyncio + async def test_status_not_running_before_start(self, scheduler_service): + """is_running is False before start().""" status = scheduler_service.get_status() assert status["is_running"] is False assert status["scan_in_progress"] is False - - # Start scheduler + + @pytest.mark.asyncio + async def test_status_is_running_after_start(self, scheduler_service): + """is_running is True after start().""" await scheduler_service.start() status = scheduler_service.get_status() assert status["is_running"] is True assert status["enabled"] is True - assert status["interval_minutes"] == 1 - - # Make rescan slow to check in-progress status - async def slow_rescan(): - await asyncio.sleep(0.5) - - mock_anime_service.rescan.side_effect = slow_rescan - - # Start rescan - task = asyncio.create_task(scheduler_service._perform_rescan()) - - # Check status during rescan - await asyncio.sleep(0.1) - status = scheduler_service.get_status() - assert status["scan_in_progress"] is True - - # Wait for rescan to complete - await task - - # Check status after rescan - status = scheduler_service.get_status() - assert status["scan_in_progress"] is False - assert status["last_scan_time"] is not None - - # Cleanup - await scheduler_service.stop() - - # Final status - status = scheduler_service.get_status() - assert status["is_running"] is False - - -class TestSchedulerEdgeCases: - """Tests for edge cases in scheduler workflows.""" @pytest.mark.asyncio - async def test_rapid_enable_disable_cycles( - self, - mock_config_service, - mock_anime_service, - mock_websocket_service + async def test_status_last_run_populated_after_rescan( + self, scheduler_service, mock_anime_service, mock_websocket_service ): - """Test rapid enable/disable cycles don't cause issues.""" - reset_scheduler_service() - scheduler = SchedulerService() - - # Rapidly enable and disable 5 times - for i in range(5): - enabled_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - interval_minutes=1 - ) - ) - disabled_config = AppConfig( - scheduler=SchedulerConfig( - enabled=False, - interval_minutes=1 - ) - ) - - if i % 2 == 0: - mock_config_service.load_config.return_value = enabled_config - await scheduler.reload_config() - else: - mock_config_service.load_config.return_value = disabled_config - await scheduler.reload_config() - - await asyncio.sleep(0.1) - - # Final state should match last configuration (i=4 is even, so enabled) - status = scheduler.get_status() - assert status["is_running"] is True # Last config (i=4) was enabled - - # Cleanup - if scheduler._is_running: - await scheduler.stop() - - @pytest.mark.asyncio - async def test_interval_change_during_active_scan( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test configuration change during active scan.""" + """last_run is not None after a successful rescan.""" await scheduler_service.start() - - # Make rescan slow + await scheduler_service.trigger_rescan() + status = scheduler_service.get_status() + assert status["last_run"] is not None + + @pytest.mark.asyncio + async def test_status_scan_in_progress_during_slow_rescan( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """scan_in_progress is True while rescan is executing.""" async def slow_rescan(): - await asyncio.sleep(1) - + await asyncio.sleep(0.3) + mock_anime_service.rescan.side_effect = slow_rescan - - # Start a rescan + await scheduler_service.start() + task = asyncio.create_task(scheduler_service._perform_rescan()) - - # Change interval while scan is in progress - await asyncio.sleep(0.2) - new_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - interval_minutes=5 - ) - ) - mock_config_service.load_config.return_value = new_config - - # Reload config (should restart scheduler) - await scheduler_service.reload_config() - - # Wait for scan to complete + await asyncio.sleep(0.05) + assert scheduler_service.get_status()["scan_in_progress"] is True await task - - # Verify new interval is applied - assert scheduler_service._config.interval_minutes == 5 - - # Cleanup + + @pytest.mark.asyncio + async def test_status_is_running_false_after_stop(self, scheduler_service): + """is_running is False after stop().""" + await scheduler_service.start() await scheduler_service.stop() + assert scheduler_service.get_status()["is_running"] is False + + @pytest.mark.asyncio + async def test_status_includes_cron_fields(self, scheduler_service): + """get_status() includes schedule_time, schedule_days, auto_download keys.""" + await scheduler_service.start() + status = scheduler_service.get_status() + for key in ("schedule_time", "schedule_days", "auto_download_after_rescan", "next_run"): + assert key in status + + +# --------------------------------------------------------------------------- +# TestReloadConfig +# --------------------------------------------------------------------------- + +class TestReloadConfig: + """Tests for reload_config() live reconfiguration.""" + + @pytest.mark.asyncio + async def test_reload_reschedules_job_on_time_change(self, scheduler_service): + """Changing schedule_time reschedules the existing job.""" + await scheduler_service.start() + assert scheduler_service._scheduler.get_job(_JOB_ID) is not None + + new_config = SchedulerConfig(enabled=True, schedule_time="08:00") + scheduler_service.reload_config(new_config) + + job = scheduler_service._scheduler.get_job(_JOB_ID) + assert job is not None + assert scheduler_service._config.schedule_time == "08:00" + + @pytest.mark.asyncio + async def test_reload_removes_job_when_disabled(self, scheduler_service): + """Setting enabled=False removes the APScheduler job.""" + await scheduler_service.start() + assert scheduler_service._scheduler.get_job(_JOB_ID) is not None + + scheduler_service.reload_config( + SchedulerConfig(enabled=False) + ) + assert scheduler_service._scheduler.get_job(_JOB_ID) is None + + @pytest.mark.asyncio + async def test_reload_removes_job_when_days_empty(self, scheduler_service): + """Empty schedule_days removes the APScheduler job.""" + await scheduler_service.start() + scheduler_service.reload_config( + SchedulerConfig(enabled=True, schedule_days=[]) + ) + assert scheduler_service._scheduler.get_job(_JOB_ID) is None + + @pytest.mark.asyncio + async def test_reload_adds_job_when_reenabling(self, scheduler_service): + """Re-enabling after disable adds a new job.""" + await scheduler_service.start() + scheduler_service.reload_config(SchedulerConfig(enabled=False)) + assert scheduler_service._scheduler.get_job(_JOB_ID) is None + + scheduler_service.reload_config( + SchedulerConfig(enabled=True, schedule_time="09:00") + ) + assert scheduler_service._scheduler.get_job(_JOB_ID) is not None + + @pytest.mark.asyncio + async def test_reload_updates_config_attribute(self, scheduler_service): + """reload_config() updates self._config with the supplied instance.""" + await scheduler_service.start() + new = SchedulerConfig(enabled=True, schedule_time="14:30", schedule_days=["mon"]) + scheduler_service.reload_config(new) + assert scheduler_service._config.schedule_time == "14:30" + assert scheduler_service._config.schedule_days == ["mon"] + + def test_reload_before_start_stores_config(self, scheduler_service): + """reload_config() before start() stores config without raising.""" + new = SchedulerConfig(enabled=True, schedule_time="22:00") + scheduler_service.reload_config(new) + assert scheduler_service._config.schedule_time == "22:00" + + +# --------------------------------------------------------------------------- +# TestAutoDownloadWorkflow +# --------------------------------------------------------------------------- + +class TestAutoDownloadWorkflow: + """Tests for auto-download-after-rescan integration.""" + + @pytest.mark.asyncio + async def test_auto_download_triggered_when_enabled( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """_auto_download_missing() is called when auto_download_after_rescan=True.""" + scheduler_service._config = SchedulerConfig( + enabled=True, + auto_download_after_rescan=True, + ) + scheduler_service._is_running = True + + called = [] + + async def fake_auto_download(): + called.append(True) + + scheduler_service._auto_download_missing = fake_auto_download + await scheduler_service._perform_rescan() + assert called == [True] + + @pytest.mark.asyncio + async def test_auto_download_not_called_when_disabled( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """_auto_download_missing() is NOT called when auto_download_after_rescan=False.""" + scheduler_service._config = SchedulerConfig( + enabled=True, + auto_download_after_rescan=False, + ) + scheduler_service._is_running = True + + called = [] + + async def fake_auto_download(): + called.append(True) + + scheduler_service._auto_download_missing = fake_auto_download + await scheduler_service._perform_rescan() + assert called == [] + + @pytest.mark.asyncio + async def test_auto_download_error_broadcasts_event( + self, scheduler_service, mock_anime_service, mock_websocket_service + ): + """Error in _auto_download_missing broadcasts 'auto_download_error'.""" + scheduler_service._config = SchedulerConfig( + enabled=True, + auto_download_after_rescan=True, + ) + scheduler_service._is_running = True + + async def failing_auto_download(): + raise RuntimeError("download failed") + + scheduler_service._auto_download_missing = failing_auto_download + await scheduler_service._perform_rescan() + + error_events = [ + b for b in mock_websocket_service.broadcasts + if b["type"] == "auto_download_error" + ] + assert len(error_events) == 1 + + +# --------------------------------------------------------------------------- +# TestSchedulerSingletonHelpers +# --------------------------------------------------------------------------- + +class TestSchedulerSingletonHelpers: + """Tests for module-level singleton helpers.""" + + def test_get_scheduler_service_returns_same_instance(self): + """get_scheduler_service() returns the same object on repeated calls.""" + svc1 = get_scheduler_service() + svc2 = get_scheduler_service() + assert svc1 is svc2 + + def test_reset_clears_singleton(self): + """reset_scheduler_service() causes get_scheduler_service() to return a new instance.""" + svc1 = get_scheduler_service() + reset_scheduler_service() + svc2 = get_scheduler_service() + assert svc1 is not svc2 + + @pytest.mark.asyncio + async def test_state_persists_across_restart(self, mock_config_service): + """Stopping and restarting loads config from service each time.""" + reset_scheduler_service() + svc = SchedulerService() + await svc.start() + original_time = svc._config.schedule_time + assert svc._is_running is True + + await svc.stop() + assert svc._is_running is False + + reset_scheduler_service() + svc2 = SchedulerService() + await svc2.start() + assert svc2._is_running is True + assert svc2._config.schedule_time == original_time + + await svc2.stop() diff --git a/tests/unit/test_scheduler_config_model.py b/tests/unit/test_scheduler_config_model.py new file mode 100644 index 0000000..c4676da --- /dev/null +++ b/tests/unit/test_scheduler_config_model.py @@ -0,0 +1,129 @@ +"""Unit tests for SchedulerConfig model fields and validators (Task 3).""" +import pytest +from pydantic import ValidationError + +from src.server.models.config import SchedulerConfig + +ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] + + +class TestSchedulerConfigDefaults: + """3.1 – Default values.""" + + def test_default_schedule_time(self) -> None: + config = SchedulerConfig() + assert config.schedule_time == "03:00" + + def test_default_schedule_days(self) -> None: + config = SchedulerConfig() + assert config.schedule_days == ALL_DAYS + + def test_default_auto_download(self) -> None: + config = SchedulerConfig() + assert config.auto_download_after_rescan is False + + def test_default_enabled(self) -> None: + config = SchedulerConfig() + assert config.enabled is True + + def test_default_interval_minutes(self) -> None: + config = SchedulerConfig() + assert config.interval_minutes == 60 + + +class TestSchedulerConfigValidScheduleTime: + """3.2 – Valid schedule_time values.""" + + @pytest.mark.parametrize("time_val", ["00:00", "03:00", "12:30", "23:59"]) + def test_valid_times(self, time_val: str) -> None: + config = SchedulerConfig(schedule_time=time_val) + assert config.schedule_time == time_val + + +class TestSchedulerConfigInvalidScheduleTime: + """3.3 – Invalid schedule_time values must raise ValidationError.""" + + @pytest.mark.parametrize( + "time_val", + ["25:00", "3pm", "", "3:00pm", "24:00", "-1:00", "9:00", "1:60"], + ) + def test_invalid_times(self, time_val: str) -> None: + with pytest.raises(ValidationError): + SchedulerConfig(schedule_time=time_val) + + +class TestSchedulerConfigValidScheduleDays: + """3.4 – Valid schedule_days values.""" + + def test_single_day(self) -> None: + config = SchedulerConfig(schedule_days=["mon"]) + assert config.schedule_days == ["mon"] + + def test_multiple_days(self) -> None: + config = SchedulerConfig(schedule_days=["mon", "fri"]) + assert config.schedule_days == ["mon", "fri"] + + def test_all_days(self) -> None: + config = SchedulerConfig(schedule_days=ALL_DAYS) + assert config.schedule_days == ALL_DAYS + + def test_empty_list(self) -> None: + config = SchedulerConfig(schedule_days=[]) + assert config.schedule_days == [] + + +class TestSchedulerConfigInvalidScheduleDays: + """3.5 – Invalid schedule_days values must raise ValidationError.""" + + @pytest.mark.parametrize( + "days", + [ + ["monday"], + ["xyz"], + ["Mon"], # Case-sensitive — must be lowercase + [""], + ], + ) + def test_invalid_days(self, days: list) -> None: + with pytest.raises(ValidationError): + SchedulerConfig(schedule_days=days) + + +class TestSchedulerConfigAutoDownload: + """3.6 – auto_download_after_rescan field.""" + + def test_set_true(self) -> None: + config = SchedulerConfig(auto_download_after_rescan=True) + assert config.auto_download_after_rescan is True + + def test_set_false(self) -> None: + config = SchedulerConfig(auto_download_after_rescan=False) + assert config.auto_download_after_rescan is False + + +class TestSchedulerConfigBackwardCompat: + """3.7 – Backward compatibility: old fields still work.""" + + def test_legacy_fields_use_defaults(self) -> None: + config = SchedulerConfig(enabled=True, interval_minutes=30) + assert config.schedule_time == "03:00" + assert config.schedule_days == ALL_DAYS + assert config.auto_download_after_rescan is False + assert config.enabled is True + assert config.interval_minutes == 30 + + +class TestSchedulerConfigSerialisation: + """3.8 – Serialisation roundtrip.""" + + def test_roundtrip(self) -> None: + original = SchedulerConfig( + enabled=True, + interval_minutes=120, + schedule_time="04:30", + schedule_days=["mon", "wed", "fri"], + auto_download_after_rescan=True, + ) + dumped = original.model_dump() + restored = SchedulerConfig(**dumped) + assert restored == original diff --git a/tests/unit/test_scheduler_service.py b/tests/unit/test_scheduler_service.py index 40b256c..3706285 100644 --- a/tests/unit/test_scheduler_service.py +++ b/tests/unit/test_scheduler_service.py @@ -1,663 +1,399 @@ -"""Unit tests for scheduler service. +"""Unit tests for SchedulerService (APScheduler-based implementation). -This module tests the scheduler service logic including: -- Scheduled library rescan execution -- Scheduler state persistence across restarts -- Background task execution and lifecycle -- Scheduler conflict resolution (manual vs automated scans) -- Error handling during scheduled operations +Covers: +- _build_cron_trigger() +- start() / stop() with APScheduler mocking +- reload_config() +- get_status() +- _perform_rescan() with auto-download +- Error handling and edge cases """ -import asyncio from datetime import datetime, timezone -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch, call import pytest +from apscheduler.triggers.cron import CronTrigger from src.server.models.config import AppConfig, SchedulerConfig from src.server.services.scheduler_service import ( SchedulerService, SchedulerServiceError, + _JOB_ID, get_scheduler_service, reset_scheduler_service, ) +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] + + +def _make_app_config(**scheduler_kwargs) -> AppConfig: + return AppConfig(scheduler=SchedulerConfig(**scheduler_kwargs)) + @pytest.fixture def mock_config_service(): - """Create a mock configuration service.""" with patch("src.server.services.scheduler_service.get_config_service") as mock: - config_service = Mock() - - # Default configuration - app_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - interval_minutes=60 - ) + svc = Mock() + svc.load_config.return_value = _make_app_config( + enabled=True, + schedule_time="03:00", + schedule_days=ALL_DAYS, ) - config_service.load_config.return_value = app_config - - mock.return_value = config_service - yield config_service - - -@pytest.fixture -def mock_anime_service(): - """Create a mock anime service.""" - with patch("src.server.utils.dependencies.get_anime_service") as mock: - service = Mock() - service.rescan = AsyncMock() - mock.return_value = service - yield service - - -@pytest.fixture -def mock_websocket_service(): - """Create a mock WebSocket service.""" - with patch("src.server.services.websocket_service.get_websocket_service") as mock: - service = Mock() - service.manager = Mock() - service.manager.broadcast = AsyncMock() - mock.return_value = service - yield service + mock.return_value = svc + yield svc @pytest.fixture def scheduler_service(): - """Create a fresh scheduler service instance for each test.""" reset_scheduler_service() - service = SchedulerService() - yield service - # Cleanup - if service._is_running: - asyncio.create_task(service.stop()) + yield SchedulerService() + reset_scheduler_service() -class TestSchedulerServiceInitialization: - """Tests for scheduler service initialization and lifecycle.""" +# --------------------------------------------------------------------------- +# 12.1 _build_cron_trigger +# --------------------------------------------------------------------------- - @pytest.mark.asyncio - async def test_scheduler_starts_when_enabled( - self, - scheduler_service, - mock_config_service - ): - """Test scheduler starts successfully when enabled in config.""" - await scheduler_service.start() - - assert scheduler_service._is_running is True - assert scheduler_service._task is not None - assert not scheduler_service._task.done() - assert scheduler_service._config.enabled is True - assert scheduler_service._config.interval_minutes == 60 - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_scheduler_does_not_start_when_disabled( - self, - scheduler_service, - mock_config_service - ): - """Test scheduler does not start when disabled in config.""" - # Modify config to disable scheduler - app_config = AppConfig( - scheduler=SchedulerConfig( - enabled=False, - interval_minutes=60 - ) +class TestBuildCronTrigger: + def test_standard_case(self, scheduler_service): + scheduler_service._config = SchedulerConfig( + schedule_time="03:00", + schedule_days=["mon", "wed", "fri"], ) - mock_config_service.load_config.return_value = app_config - - await scheduler_service.start() - - assert scheduler_service._is_running is False - assert scheduler_service._task is None + trigger = scheduler_service._build_cron_trigger() + assert isinstance(trigger, CronTrigger) + fields = {f.name: str(f) for f in trigger.fields} + assert fields["hour"] == "3" + assert fields["minute"] == "0" + assert "mon" in fields["day_of_week"] + assert "fri" in fields["day_of_week"] + + def test_all_days(self, scheduler_service): + scheduler_service._config = SchedulerConfig( + schedule_time="23:59", + schedule_days=ALL_DAYS, + ) + trigger = scheduler_service._build_cron_trigger() + assert trigger is not None + fields = {f.name: str(f) for f in trigger.fields} + for day in ALL_DAYS: + assert day in fields["day_of_week"] + + def test_empty_days_returns_none(self, scheduler_service): + scheduler_service._config = SchedulerConfig( + schedule_time="03:00", + schedule_days=[], + ) + assert scheduler_service._build_cron_trigger() is None + + def test_no_config_returns_none(self, scheduler_service): + scheduler_service._config = None + assert scheduler_service._build_cron_trigger() is None + + +# --------------------------------------------------------------------------- +# 12.2 start() – normal case +# --------------------------------------------------------------------------- + +class TestStart: + @pytest.mark.asyncio + async def test_start_adds_job_and_starts_scheduler( + self, scheduler_service, mock_config_service + ): + with patch( + "src.server.services.scheduler_service.AsyncIOScheduler" + ) as MockScheduler: + mock_sched = MagicMock() + mock_sched.running = False + MockScheduler.return_value = mock_sched + + await scheduler_service.start() + + mock_sched.add_job.assert_called_once() + call_kwargs = mock_sched.add_job.call_args + assert call_kwargs[1]["id"] == _JOB_ID + assert isinstance(call_kwargs[1]["trigger"], CronTrigger) + mock_sched.start.assert_called_once() + assert scheduler_service._is_running is True @pytest.mark.asyncio - async def test_scheduler_raises_error_if_already_running( - self, - scheduler_service, - mock_config_service - ): - """Test scheduler raises error when started twice.""" - await scheduler_service.start() - + async def test_start_raises_if_already_running(self, scheduler_service): + scheduler_service._is_running = True with pytest.raises(SchedulerServiceError, match="already running"): await scheduler_service.start() - - # Cleanup - await scheduler_service.stop() - @pytest.mark.asyncio - async def test_scheduler_stops_gracefully( - self, - scheduler_service, - mock_config_service - ): - """Test scheduler stops gracefully.""" - await scheduler_service.start() - assert scheduler_service._is_running is True - - await scheduler_service.stop() - - assert scheduler_service._is_running is False - assert scheduler_service._task.done() or scheduler_service._task.cancelled() - @pytest.mark.asyncio - async def test_scheduler_stop_when_not_running( - self, - scheduler_service - ): - """Test scheduler stop is safe when not running.""" - # Should not raise any errors - await scheduler_service.stop() - assert scheduler_service._is_running is False +# --------------------------------------------------------------------------- +# 12.3 start() – empty schedule_days +# --------------------------------------------------------------------------- +class TestStartEmptyDays: @pytest.mark.asyncio - async def test_scheduler_raises_error_on_config_load_failure( - self, - scheduler_service, - mock_config_service - ): - """Test scheduler raises error if config loading fails.""" - from src.server.services.config_service import ConfigServiceError - - mock_config_service.load_config.side_effect = ConfigServiceError( - "Config file not found" - ) - - with pytest.raises(SchedulerServiceError, match="Failed to load config"): + async def test_no_job_added_when_days_empty(self, scheduler_service): + with patch( + "src.server.services.scheduler_service.get_config_service" + ) as mock_cs, patch( + "src.server.services.scheduler_service.AsyncIOScheduler" + ) as MockScheduler: + svc = Mock() + svc.load_config.return_value = _make_app_config( + enabled=True, schedule_days=[] + ) + mock_cs.return_value = svc + + mock_sched = MagicMock() + MockScheduler.return_value = mock_sched + await scheduler_service.start() + mock_sched.add_job.assert_not_called() + mock_sched.start.assert_called_once() + assert scheduler_service._is_running is True -class TestSchedulerServiceExecution: - """Tests for scheduled rescan execution logic.""" +# --------------------------------------------------------------------------- +# 12.4 stop() +# --------------------------------------------------------------------------- + +class TestStop: @pytest.mark.asyncio - async def test_scheduler_performs_rescan( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test scheduler performs library rescan.""" - # Use a short interval for testing - app_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - interval_minutes=1 # 1 minute for faster testing - ) - ) - mock_config_service.load_config.return_value = app_config - - await scheduler_service.start() - - # Wait a bit longer than the interval to ensure rescan executes - # (1 minute = 60 seconds, add buffer) - await asyncio.sleep(65) - - # Verify rescan was called - assert mock_anime_service.rescan.call_count >= 1 - assert mock_websocket_service.manager.broadcast.call_count >= 2 # start + complete - - # Verify last scan time was recorded - assert scheduler_service._last_scan_time is not None - - # Cleanup + async def test_stop_shuts_down_scheduler(self, scheduler_service): + mock_sched = MagicMock() + mock_sched.running = True + scheduler_service._scheduler = mock_sched + scheduler_service._is_running = True + await scheduler_service.stop() - @pytest.mark.asyncio - async def test_scheduler_broadcasts_rescan_events( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test scheduler broadcasts WebSocket events during rescan.""" - await scheduler_service._perform_rescan() - - # Should broadcast start and complete events - assert mock_websocket_service.manager.broadcast.call_count == 2 - - # Verify event types - calls = mock_websocket_service.manager.broadcast.call_args_list - start_event = calls[0][0][0] - complete_event = calls[1][0][0] - - assert start_event["type"] == "scheduled_rescan_started" - assert "timestamp" in start_event["data"] - - assert complete_event["type"] == "scheduled_rescan_completed" - assert "timestamp" in complete_event["data"] - assert "duration_seconds" in complete_event["data"] + mock_sched.shutdown.assert_called_once_with(wait=False) + assert scheduler_service._is_running is False @pytest.mark.asyncio - async def test_scheduler_handles_rescan_failure( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service + async def test_stop_when_not_running_is_noop(self, scheduler_service): + # Should not raise + await scheduler_service.stop() + assert scheduler_service._is_running is False + + +# --------------------------------------------------------------------------- +# 12.5 reload_config() – reschedule +# --------------------------------------------------------------------------- + +class TestReloadConfig: + def test_reschedule_on_time_change(self, scheduler_service): + mock_sched = MagicMock() + mock_sched.running = True + mock_sched.get_job.return_value = Mock() # job exists + scheduler_service._scheduler = mock_sched + scheduler_service._config = SchedulerConfig( + schedule_time="03:00", schedule_days=ALL_DAYS + ) + + new_config = SchedulerConfig(schedule_time="05:00", schedule_days=ALL_DAYS) + scheduler_service.reload_config(new_config) + + mock_sched.reschedule_job.assert_called_once_with( + _JOB_ID, trigger=mock_sched.reschedule_job.call_args[1]["trigger"] + ) + assert scheduler_service._config.schedule_time == "05:00" + + def test_reschedule_on_days_change(self, scheduler_service): + mock_sched = MagicMock() + mock_sched.running = True + mock_sched.get_job.return_value = Mock() + scheduler_service._scheduler = mock_sched + scheduler_service._config = SchedulerConfig( + schedule_time="03:00", schedule_days=ALL_DAYS + ) + + new_config = SchedulerConfig(schedule_time="03:00", schedule_days=["mon"]) + scheduler_service.reload_config(new_config) + + mock_sched.reschedule_job.assert_called_once() + + +# --------------------------------------------------------------------------- +# 12.6 reload_config() – empty days removes job +# --------------------------------------------------------------------------- + +class TestReloadConfigEmptyDays: + def test_removes_job_when_days_empty(self, scheduler_service): + mock_sched = MagicMock() + mock_sched.running = True + mock_sched.get_job.return_value = Mock() # job exists + scheduler_service._scheduler = mock_sched + scheduler_service._config = SchedulerConfig( + schedule_time="03:00", schedule_days=ALL_DAYS + ) + + new_config = SchedulerConfig(schedule_time="03:00", schedule_days=[]) + scheduler_service.reload_config(new_config) + + mock_sched.remove_job.assert_called_once_with(_JOB_ID) + mock_sched.reschedule_job.assert_not_called() + + +# --------------------------------------------------------------------------- +# 12.7 _perform_rescan() with auto_download=True +# --------------------------------------------------------------------------- + +class TestPerformRescanAutoDownload: + @pytest.mark.asyncio + async def test_auto_download_queues_missing_episodes(self, scheduler_service): + scheduler_service._config = SchedulerConfig( + auto_download_after_rescan=True, + schedule_time="03:00", + schedule_days=ALL_DAYS, + ) + + mock_anime = MagicMock() + mock_anime.rescan = AsyncMock() + mock_anime._cached_list_missing.return_value = [ + {"key": "series-a", "name": "Series A", "folder": "Series A", + "episodeDict": {"1": [1, 2, 3]}}, + {"key": "series-b", "name": "Series B", "folder": "Series B", + "episodeDict": {}}, # no missing → should be skipped + ] + + mock_dl = MagicMock() + mock_dl.add_to_queue = AsyncMock(return_value=["id1", "id2", "id3"]) + mock_dl.start_queue_processing = AsyncMock(return_value=None) + + mock_ws = MagicMock() + mock_ws.manager.broadcast = AsyncMock() + + with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \ + patch("src.server.utils.dependencies.get_download_service", return_value=mock_dl), \ + patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws): + await scheduler_service._perform_rescan() + + mock_dl.add_to_queue.assert_called_once() + call_kwargs = mock_dl.add_to_queue.call_args[1] + assert len(call_kwargs["episodes"]) == 3 + mock_dl.start_queue_processing.assert_called_once() + + # Check auto_download_started was broadcast + calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list] + assert any("auto_download_started" in c for c in calls) + + +# --------------------------------------------------------------------------- +# 12.8 _perform_rescan() with auto_download=False +# --------------------------------------------------------------------------- + +class TestPerformRescanNoAutoDownload: + @pytest.mark.asyncio + async def test_no_download_when_disabled(self, scheduler_service): + scheduler_service._config = SchedulerConfig( + auto_download_after_rescan=False, + ) + + mock_anime = MagicMock() + mock_anime.rescan = AsyncMock() + mock_anime._cached_list_missing.return_value = [ + {"key": "series-a", "name": "Series A", "folder": "Series A", + "episodeDict": {"1": [1, 2]}}, + ] + + mock_dl = MagicMock() + mock_dl.add_to_queue = AsyncMock() + + mock_ws = MagicMock() + mock_ws.manager.broadcast = AsyncMock() + + with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \ + patch("src.server.utils.dependencies.get_download_service", return_value=mock_dl), \ + patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws): + await scheduler_service._perform_rescan() + + mock_dl.add_to_queue.assert_not_called() + calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list] + assert not any("auto_download_started" in c for c in calls) + + +# --------------------------------------------------------------------------- +# 12.9 Auto-download error handling +# --------------------------------------------------------------------------- + +class TestAutoDownloadErrorHandling: + @pytest.mark.asyncio + async def test_download_error_broadcasts_and_does_not_crash( + self, scheduler_service ): - """Test scheduler handles rescan failures gracefully.""" - # Make rescan fail - mock_anime_service.rescan.side_effect = Exception("Database error") - - # Should not raise exception - await scheduler_service._perform_rescan() - - # Should broadcast error event - assert mock_websocket_service.manager.broadcast.call_count >= 2 - error_event = mock_websocket_service.manager.broadcast.call_args_list[-1][0][0] - - assert error_event["type"] == "scheduled_rescan_error" - assert "error" in error_event["data"] - assert "Database error" in error_event["data"]["error"] - - # Scan should no longer be in progress + scheduler_service._config = SchedulerConfig( + auto_download_after_rescan=True, + ) + + mock_anime = MagicMock() + mock_anime.rescan = AsyncMock() + mock_anime._cached_list_missing.return_value = [ + {"key": "series-a", "name": "Series A", "folder": "Series A", + "episodeDict": {"1": [1]}}, + ] + + mock_dl = MagicMock() + mock_dl.add_to_queue = AsyncMock(side_effect=RuntimeError("boom")) + + mock_ws = MagicMock() + mock_ws.manager.broadcast = AsyncMock() + + with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \ + patch("src.server.utils.dependencies.get_download_service", return_value=mock_dl), \ + patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws): + # Should NOT raise + await scheduler_service._perform_rescan() + + calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list] + assert any("auto_download_error" in c for c in calls) + + # Rescan itself succeeded — scan_in_progress must be False assert scheduler_service._scan_in_progress is False -class TestSchedulerServiceConflictResolution: - """Tests for scheduler conflict resolution.""" +# --------------------------------------------------------------------------- +# 12.10 get_status() returns correct fields +# --------------------------------------------------------------------------- - @pytest.mark.asyncio - async def test_scheduler_prevents_concurrent_rescans( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test scheduler prevents concurrent rescans.""" - # Make rescan slow - async def slow_rescan(): - await asyncio.sleep(1) - - mock_anime_service.rescan.side_effect = slow_rescan - - # Start first rescan - task1 = asyncio.create_task(scheduler_service._perform_rescan()) - - # Wait a bit to ensure first rescan is in progress - await asyncio.sleep(0.1) - - # Try to start second rescan - task2 = asyncio.create_task(scheduler_service._perform_rescan()) - - # Wait for both to complete - await task1 - await task2 - - # Only one rescan should have executed - assert mock_anime_service.rescan.call_count == 1 - - @pytest.mark.asyncio - async def test_manual_trigger_fails_during_scheduled_scan( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test manual trigger returns False when scan is in progress.""" - await scheduler_service.start() - - # Make rescan slow - async def slow_rescan(): - await asyncio.sleep(1) - - mock_anime_service.rescan.side_effect = slow_rescan - - # Start a rescan - task = asyncio.create_task(scheduler_service._perform_rescan()) - - # Wait for scan to be in progress - await asyncio.sleep(0.1) - - # Try to manually trigger - result = await scheduler_service.trigger_rescan() - - assert result is False - - # Wait for the rescan to complete - await task - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_manual_trigger_succeeds_when_idle( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test manual trigger succeeds when no scan is in progress.""" - await scheduler_service.start() - - result = await scheduler_service.trigger_rescan() - - assert result is True - assert mock_anime_service.rescan.call_count == 1 - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_manual_trigger_raises_error_when_not_running( - self, - scheduler_service, - mock_config_service - ): - """Test manual trigger raises error when scheduler is not running.""" - with pytest.raises(SchedulerServiceError, match="not running"): - await scheduler_service.trigger_rescan() - - -class TestSchedulerServiceConfiguration: - """Tests for scheduler configuration management.""" - - @pytest.mark.asyncio - async def test_reload_config_updates_settings( - self, - scheduler_service, - mock_config_service - ): - """Test config reload updates scheduler settings.""" - # Start with default config - await scheduler_service.start() - assert scheduler_service._config.interval_minutes == 60 - - # Update config - new_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - interval_minutes=120 - ) +class TestGetStatus: + def test_get_status_fields_when_not_running(self, scheduler_service): + scheduler_service._config = SchedulerConfig( + schedule_time="04:00", + schedule_days=["mon"], + auto_download_after_rescan=True, ) - mock_config_service.load_config.return_value = new_config - - await scheduler_service.reload_config() - - assert scheduler_service._config.interval_minutes == 120 - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_reload_config_restarts_on_interval_change( - self, - scheduler_service, - mock_config_service - ): - """Test scheduler restarts when interval changes.""" - await scheduler_service.start() - original_task = scheduler_service._task - - # Change interval - new_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - interval_minutes=120 - ) - ) - mock_config_service.load_config.return_value = new_config - - await scheduler_service.reload_config() - - # Should have a new task - assert scheduler_service._task != original_task - assert scheduler_service._is_running is True - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_reload_config_stops_when_disabled( - self, - scheduler_service, - mock_config_service - ): - """Test scheduler stops when config is disabled.""" - await scheduler_service.start() - assert scheduler_service._is_running is True - - # Disable scheduler - new_config = AppConfig( - scheduler=SchedulerConfig( - enabled=False, - interval_minutes=60 - ) - ) - mock_config_service.load_config.return_value = new_config - - await scheduler_service.reload_config() - - assert scheduler_service._is_running is False - - @pytest.mark.asyncio - async def test_reload_config_starts_when_enabled( - self, - scheduler_service, - mock_config_service - ): - """Test scheduler starts when config is enabled.""" - # Start with disabled config - disabled_config = AppConfig( - scheduler=SchedulerConfig( - enabled=False, - interval_minutes=60 - ) - ) - mock_config_service.load_config.return_value = disabled_config - - await scheduler_service.start() - assert scheduler_service._is_running is False - - # Enable scheduler - enabled_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - interval_minutes=60 - ) - ) - mock_config_service.load_config.return_value = enabled_config - - await scheduler_service.reload_config() - - assert scheduler_service._is_running is True - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_reload_config_raises_error_on_failure( - self, - scheduler_service, - mock_config_service - ): - """Test config reload raises error on failure.""" - from src.server.services.config_service import ConfigServiceError - - await scheduler_service.start() - - mock_config_service.load_config.side_effect = ConfigServiceError( - "Config corrupted" - ) - - with pytest.raises(SchedulerServiceError, match="Failed to reload config"): - await scheduler_service.reload_config() - - # Cleanup - await scheduler_service.stop() - - -class TestSchedulerServiceStatus: - """Tests for scheduler status reporting.""" - - def test_get_status_returns_correct_state( - self, - scheduler_service, - mock_config_service - ): - """Test get_status returns accurate scheduler state.""" status = scheduler_service.get_status() - + + assert "is_running" in status + assert "next_run" in status + assert "last_run" in status + assert "schedule_time" in status + assert "schedule_days" in status + assert "auto_download_after_rescan" in status + assert status["schedule_time"] == "04:00" + assert status["schedule_days"] == ["mon"] + assert status["auto_download_after_rescan"] is True assert status["is_running"] is False - assert status["enabled"] is False - assert status["interval_minutes"] is None - assert status["last_scan_time"] is None - assert status["next_scan_time"] is None - assert status["scan_in_progress"] is False - - @pytest.mark.asyncio - async def test_get_status_after_start( - self, - scheduler_service, - mock_config_service - ): - """Test get_status returns correct state after starting.""" - await scheduler_service.start() - - status = scheduler_service.get_status() - - assert status["is_running"] is True - assert status["enabled"] is True - assert status["interval_minutes"] == 60 - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_get_status_after_rescan( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test get_status includes scan times after rescan.""" - await scheduler_service.start() - await scheduler_service._perform_rescan() - - status = scheduler_service.get_status() - - assert status["last_scan_time"] is not None - assert isinstance(status["last_scan_time"], str) # ISO format - - # Cleanup - await scheduler_service.stop() + assert status["next_run"] is None -class TestSchedulerServiceSingleton: - """Tests for scheduler service singleton pattern.""" +# --------------------------------------------------------------------------- +# Singleton helpers +# --------------------------------------------------------------------------- - def test_get_scheduler_service_returns_singleton(self): - """Test get_scheduler_service returns the same instance.""" +class TestSingletonHelpers: + def test_get_scheduler_service_returns_same_instance(self): reset_scheduler_service() - - service1 = get_scheduler_service() - service2 = get_scheduler_service() - - assert service1 is service2 + svc1 = get_scheduler_service() + svc2 = get_scheduler_service() + assert svc1 is svc2 - def test_reset_scheduler_service_clears_singleton(self): - """Test reset_scheduler_service creates new instance.""" - service1 = get_scheduler_service() - + def test_reset_clears_singleton(self): + get_scheduler_service() reset_scheduler_service() - - service2 = get_scheduler_service() - - assert service1 is not service2 + svc = get_scheduler_service() + assert svc is not None # fresh instance - -class TestSchedulerServiceEdgeCases: - """Tests for edge cases and error conditions.""" - - @pytest.mark.asyncio - async def test_scheduler_handles_websocket_broadcast_failure( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test scheduler continues when WebSocket broadcast fails.""" - # Make broadcast fail AFTER rescan completes (so rescan still executes) - call_count = {"count": 0} - - async def broadcast_side_effect(*args, **kwargs): - call_count["count"] += 1 - # First call (rescan started) - succeed - # Second call (rescan completed) - fail - if call_count["count"] >= 2: - raise Exception("WebSocket error") - - mock_websocket_service.manager.broadcast.side_effect = broadcast_side_effect - - # Should not raise exception - await scheduler_service._perform_rescan() - - # Rescan should still have been attempted - assert mock_anime_service.rescan.call_count == 1 - # WebSocket broadcast should have been attempted multiple times - assert mock_websocket_service.manager.broadcast.call_count >= 1 - - @pytest.mark.asyncio - async def test_scheduler_loop_continues_after_error( - self, - scheduler_service, - mock_config_service, - mock_anime_service, - mock_websocket_service - ): - """Test scheduler loop continues after encountering error.""" - # Make first rescan fail, second succeed - mock_anime_service.rescan.side_effect = [ - Exception("First error"), - None # Second call succeeds - ] - - # Start with very short interval - app_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - interval_minutes=1 # 1 minute - ) - ) - mock_config_service.load_config.return_value = app_config - - await scheduler_service.start() - - # Wait for two rescan cycles (2 minutes + buffer) - await asyncio.sleep(130) - - # Should have attempted at least 2 rescans - assert mock_anime_service.rescan.call_count >= 2 - - # Cleanup - await scheduler_service.stop() - - @pytest.mark.asyncio - async def test_scheduler_cancellation_is_clean( - self, - scheduler_service, - mock_config_service - ): - """Test scheduler task cancellation is handled cleanly.""" - await scheduler_service.start() - - # Cancel the task directly - scheduler_service._task.cancel() - - # Stop should handle this gracefully - await scheduler_service.stop() - - assert scheduler_service._is_running is False