{ config, lib, pkgs, ... }: let qbitPort = 18080; radarrPort = 17878; sonarrPort = 18989; radarrConfig = pkgs.writeText "radarr-config.xml" '' test-radarr-key ''; sonarrConfig = pkgs.writeText "sonarr-config.xml" '' test-sonarr-key ''; python = "${ pkgs.python3.withPackages (ps: [ ps.pyarr ps.qbittorrent-api ]) }/bin/python3"; auditScript = ../services/arr/torrent-audit.py; # Single mock API server script -- accepts SERVICE and PORT as CLI args. # Routes responses based on SERVICE type (qbit / radarr / sonarr). mockScript = pkgs.writeText "mock-api-server.py" '' import json import sys from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs SERVICE = sys.argv[1] PORT = int(sys.argv[2]) # ── Hash constants (uppercase, 40 hex chars) ────────────────────────── # Movies UNMANAGED_MOV = "A" * 38 + "01" MANAGED_MOV = "A" * 38 + "02" OLD_MOV = "A" * 38 + "03" # movieId=2, older import → abandoned SAFE NEW_MOV = "A" * 38 + "04" # movieId=2, newer import → keeper KEEPER_CROSS = "A" * 38 + "05" # keeper for movieId=3, old for movieId=4 KEEPER3_OLD = "A" * 38 + "0B" # movieId=3, older import (not in qBit) KEEPER4_NEW = "A" * 38 + "06" # movieId=4, newer import → keeper REMOVED_OLD = "A" * 38 + "07" # movieId=5, older import (movie removed) REMOVED_NEW = "A" * 38 + "08" # movieId=5, newer import → keeper (not in qBit) LARGER_OLD = "A" * 38 + "09" # movieId=6, older import (larger than current) LARGER_NEW = "A" * 38 + "0A" # movieId=6, newer import → keeper SINGLE_CROSS = "A" * 38 + "0C" # movieId=7 single import AND older import for movieId=8 SINGLE8_NEW = "A" * 38 + "0D" # movieId=8, newer import → keeper (not in qBit) QUEUED_MOV = "A" * 38 + "0E" # in Radarr queue, not in history # TV UNMANAGED_TV = "B" * 38 + "01" MANAGED_TV = "B" * 38 + "02" # episodeId=100, single import OLD_TV = "B" * 38 + "03" # episodeId=200, older import → abandoned SAFE NEW_TV = "B" * 38 + "04" # episodeId=200, newer import → active SEASON_PACK = "B" * 38 + "05" # episodeIds 300,301,302 (still active for 301,302) REPACK = "B" * 38 + "06" # episodeId=300, newer import → active REMOVED_TV = "B" * 38 + "07" # episodeId=400, older import (series removed) REMOVED_TV_NEW = "B" * 38 + "08" # episodeId=400, newer import (not in qBit) def make_torrent(h, name, size, added_on, state="uploading"): return { "hash": h.lower(), "name": name, "size": size, "state": state, "added_on": added_on, "content_path": f"/downloads/{name}", } QBIT_DATA = { "movies": [ make_torrent(UNMANAGED_MOV, "Unmanaged.Movie.2024", 5_000_000_000, 1704067200), make_torrent(MANAGED_MOV, "Managed.Movie.2024", 4_000_000_000, 1704067201), make_torrent(OLD_MOV, "Old.Movie.Quality.2024", 3_000_000_000, 1704067202), make_torrent(NEW_MOV, "New.Movie.Quality.2024", 6_000_000_000, 1704067203), make_torrent(KEEPER_CROSS, "CrossRef.Movie.2024", 4_500_000_000, 1704067204), make_torrent(REMOVED_OLD, "Removed.Movie.2024", 3_500_000_000, 1704067205), make_torrent(LARGER_OLD, "Larger.Movie.2024", 10_737_418_240, 1704067206), make_torrent(SINGLE_CROSS, "SingleCross.Movie.2024", 4_000_000_000, 1704067207), make_torrent(QUEUED_MOV, "Queued.Movie.2024", 2_000_000_000, 1704067208), ], "tvshows": [ make_torrent(UNMANAGED_TV, "Unmanaged.Show.S01E01", 1_000_000_000, 1704067200), make_torrent(MANAGED_TV, "Managed.Show.S01E01", 800_000_000, 1704067201), make_torrent(OLD_TV, "Old.Show.S01E01", 700_000_000, 1704067202), make_torrent(NEW_TV, "New.Show.S01E01", 1_200_000_000, 1704067203), make_torrent(SEASON_PACK, "Season.Pack.S02", 5_000_000_000, 1704067204), make_torrent(REMOVED_TV, "Removed.Show.S01E01", 900_000_000, 1704067205), ], } # ── Radarr mock data ────────────────────────────────────────────────── RADARR_HISTORY = [ {"movieId": 1, "downloadId": MANAGED_MOV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"movieId": 2, "downloadId": OLD_MOV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"movieId": 2, "downloadId": NEW_MOV, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, {"movieId": 3, "downloadId": KEEPER3_OLD, "eventType": "downloadFolderImported", "date": "2023-01-01T00:00:00Z"}, {"movieId": 3, "downloadId": KEEPER_CROSS, "eventType": "downloadFolderImported", "date": "2024-03-01T00:00:00Z"}, {"movieId": 4, "downloadId": KEEPER_CROSS, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"movieId": 4, "downloadId": KEEPER4_NEW, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, {"movieId": 5, "downloadId": REMOVED_OLD, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"movieId": 5, "downloadId": REMOVED_NEW, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, {"movieId": 6, "downloadId": LARGER_OLD, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"movieId": 6, "downloadId": LARGER_NEW, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, # Non-import event (should be ignored by abandoned detection) {"movieId": 2, "downloadId": NEW_MOV, "eventType": "grabbed", "date": "2024-05-31T00:00:00Z"}, # Single-import keeper test (Fix 13): SINGLE_CROSS is only import for movieId=7 # AND an older import for movieId=8 (SINGLE8_NEW is newer for movieId=8) {"movieId": 7, "downloadId": SINGLE_CROSS, "eventType": "downloadFolderImported", "date": "2024-03-01T00:00:00Z"}, {"movieId": 8, "downloadId": SINGLE_CROSS, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"movieId": 8, "downloadId": SINGLE8_NEW, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, ] RADARR_MOVIES = [ {"id": 1, "hasFile": True, "movieFile": {"size": 4_000_000_000, "quality": {"quality": {"name": "Bluray-1080p"}}}}, {"id": 2, "hasFile": True, "movieFile": {"size": 6_000_000_000, "quality": {"quality": {"name": "Remux-1080p"}}}}, {"id": 3, "hasFile": True, "movieFile": {"size": 4_500_000_000, "quality": {"quality": {"name": "Bluray-1080p"}}}}, {"id": 4, "hasFile": True, "movieFile": {"size": 5_000_000_000, "quality": {"quality": {"name": "Remux-1080p"}}}}, # id=5 intentionally MISSING -- movie removed from Radarr {"id": 6, "hasFile": True, "movieFile": {"size": 5_368_709_120, "quality": {"quality": {"name": "Bluray-720p"}}}}, {"id": 7, "hasFile": True, "movieFile": {"size": 4_000_000_000, "quality": {"quality": {"name": "Bluray-1080p"}}}}, {"id": 8, "hasFile": True, "movieFile": {"size": 5_000_000_000, "quality": {"quality": {"name": "Remux-1080p"}}}}, ] # ── Sonarr mock data ────────────────────────────────────────────────── # Page 1 records (returned on page=1, with totalRecords=1001 to force pagination) SONARR_HISTORY_PAGE1 = [ {"episodeId": 100, "seriesId": 1, "downloadId": MANAGED_TV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"episodeId": 200, "seriesId": 1, "downloadId": OLD_TV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"episodeId": 200, "seriesId": 1, "downloadId": NEW_TV, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, # Season pack covers 3 episodes {"episodeId": 300, "seriesId": 2, "downloadId": SEASON_PACK, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"episodeId": 301, "seriesId": 2, "downloadId": SEASON_PACK, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"episodeId": 302, "seriesId": 2, "downloadId": SEASON_PACK, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, # Non-import event (should be ignored) {"episodeId": 200, "seriesId": 1, "downloadId": NEW_TV, "eventType": "grabbed", "date": "2024-05-31T00:00:00Z"}, ] # Page 2 records (critical data only available via pagination) SONARR_HISTORY_PAGE2 = [ # Episode 300 re-imported from a repack -- but 301,302 still reference SEASON_PACK {"episodeId": 300, "seriesId": 2, "downloadId": REPACK, "eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, # Removed series scenario {"episodeId": 400, "seriesId": 99, "downloadId": REMOVED_TV, "eventType": "downloadFolderImported", "date": "2024-01-01T00:00:00Z"}, {"episodeId": 400, "seriesId": 99, "downloadId": REMOVED_TV_NEW,"eventType": "downloadFolderImported", "date": "2024-06-01T00:00:00Z"}, ] SONARR_HISTORY_ALL = SONARR_HISTORY_PAGE1 + SONARR_HISTORY_PAGE2 # seriesId=99 intentionally MISSING -- series removed from Sonarr SONARR_SERIES = [ {"id": 1, "title": "Managed Show"}, {"id": 2, "title": "Season Pack Show"}, ] class Handler(BaseHTTPRequestHandler): def do_POST(self): if self.path.startswith("/api/v2/auth/login"): self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Set-Cookie", "SID=test; path=/") self.end_headers() self.wfile.write(b"Ok.") else: self._handle_json() def do_GET(self): self._handle_json() def _handle_json(self): parsed = urlparse(self.path) path = parsed.path params = parse_qs(parsed.query) content_length = int(self.headers.get("Content-Length", 0)) if content_length: body = self.rfile.read(content_length).decode() params.update(parse_qs(body)) response = self._route(path, params) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(response).encode()) def _route(self, path, params): if SERVICE == "qbit": category = params.get("category", [""])[0] return QBIT_DATA.get(category, []) elif SERVICE == "radarr": if path == "/api/v3/history": return {"records": RADARR_HISTORY, "totalRecords": len(RADARR_HISTORY)} elif path == "/api/v3/queue": return {"records": [{"downloadId": QUEUED_MOV}], "totalRecords": 1} elif path == "/api/v3/movie": return RADARR_MOVIES return {} elif SERVICE == "sonarr": if path == "/api/v3/history": page = int(params.get("page", ["1"])[0]) if page == 1: return {"records": SONARR_HISTORY_PAGE1, "totalRecords": 1001} else: return {"records": SONARR_HISTORY_PAGE2, "totalRecords": 1001} elif path == "/api/v3/queue": return {"records": [], "totalRecords": 0} elif path == "/api/v3/series": return SONARR_SERIES return {} return {} def log_message(self, fmt, *args): pass HTTPServer(("0.0.0.0", PORT), Handler).serve_forever() ''; in pkgs.testers.runNixOSTest { name = "torrent-audit"; nodes.machine = { pkgs, ... }: { environment.systemPackages = [ pkgs.curl ]; systemd.services.mock-qbittorrent = { description = "Mock qBittorrent API"; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${pkgs.python3}/bin/python3 ${mockScript} qbit ${toString qbitPort}"; Type = "simple"; }; }; systemd.services.mock-radarr = { description = "Mock Radarr API"; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${pkgs.python3}/bin/python3 ${mockScript} radarr ${toString radarrPort}"; Type = "simple"; }; }; systemd.services.mock-sonarr = { description = "Mock Sonarr API"; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${pkgs.python3}/bin/python3 ${mockScript} sonarr ${toString sonarrPort}"; Type = "simple"; }; }; }; testScript = '' start_all() machine.wait_for_unit("multi-user.target") # Wait for all mock services to be responsive machine.wait_for_unit("mock-qbittorrent.service") machine.wait_for_unit("mock-radarr.service") machine.wait_for_unit("mock-sonarr.service") machine.wait_until_succeeds( "curl -sf http://localhost:${toString qbitPort}/api/v2/torrents/info?category=movies", timeout=30, ) machine.wait_until_succeeds( "curl -sf http://localhost:${toString radarrPort}/api/v3/movie", timeout=30, ) machine.wait_until_succeeds( "curl -sf http://localhost:${toString sonarrPort}/api/v3/queue", timeout=30, ) # Run the audit script and capture stdout output = machine.succeed( "QBITTORRENT_URL=http://localhost:${toString qbitPort} " "RADARR_URL=http://localhost:${toString radarrPort} " "RADARR_CONFIG=${radarrConfig} " "SONARR_URL=http://localhost:${toString sonarrPort} " "SONARR_CONFIG=${sonarrConfig} " "CATEGORIES=movies,tvshows,anime " "${python} ${auditScript}" ) print("=== SCRIPT OUTPUT ===") print(output) print("=== END OUTPUT ===") # Fix 10: Assert section heading exists before splitting assert "ABANDONED UPGRADE LEFTOVERS" in output, \ "Output must contain ABANDONED UPGRADE LEFTOVERS heading" # Split output into sections for targeted assertions unmanaged_section = output.split("ABANDONED UPGRADE LEFTOVERS")[0] abandoned_section = output.split("ABANDONED UPGRADE LEFTOVERS")[1] # Helper: find a torrent name line and check nearby lines (within 3) for a note def assert_note_near(section, torrent_name, note_text): lines = section.splitlines() found_idx = None for i, line in enumerate(lines): if torrent_name in line: found_idx = i break assert found_idx is not None, f"{torrent_name} not found in section" nearby = "\n".join(lines[max(0, found_idx):found_idx + 4]) assert note_text in nearby, \ f"Expected '{note_text}' near '{torrent_name}', got:\n{nearby}" with subtest("Detects unmanaged movie torrent"): assert "Unmanaged.Movie.2024" in unmanaged_section, \ "Should detect unmanaged movie" assert "1 unmanaged / 9 total" in unmanaged_section, \ "Should show 1 unmanaged movie out of 9" with subtest("Detects unmanaged TV torrent"): assert "Unmanaged.Show.S01E01" in unmanaged_section, \ "Should detect unmanaged TV show" assert "1 unmanaged / 6 total" in unmanaged_section, \ "Should show 1 unmanaged TV show out of 6" with subtest("Empty category shows zero counts"): assert "0 unmanaged / 0 total" in unmanaged_section, \ "anime category should show 0 unmanaged / 0 total" with subtest("Managed torrents are NOT listed as unmanaged"): assert "Managed.Movie.2024" not in unmanaged_section, \ "Managed movie should not appear in unmanaged section" assert "Managed.Show.S01E01" not in unmanaged_section, \ "Managed TV show should not appear in unmanaged section" with subtest("Queue-known hash is NOT listed as unmanaged"): assert "Queued.Movie.2024" not in unmanaged_section, \ "Torrent in Radarr queue should not appear as unmanaged" with subtest("Detects abandoned movie upgrade as SAFE"): assert "Old.Movie.Quality.2024" in abandoned_section, \ "Should detect abandoned movie" for line in abandoned_section.splitlines(): if "Old.Movie.Quality.2024" in line: assert "SAFE" in line, f"Old movie should be SAFE, got: {line}" break with subtest("Detects abandoned TV episode as SAFE"): assert "Old.Show.S01E01" in abandoned_section, \ "Should detect abandoned TV episode" for line in abandoned_section.splitlines(): if "Old.Show.S01E01" in line: assert "SAFE" in line, f"Old TV should be SAFE, got: {line}" break with subtest("Keeper-also-abandoned hash is NOT listed as abandoned"): assert "CrossRef.Movie.2024" not in abandoned_section, \ "Hash that is keeper for another movie must not appear as abandoned" with subtest("Season pack NOT abandoned when still active for other episodes"): assert "Season.Pack.S02" not in abandoned_section, \ "Season pack still active for episodes 301/302 must not be abandoned" with subtest("Negative assertions for keepers"): assert "New.Movie.Quality.2024" not in abandoned_section, \ "Keeper for movieId=2 must not appear as abandoned" assert "New.Show.S01E01" not in abandoned_section, \ "Keeper for episodeId=200 must not appear as abandoned" assert "Managed.Movie.2024" not in abandoned_section, \ "Single-import movie must not appear as abandoned" assert "Managed.Show.S01E01" not in abandoned_section, \ "Single-import TV show must not appear as abandoned" with subtest("Single-import keeper not abandoned (Bug 1 regression)"): assert "SingleCross.Movie.2024" not in abandoned_section, \ "Hash that is sole import for movieId=7 must be in keeper set, not abandoned" with subtest("Removed movie triggers REVIEW status"): assert "Removed.Movie.2024" in abandoned_section, \ "Should detect abandoned torrent for removed movie" assert_note_near(abandoned_section, "Removed.Movie.2024", "movie removed") for line in abandoned_section.splitlines(): if "Removed.Movie.2024" in line: assert "REVIEW" in line, f"Removed movie should be REVIEW, got: {line}" break with subtest("Abandoned larger than current triggers REVIEW"): assert "Larger.Movie.2024" in abandoned_section, \ "Should detect larger abandoned torrent" assert_note_near(abandoned_section, "Larger.Movie.2024", "abandoned is larger") for line in abandoned_section.splitlines(): if "Larger.Movie.2024" in line: assert "REVIEW" in line, f"Larger abandoned should be REVIEW, got: {line}" break with subtest("Removed series triggers REVIEW status for TV"): assert "Removed.Show.S01E01" in abandoned_section, \ "Should detect abandoned torrent for removed series" assert_note_near(abandoned_section, "Removed.Show.S01E01", "series removed") for line in abandoned_section.splitlines(): if "Removed.Show.S01E01" in line: assert "REVIEW" in line, f"Removed series should be REVIEW, got: {line}" break with subtest("Correct abandoned counts per category"): assert "movies (3 abandoned)" in abandoned_section, \ "Should show 3 abandoned movies" assert "tvshows (2 abandoned)" in abandoned_section, \ "Should show 2 abandoned TV shows" with subtest("Correct summary totals"): assert "ABANDONED: 5 total (2 safe to delete)" in output, \ "Summary should show 5 total abandoned, 2 safe to delete" assert "SAFE TO RECLAIM: 3.4 GiB" in output, \ "Should report 3.4 GiB reclaimable (2.8 GiB movie + 0.7 GiB TV)" ''; }