Ctrl K
ComunidadGuías…

backend/tests/test_batch_process.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Tests for batch processing routes and _batch_process_library filter."""

import json
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch

import pytest


def _now() -> datetime:
    return datetime.now(UTC)


@pytest.fixture(autouse=True)
def reset_batch_running():
    """Reset the module-level _batch_running flag before each test.

    The flag is set to True in the background thread and cleared when done,
    but if a previous test left it True the next POST returns 409 instead of 202.
    """
    import routes.subtitle_processor as _mod

    _mod._batch_running = False
    yield
    _mod._batch_running = False


def test_batch_process_series_returns_202(client, temp_db):
    """POST /library/series/{id}/process returns 202 immediately."""
    resp = client.post("/api/v1/library/series/1/process")
    assert resp.status_code == 202
    assert resp.get_json()["status"] == "started"


def test_batch_process_all_returns_202(client, temp_db):
    """POST /library/process-all returns 202 immediately."""
    resp = client.post("/api/v1/library/process-all", json={"filter": "all"})
    assert resp.status_code == 202
    assert resp.get_json()["status"] == "started"


def test_batch_process_all_invalid_filter_returns_400(client, temp_db):
    """POST /library/process-all with unknown filter returns 400."""
    resp = client.post("/api/v1/library/process-all", json={"filter": "bad_filter"})
    assert resp.status_code == 400


def test_batch_process_conflict_returns_409(client, temp_db):
    """Second POST while batch is running returns 409."""
    import routes.subtitle_processor as _mod

    _mod._batch_running = True
    resp = client.post("/api/v1/library/process-all", json={"filter": "all"})
    assert resp.status_code == 409


def test_batch_process_unprocessed_filter(app_ctx, temp_db):
    """_batch_process_library with filter='unprocessed' skips series that already have processing_config set."""
    from routes.subtitle_processor import _batch_process_library

    mock_client = MagicMock()
    mock_client.get_series.return_value = [
        {"id": 101, "title": "SeriesA"},
        {"id": 102, "title": "SeriesB"},
    ]

    # Give series 102 a processing_config — it should be skipped
    from db.models.core import SeriesSettings
    from extensions import db as _db

    row = SeriesSettings(
        sonarr_series_id=102,
        processing_config=json.dumps({"hi_removal": True}),
        updated_at=_now(),
    )
    _db.session.add(row)
    _db.session.commit()

    processed_ids = []

    def mock_process(series_id):
        processed_ids.append(series_id)

    with (
        patch("sonarr_client.get_sonarr_client", return_value=mock_client),
        patch("routes.subtitle_processor._batch_process_series", side_effect=mock_process),
    ):
        _batch_process_library("unprocessed")

    assert 101 in processed_ids
    assert 102 not in processed_ids


def test_batch_process_all_filter_includes_all_series(app_ctx, temp_db):
    """_batch_process_library with filter='all' processes all series regardless of config."""
    from routes.subtitle_processor import _batch_process_library

    mock_client = MagicMock()
    mock_client.get_series.return_value = [
        {"id": 201, "title": "SeriesC"},
        {"id": 202, "title": "SeriesD"},
    ]

    # Give series 202 a processing_config — should still be processed with filter='all'
    from db.models.core import SeriesSettings
    from extensions import db as _db

    row = SeriesSettings(
        sonarr_series_id=202,
        processing_config=json.dumps({"hi_removal": False}),
        updated_at=_now(),
    )
    _db.session.add(row)
    _db.session.commit()

    processed_ids = []

    def mock_process(series_id):
        processed_ids.append(series_id)

    with (
        patch("sonarr_client.get_sonarr_client", return_value=mock_client),
        patch("routes.subtitle_processor._batch_process_series", side_effect=mock_process),
    ):
        _batch_process_library("all")

    assert 201 in processed_ids
    assert 202 in processed_ids


def test_batch_process_library_skips_series_without_id(app_ctx, temp_db):
    """_batch_process_library skips series entries that have no 'id' key."""
    from routes.subtitle_processor import _batch_process_library

    mock_client = MagicMock()
    mock_client.get_series.return_value = [
        {"title": "NoId"},  # missing 'id' key
        {"id": 301, "title": "HasId"},
    ]

    processed_ids = []

    def mock_process(series_id):
        processed_ids.append(series_id)

    with (
        patch("sonarr_client.get_sonarr_client", return_value=mock_client),
        patch("routes.subtitle_processor._batch_process_series", side_effect=mock_process),
    ):
        _batch_process_library("all")

    assert 301 in processed_ids
    assert len(processed_ids) == 1


def test_batch_process_library_returns_early_when_no_sonarr_client(app_ctx, temp_db):
    """_batch_process_library exits gracefully when Sonarr client is unavailable."""
    from routes.subtitle_processor import _batch_process_library

    processed_ids = []

    def mock_process(series_id):
        processed_ids.append(series_id)

    with (
        patch("sonarr_client.get_sonarr_client", return_value=None),
        patch("routes.subtitle_processor._batch_process_series", side_effect=mock_process),
    ):
        _batch_process_library("all")

    assert processed_ids == []


def test_batch_process_library_handles_sonarr_exception(app_ctx, temp_db):
    """_batch_process_library handles Sonarr get_series() exceptions without crashing."""
    from routes.subtitle_processor import _batch_process_library

    mock_client = MagicMock()
    mock_client.get_series.side_effect = ConnectionError("Sonarr unreachable")

    processed_ids = []

    def mock_process(series_id):
        processed_ids.append(series_id)

    with (
        patch("sonarr_client.get_sonarr_client", return_value=mock_client),
        patch("routes.subtitle_processor._batch_process_series", side_effect=mock_process),
    ):
        _batch_process_library("all")  # must not raise

    assert processed_ids == []


# ─── Standalone mode (no Sonarr) ─────────────────────────────────────────────


def _add_standalone_series(tmp_path, name="show"):
    from db.models.standalone import StandaloneSeries
    from extensions import db as _db

    folder = tmp_path / name
    folder.mkdir()
    (folder / "Show - S01E01.mkv").write_bytes(b"\x00")
    (folder / "Show - S01E01.de.srt").write_text("1\n00:00:01,000 --> 00:00:02,000\nHallo\n")
    (folder / "Show - S01E01.de.hi.srt").write_text("1\n00:00:01,000 --> 00:00:02,000\n[x]\n")
    row = StandaloneSeries(
        title=name, folder_path=str(folder), created_at=_now(), updated_at=_now()
    )
    _db.session.add(row)
    _db.session.commit()
    return row.id, folder


def test_batch_process_series_standalone_processes_folder_sidecars(app_ctx, temp_db, tmp_path):
    """Without Sonarr a series id is a standalone series: its folder's sidecars
    are processed (variant sidecars still skipped) and completion is emitted."""
    from routes.subtitle_processor import _batch_process_series

    series_id, folder = _add_standalone_series(tmp_path)
    result = MagicMock(changes=[], backed_up=False)
    with (
        patch("sonarr_client.get_sonarr_client", return_value=None),
        patch("routes.subtitle_processor._build_pipeline_mods", return_value=["mod"]),
        patch("subtitle_processor.apply_mods", return_value=result) as mock_apply,
        patch("events.emit_event") as mock_emit,
    ):
        _batch_process_series(series_id)

    assert [c.args[0] for c in mock_apply.call_args_list] == [str(folder / "Show - S01E01.de.srt")]
    completed = [c for c in mock_emit.call_args_list if c.args[0] == "batch_process_completed"]
    assert completed and completed[0].args[1] == {"series_id": series_id, "total": 1}


def test_batch_process_library_standalone_iterates_standalone_series(app_ctx, temp_db, tmp_path):
    from routes.subtitle_processor import _batch_process_library

    a, _ = _add_standalone_series(tmp_path, "a")
    b, _ = _add_standalone_series(tmp_path, "b")
    processed_ids = []
    with (
        patch("sonarr_client.get_sonarr_client", return_value=None),
        patch("routes.subtitle_processor._batch_process_series", side_effect=processed_ids.append),
    ):
        _batch_process_library("all")

    # Standalone series are handed on by their negative id (the source flag).
    assert sorted(processed_ids) == sorted([-a, -b])