Files
Unraid-AppOps-Gateway-Public/backend/tests/test_media_pipeline.py
T

470 lines
26 KiB
Python

from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine
@pytest.fixture
def api_client(tmp_path, monkeypatch):
db_path = tmp_path / "media_pipeline_test.db"
monkeypatch.setenv("APPOPS_DB_PATH", str(db_path))
monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token")
from backend.app.db import session
from backend.app.db.init_db import init_db
from backend.app.main import app
engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False})
session.engine = engine
SQLModel.metadata.drop_all(engine)
SQLModel.metadata.create_all(engine)
init_db()
with TestClient(app) as client:
yield client
def _healthy_payloads():
return {
"sonarr_health": {"success": True, "status": "connected"},
"sonarr_wanted": {"success": True, "status": "connected", "total": 3, "items": [{"title": "The Boys"}]},
"sonarr_queue": {"success": True, "status": "connected", "total": 1, "downloading": 1, "queued": 0, "failed": 0, "stalled": 0, "items": [{"title": "The Boys", "status": "downloading"}]},
"prowlarr_health": {"success": True, "status": "connected"},
"prowlarr_indexers": {"success": True, "status": "ok", "total": 4, "enabled": 4, "failing": 0, "items": []},
"prowlarr_apps": {"success": True, "status": "ok", "total": 2, "items": [{"name": "Sonarr", "status": "healthy"}, {"name": "Radarr", "status": "healthy"}]},
"prowlarr_sync": {"success": True, "status": "ok", "applicationsConfigured": 2, "indexersConfigured": 4, "indexersEnabled": 4, "indexersFailing": 0, "healthWarnings": 0, "healthErrors": 0},
"deluge_health": {"success": True, "status": "connected"},
"deluge_session": {"success": True, "status": "ok", "connected": True, "downloadRate": 1_200_000, "uploadRate": 200_000, "numTorrents": 2, "active": 1, "paused": 1, "stalled": 0, "errors": 0},
"deluge_active": {"success": True, "status": "ok", "total": 1, "items": [{"id": "active-1", "name": "The Boys", "state": "Downloading", "progress": 55}]},
"deluge_stalled": {"success": True, "status": "ok", "total": 0, "items": []},
"deluge_paused": {"success": True, "status": "ok", "total": 1, "items": [{"id": "paused-1", "name": "Paused", "state": "Paused"}]},
"deluge_speeds": {"success": True, "status": "ok", "downloadRate": 1_200_000, "uploadRate": 200_000, "active": 1, "paused": 1, "stalled": 0, "errors": 0},
"plex_health": {"success": True, "status": "connected"},
"plex_libraries": {"success": True, "status": "ok", "total": 2, "libraries": [{"id": "1", "title": "Series", "type": "show", "itemCount": 200}]},
"plex_sessions": {"success": True, "status": "ok", "total": 1, "sessions": [{"id": "plex-1", "title": "The Boys", "user": "Jens", "videoDecision": "directplay"}]},
"plex_recent": {"success": True, "status": "ok", "total": 1, "items": [{"title": "The Boys", "library": "Series", "type": "episode"}]},
"plex_streams": {"success": True, "status": "ok", "total": 1, "directPlay": 1, "directStream": 0, "transcoding": 0, "bandwidth": 4000, "users": 1},
"tautulli_health": {"success": True, "status": "connected"},
"tautulli_activity": {"success": True, "status": "ok", "streamCount": 1, "sessions": [{"sessionKey": "t1", "title": "The Boys", "user": "Jens", "ipAddress": "192.168.10.x", "transcodeDecision": "directplay"}]},
"tautulli_history": {"success": True, "status": "ok", "total": 1, "items": [{"rowId": "h1", "title": "The Boys", "user": "Jens", "duration": 3600}]},
"tautulli_stats": {"success": True, "status": "ok", "totalPlays": 10, "totalDuration": 36000, "topUsers": [{"name": "Jens", "plays": 10, "duration": 36000}], "topLibraries": [], "topPlatforms": [], "playsByDay": []},
"tautulli_streams": {"success": True, "status": "ok", "activeStreams": 1, "directPlay": 1, "directStream": 0, "transcoding": 0, "lanStreams": 1, "wanStreams": 0, "bandwidth": 4000},
"tautulli_libraries": {"success": True, "status": "ok", "total": 1, "items": [{"sectionId": "1", "name": "Series", "type": "show", "count": 200}]},
"widefrog_health": {"success": True, "status": "connected"},
"widefrog_queue": {"success": True, "status": "ok", "total": 1, "active": 1, "queued": 0, "completed": 0, "failed": 0, "items": [{"id": "wf-1", "title": "Process media"}]},
"widefrog_failed": {"success": True, "status": "ok", "failed": 0, "items": []},
"widefrog_activity": {"success": True, "status": "ok", "recent": [{"message": "Processed media"}], "last_event": {"message": "Processed media"}},
"widefrog_downloads": {"success": True, "status": "ok", "active": 1, "processing": 1, "items": [{"title": "Import"}]},
}
def _patch_pipeline(monkeypatch, payloads=None):
payloads = payloads or _healthy_payloads()
monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.health", lambda self: payloads["sonarr_health"], raising=False)
monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.wanted_missing", lambda self: payloads["sonarr_wanted"], raising=False)
monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.queue_status", lambda self: payloads["sonarr_queue"], raising=False)
monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.health", lambda self: payloads["prowlarr_health"], raising=False)
monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.indexer_status", lambda self: payloads["prowlarr_indexers"], raising=False)
monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.application_status", lambda self: payloads["prowlarr_apps"], raising=False)
monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.sync_status", lambda self: payloads["prowlarr_sync"], raising=False)
monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.health", lambda self: payloads["deluge_health"], raising=False)
monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.session_status", lambda self: payloads["deluge_session"], raising=False)
monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.active_downloads", lambda self: payloads["deluge_active"], raising=False)
monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.stalled_downloads", lambda self: payloads["deluge_stalled"], raising=False)
monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.paused_downloads", lambda self: payloads["deluge_paused"], raising=False)
monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.speed_status", lambda self: payloads["deluge_speeds"], raising=False)
monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.health", lambda self: payloads["plex_health"], raising=False)
monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.library_status", lambda self: payloads["plex_libraries"], raising=False)
monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.active_sessions", lambda self: payloads["plex_sessions"], raising=False)
monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.recent_media", lambda self: payloads["plex_recent"], raising=False)
monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.stream_summary", lambda self: payloads["plex_streams"], raising=False)
monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.health", lambda self: payloads["tautulli_health"], raising=False)
monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.activity", lambda self: payloads["tautulli_activity"], raising=False)
monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.history", lambda self: payloads["tautulli_history"], raising=False)
monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.watch_stats", lambda self: payloads["tautulli_stats"], raising=False)
monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.stream_summary", lambda self: payloads["tautulli_streams"], raising=False)
monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.libraries", lambda self: payloads["tautulli_libraries"], raising=False)
monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.health", lambda self: payloads["widefrog_health"], raising=False)
monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.queue_status", lambda self: payloads["widefrog_queue"], raising=False)
monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.failed_jobs", lambda self: payloads["widefrog_failed"], raising=False)
monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.activity", lambda self: payloads["widefrog_activity"], raising=False)
monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.download_status", lambda self: payloads["widefrog_downloads"], raising=False)
return payloads
def test_media_pipeline_summary_healthy(api_client, monkeypatch):
_patch_pipeline(monkeypatch)
payload = api_client.get("/api/media-pipeline/summary").json()
assert payload["success"] is True
assert payload["status"] == "healthy"
assert payload["connectors"]["sonarr"]["status"] == "connected"
assert payload["counts"]["wantedSeries"] == 3
assert payload["counts"]["activeDownloads"] == 1
assert payload["counts"]["plexLibraries"] == 2
assert payload["counts"]["activeStreams"] == 1
assert payload["counts"]["indexers"] == 4
assert payload["risk"]["level"] == "low"
def test_media_pipeline_reuses_snapshot_for_burst_reads(api_client, monkeypatch):
from backend.app.services import media_pipeline_service
payloads = _patch_pipeline(monkeypatch)
calls = {"sonarr_health": 0}
def sonarr_health(_self):
calls["sonarr_health"] += 1
return payloads["sonarr_health"]
monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.health", sonarr_health, raising=False)
media_pipeline_service._SNAPSHOT_CACHE.clear()
summary_payload = api_client.get("/api/media-pipeline/summary").json()
issues_payload = api_client.get("/api/media-pipeline/issues").json()
assert summary_payload["success"] is True
assert issues_payload["success"] is True
assert calls["sonarr_health"] == 1
def test_media_pipeline_summary_reuses_persistent_cache_after_snapshot_loss(api_client, monkeypatch):
from backend.app.services import media_pipeline_service
_patch_pipeline(monkeypatch)
media_pipeline_service._SNAPSHOT_CACHE.clear()
first = api_client.get("/api/media-pipeline/summary").json()
media_pipeline_service._SNAPSHOT_CACHE.clear()
def fail_build_snapshot():
raise AssertionError("persistent media summary cache should avoid cold connector reads")
monkeypatch.setattr(media_pipeline_service, "_build_snapshot", fail_build_snapshot)
second = api_client.get("/api/media-pipeline/summary").json()
assert second["status"] == first["status"]
assert second["counts"] == first["counts"]
assert second["connectors"] == first["connectors"]
def test_media_pipeline_snapshot_cache_prevents_concurrent_stampede(api_client, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from time import sleep
from backend.app.services import media_pipeline_service
payloads = _patch_pipeline(monkeypatch)
calls = {"sonarr_health": 0}
def sonarr_health(_self):
calls["sonarr_health"] += 1
sleep(0.05)
return payloads["sonarr_health"]
monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.health", sonarr_health, raising=False)
media_pipeline_service._SNAPSHOT_CACHE.clear()
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(lambda _index: media_pipeline_service._snapshot_cached(), range(4)))
assert all(result["sonarrHealth"]["success"] is True for result in results)
assert calls["sonarr_health"] == 1
def test_media_pipeline_cold_snapshot_runs_connector_reads_in_parallel(api_client, monkeypatch):
from threading import Barrier
from backend.app.services import media_pipeline_service
payloads = _patch_pipeline(monkeypatch)
barrier = Barrier(3, timeout=1)
def wait_and_return(key):
def _inner(_self):
barrier.wait()
return payloads[key]
return _inner
monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.health", wait_and_return("sonarr_health"), raising=False)
monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.wanted_missing", wait_and_return("sonarr_wanted"), raising=False)
monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.queue_status", wait_and_return("sonarr_queue"), raising=False)
media_pipeline_service._SNAPSHOT_CACHE.clear()
payload = api_client.get("/api/media-pipeline/summary").json()
assert payload["success"] is True
assert payload["connectors"]["sonarr"]["status"] == "connected"
assert payload["counts"]["wantedSeries"] == 3
def test_media_pipeline_summary_partial_when_connector_missing_config(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["prowlarr_health"] = {"success": False, "status": "missing_config", "message": "missing_config"}
payloads["prowlarr_indexers"] = {"success": False, "status": "missing_config", "total": 0, "enabled": 0, "failing": 0, "items": []}
payloads["prowlarr_sync"] = {"success": False, "status": "missing_config", "applicationsConfigured": 0, "indexersConfigured": 0, "indexersEnabled": 0, "indexersFailing": 0, "healthWarnings": 0, "healthErrors": 0}
_patch_pipeline(monkeypatch, payloads)
payload = api_client.get("/api/media-pipeline/summary").json()
assert payload["success"] is True
assert payload["status"] in {"partial", "missing_config"}
assert payload["connectors"]["prowlarr"]["status"] == "missing_config"
assert "prowlarr" in " ".join(payload["risk"]["reasons"]).lower()
def test_media_pipeline_flow_returns_nodes_and_edges(api_client, monkeypatch):
_patch_pipeline(monkeypatch)
payload = api_client.get("/api/media-pipeline/flow").json()
node_ids = {node["id"] for node in payload["nodes"]}
assert {"sonarr", "prowlarr", "deluge", "widefrog", "plex", "tautulli"}.issubset(node_ids)
assert {"from": "sonarr", "to": "prowlarr", "status": "healthy", "label": "indexer sync"} in payload["edges"]
assert not any(edge["from"] == "deluge" and edge["to"] == "widefrog" for edge in payload["edges"])
assert {"from": "widefrog", "to": "plex", "status": "healthy", "label": "media processing"} in payload["edges"]
action_ids = [action["actionId"] for node in payload["nodes"] for action in node.get("actions", [])]
assert not any("delete" in action or "remove" in action for action in action_ids)
def test_media_pipeline_flow_has_no_healthy_edges_when_configuration_is_missing(api_client, monkeypatch):
payloads = _healthy_payloads()
for connector in ("sonarr", "prowlarr", "deluge", "widefrog", "plex", "tautulli"):
payloads[f"{connector}_health"] = {"success": False, "status": "missing_config"}
_patch_pipeline(monkeypatch, payloads)
response = api_client.get("/api/media-pipeline/flow")
assert response.status_code == 200
payload = response.json()
assert len(payload["edges"]) == 4
assert all(edge["status"] == "unknown" for edge in payload["edges"])
assert all(node["status"] == "missing_config" for node in payload["nodes"])
@pytest.mark.parametrize("connector", ["sonarr", "prowlarr", "deluge", "widefrog", "plex", "tautulli"])
@pytest.mark.parametrize("health_status,expected", [
("missing_config", "unknown"),
("unknown", "unknown"),
("unavailable", "unknown"),
("unsupported", "unknown"),
("unexpected_state", "unknown"),
("auth_failed", "broken"),
("unreachable", "broken"),
("error", "broken"),
])
def test_media_pipeline_flow_checks_each_endpoint_before_queue_warnings(api_client, monkeypatch, connector, health_status, expected):
payloads = _healthy_payloads()
payloads[f"{connector}_health"] = {"success": False, "status": health_status}
# Nonzero counts must never turn unavailable or failed endpoints amber/green.
payloads["prowlarr_indexers"]["failing"] = 2
payloads["widefrog_failed"]["failed"] = 3
_patch_pipeline(monkeypatch, payloads)
response = api_client.get("/api/media-pipeline/flow")
assert response.status_code == 200
edges = response.json()["edges"]
unrelated_statuses = {
("sonarr", "prowlarr"): "healthy",
("prowlarr", "deluge"): "warning",
("widefrog", "plex"): "warning",
("plex", "tautulli"): "healthy",
}
for edge in edges:
if connector in (edge["from"], edge["to"]):
assert edge["status"] == expected
else:
assert edge["status"] == unrelated_statuses[(edge["from"], edge["to"])]
def test_media_pipeline_flow_unknown_endpoint_precedes_failure_on_the_other_endpoint(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["prowlarr_health"] = {"success": False, "status": "unreachable"}
payloads["deluge_health"] = {"success": False, "status": "missing_config"}
_patch_pipeline(monkeypatch, payloads)
payload = api_client.get("/api/media-pipeline/flow").json()
edge_statuses = {(edge["from"], edge["to"]): edge["status"] for edge in payload["edges"]}
assert edge_statuses[("sonarr", "prowlarr")] == "broken"
assert edge_statuses[("prowlarr", "deluge")] == "unknown"
def test_media_pipeline_flow_applies_queue_warnings_only_with_connected_endpoints(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["prowlarr_indexers"]["failing"] = 2
payloads["widefrog_failed"]["failed"] = 3
_patch_pipeline(monkeypatch, payloads)
payload = api_client.get("/api/media-pipeline/flow").json()
assert {(edge["from"], edge["to"]): edge["status"] for edge in payload["edges"]} == {
("sonarr", "prowlarr"): "healthy",
("prowlarr", "deluge"): "warning",
("widefrog", "plex"): "warning",
("plex", "tautulli"): "healthy",
}
def test_media_pipeline_issues_detect_cross_connector_problems(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["prowlarr_indexers"] = {"success": True, "status": "ok", "total": 3, "enabled": 2, "failing": 1, "items": [{"name": "Indexer", "status": "error"}]}
payloads["deluge_stalled"] = {"success": True, "status": "ok", "total": 1, "items": [{"id": "stalled-1", "name": "The Boys", "state": "Error"}]}
payloads["widefrog_failed"] = {"success": True, "status": "ok", "failed": 2, "items": [{"id": "wf-fail", "title": "Import failed"}]}
payloads["tautulli_health"] = {"success": False, "status": "missing_config", "message": "missing_config"}
_patch_pipeline(monkeypatch, payloads)
payload = api_client.get("/api/media-pipeline/issues").json()
titles = " ".join(issue["title"] for issue in payload["issues"]).lower()
assert "indexer" in titles
assert "stalled" in titles
assert "widefrog" in titles
assert "tautulli" in titles
actions = [action for issue in payload["issues"] for action in issue["suggestedActions"]]
assert actions
assert all(action["policyDecision"] in {"allowed", "approval_required", "blocked"} for action in actions)
assert all(action.get("link") for action in actions)
assert not any(any(blocked in action["actionId"] for blocked in ["delete", "remove", "kill_session", "terminate"]) for action in actions)
def test_deluge_stalled_issue_includes_source_and_timestamp(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["deluge_stalled"] = {"success": True, "status": "ok", "total": 1, "items": [{"id": "stalled-1", "name": "The Boys", "state": "Downloading"}]}
_patch_pipeline(monkeypatch, payloads)
payload = api_client.get("/api/media-pipeline/issues").json()
stalled = next(issue for issue in payload["issues"] if issue["id"] == "deluge-stalled")
assert stalled["source"] == "deluge.stalled_downloads"
assert stalled["observedAt"]
assert any(str(item).startswith("source=deluge.stalled_downloads") for item in stalled["evidence"])
assert any(str(item).startswith("observedAt=") for item in stalled["evidence"])
def test_media_pipeline_issues_handles_review_apps_without_connector_type(api_client, monkeypatch):
from sqlmodel import Session
from backend.app.db.models import App
from backend.app.db.session import engine
_patch_pipeline(monkeypatch)
with Session(engine) as session:
session.add(App(id="media-review-app", name="Media Review App", category="media", needs_review=True))
session.commit()
response = api_client.get("/api/media-pipeline/issues")
assert response.status_code == 200
payload = response.json()
review_issue = next(issue for issue in payload["issues"] if issue["id"] == "app-needs-review-media-review-app")
assert review_issue["affectedConnectors"] == ["media-review-app"]
assert review_issue["suggestedActions"][0]["target"] == "media-review-app"
assert review_issue["suggestedActions"][0]["link"] == "/apps/media-review-app"
def test_media_pipeline_downloads_combines_deluge_and_widefrog(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["deluge_stalled"] = {"success": True, "status": "ok", "total": 1, "items": [{"id": "stalled-1", "name": "The Boys", "state": "Error"}]}
payloads["widefrog_failed"] = {"success": True, "status": "ok", "failed": 1, "items": [{"id": "wf-fail", "title": "Import failed"}]}
_patch_pipeline(monkeypatch, payloads)
payload = api_client.get("/api/media-pipeline/downloads").json()
assert payload["active"]["total"] == 1
assert payload["stalled"]["total"] == 1
assert payload["failedWidefrogJobs"]["failed"] == 1
action_ids = [action["actionId"] for action in payload["suggestedActions"]]
assert "deluge.resume_torrent" in action_ids
assert "widefrog.retry_failed" in action_ids
assert {action["target"]: action["link"] for action in payload["suggestedActions"] if action["target"] in {"deluge", "widefrog"}} == {
"deluge": "/apps/deluge",
"widefrog": "/apps/widefrog",
}
assert not any("remove" in action or "delete" in action for action in action_ids)
def test_media_pipeline_library_combines_plex_tautulli_widefrog(api_client, monkeypatch):
_patch_pipeline(monkeypatch)
payload = api_client.get("/api/media-pipeline/library").json()
assert payload["plexLibraries"]["total"] == 2
assert payload["tautulliStats"]["totalPlays"] == 10
assert payload["widefrogQueue"]["total"] == 1
action_ids = [action["actionId"] for action in payload["suggestedActions"]]
assert "plex.scan_library" in action_ids
assert "plex.scan_all_libraries" in action_ids
assert "widefrog.scan_library" in action_ids
assert all(action.get("link") in {"/apps/plex", "/apps/widefrog"} for action in payload["suggestedActions"])
assert not any("empty_trash" in action or "kill_session" in action for action in action_ids)
def test_media_pipeline_activity_marks_privacy_sensitive_data(api_client, monkeypatch):
_patch_pipeline(monkeypatch)
payload = api_client.get("/api/media-pipeline/activity").json()
assert payload["privacy"]["sensitive"] is True
assert "tautulli.history" in payload["privacy"]["actions"]
assert payload["tautulliActivity"]["streamCount"] == 1
assert payload["plexSessions"]["total"] == 1
assert "192.168.10.x" in str(payload)
def test_media_pipeline_recommendations_are_generated_from_issues(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["prowlarr_indexers"] = {"success": True, "status": "ok", "total": 3, "enabled": 2, "failing": 1, "items": []}
payloads["plex_streams"] = {"success": True, "status": "ok", "total": 3, "directPlay": 0, "directStream": 0, "transcoding": 3, "bandwidth": 12000, "users": 3}
_patch_pipeline(monkeypatch, payloads)
payload = api_client.get("/api/media-pipeline/recommendations").json()
assert payload["success"] is True
assert payload["recommendations"]
assert all("confidence" in item for item in payload["recommendations"])
assert any("transcode" in item["title"].lower() or "indexer" in item["title"].lower() for item in payload["recommendations"])
def test_media_pipeline_check_is_read_only_and_audited(api_client, monkeypatch):
_patch_pipeline(monkeypatch)
payload = api_client.post("/api/media-pipeline/check").json()
assert payload["success"] is True
assert payload["summary"]["status"] == "healthy"
assert payload["auditId"].startswith("audit-")
assert payload["jobId"].startswith("job-")
def test_media_pipeline_diagnose_returns_evidence_without_execution(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["sonarr_wanted"] = {"success": True, "status": "connected", "total": 1, "items": [{"title": "The Boys"}]}
payloads["deluge_active"] = {"success": True, "status": "ok", "total": 0, "items": []}
payloads["prowlarr_indexers"] = {"success": True, "status": "ok", "total": 3, "enabled": 2, "failing": 1, "items": []}
_patch_pipeline(monkeypatch, payloads)
payload = api_client.post("/api/media-pipeline/diagnose", json={"query": "waarom downloadt The Boys niet?", "scope": "downloads"}).json()
assert payload["success"] is True
assert payload["diagnosis"]["likelyCauses"]
assert payload["diagnosis"]["evidence"]
assert payload["diagnosis"]["executedActions"] == []
assert not any("delete" in action["actionId"] or "remove" in action["actionId"] for action in payload["diagnosis"]["suggestedActions"])
def test_media_pipeline_diagnose_policy_is_read_only_allowed(api_client):
payload = api_client.post(
"/api/policies/evaluate",
json={"action": "media_pipeline.diagnose", "target": "media-pipeline", "source": "manual"},
).json()
assert payload["decision"] == "allowed"
assert payload["risk"] == "low"