from __future__ import annotations import asyncio import tempfile import threading import unittest import xml.etree.ElementTree as ET from pathlib import Path from unittest.mock import Mock, patch from fastapi.testclient import TestClient from progression_backend.app import create_app from progression_backend.app import latest_windows_release_url from progression_backend.config import Settings from progression_backend.database import Database from progression_backend.modlist import ( ModListItem, core_items, generate_rml, parse_active_mods, workshop_items, ) from progression_backend.refresh import RefreshCoordinator, RefreshService from progression_backend.steam_api import WorkshopDetails from progression_backend.workshop import ( SteamCmdBatchResult, SteamCmdRunner, parse_about_xml, utc_now, ) class FakeSteam: def get_collections(self, collection_ids: list[str]) -> dict[str, list[str]]: return {collection_id: ["123456"] for collection_id in collection_ids} def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]: return { workshop_id: WorkshopDetails( workshop_id=workshop_id, title="Test & Mod", time_updated=123, file_size=456, consumer_app_id=294100, result=1, ) for workshop_id in workshop_ids } class FakeRunner: def __init__(self, content_dir: Path): self.content_dir = content_dir self.last_timeout_seconds: float | None = None def available(self) -> bool: return True def run_batch( self, workshop_ids: list[str], timeout_seconds: float | None = None ) -> SteamCmdBatchResult: self.last_timeout_seconds = timeout_seconds for workshop_id in workshop_ids: about = self.content_dir / workshop_id / "About" about.mkdir(parents=True) (about / "About.xml").write_text( "Test & Mod" "Author.Test" "294100", encoding="utf-8", ) timestamp = utc_now() return SteamCmdBatchResult( workshop_ids=workshop_ids, started_at=timestamp, finished_at=timestamp, elapsed_seconds=0.1, exit_code=0, status="completed", failed_ids=set(), output_tail="Success", ) class FailingRunner: def __init__(self) -> None: self.calls = 0 self.last_timeout_seconds: float | None = None def available(self) -> bool: return True def run_batch( self, workshop_ids: list[str], timeout_seconds: float | None = None ) -> SteamCmdBatchResult: self.calls += 1 self.last_timeout_seconds = timeout_seconds timestamp = utc_now() return SteamCmdBatchResult( workshop_ids=workshop_ids, started_at=timestamp, finished_at=timestamp, elapsed_seconds=0.001, exit_code=1, status="failed", failed_ids=set(workshop_ids), output_tail="Download failed", ) class BackendTests(unittest.TestCase): @patch("progression_backend.app.requests.get") def test_latest_windows_release_includes_prereleases(self, get: Mock) -> None: response = Mock() response.json.return_value = [ { "tag_name": "0.2.2", "published_at": "2026-06-13T02:37:15Z", "draft": False, "prerelease": True, "assets": [ { "name": "ProgressionLoader.exe", "browser_download_url": "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.2/ProgressionLoader.exe", } ], } ] get.return_value = response self.assertEqual( latest_windows_release_url(), "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.2/ProgressionLoader.exe", ) response.raise_for_status.assert_called_once_with() @patch("progression_backend.app.requests.get") def test_latest_windows_release_prefers_stable_build(self, get: Mock) -> None: response = Mock() response.json.return_value = [ { "published_at": "2026-07-01T00:00:00Z", "draft": False, "prerelease": True, "assets": [ { "name": "ProgressionLoader.exe", "browser_download_url": "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.3.0-rc1/ProgressionLoader.exe", } ], }, { "published_at": "2026-06-30T00:00:00Z", "draft": False, "prerelease": False, "assets": [ { "name": "ProgressionLoader.exe", "browser_download_url": "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.3/ProgressionLoader.exe", } ], }, ] get.return_value = response self.assertEqual( latest_windows_release_url(), "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.3/ProgressionLoader.exe", ) def test_about_parser_normalizes_package_id(self) -> None: with tempfile.TemporaryDirectory() as directory: about = Path(directory) / "About" about.mkdir() (about / "About.xml").write_text( "A & BAuthor.Mod", encoding="utf-8", ) parsed = parse_about_xml(Path(directory)) self.assertEqual(parsed.package_id, "author.mod") self.assertEqual(parsed.display_name, "A & B") def test_steamcmd_runner_prepares_writable_workshop_path(self) -> None: with tempfile.TemporaryDirectory() as directory: content_dir = ( Path(directory) / "steamapps" / "workshop" / "content" / "294100" ) runner = SteamCmdRunner("steamcmd", content_dir, 60) runner.ensure_workshop_writable() self.assertTrue(content_dir.is_dir()) self.assertFalse( (content_dir.parent.parent / ".progression-write-test").exists() ) @patch("progression_backend.workshop.subprocess.run") def test_steamcmd_runner_forces_mounted_install_root(self, run: Mock) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) executable = root / "steamcmd-bin" / "steamcmd" executable.parent.mkdir() executable.write_text("", encoding="utf-8") content_dir = ( root / "steam-library" / "steamapps" / "workshop" / "content" / "294100" ) run.return_value = Mock(returncode=0, stdout="", stderr="") runner = SteamCmdRunner(str(executable), content_dir, 60) runner.run_batch(["123456"]) command = run.call_args.args[0] self.assertEqual( command[:5], [ str(executable.resolve()), "+force_install_dir", str(root / "steam-library"), "+login", "anonymous", ], ) def test_steamcmd_io_recovery_preserves_completed_mods(self) -> None: with tempfile.TemporaryDirectory() as directory: content_dir = ( Path(directory) / "steamapps" / "workshop" / "content" / "294100" ) completed_mod = content_dir / "123456" / "About" completed_mod.mkdir(parents=True) (completed_mod / "About.xml").write_text("") workshop_dir = content_dir.parent.parent manifest = workshop_dir / "appworkshop_294100.acf" manifest.write_text("stale", encoding="utf-8") staging = workshop_dir / "downloads" / "294100" staging.mkdir(parents=True) (staging / "partial.bin").write_bytes(b"partial") runner = SteamCmdRunner("steamcmd", content_dir, 60) moved = runner.recover_io_failure() self.assertEqual(len(moved), 2) self.assertFalse(manifest.exists()) self.assertFalse(staging.exists()) self.assertTrue((completed_mod / "About.xml").is_file()) self.assertEqual(runner.recover_io_failure(), []) def test_rml_uses_workshop_id_and_escapes_names(self) -> None: payload = generate_rml( [ *core_items(["royalty"]), ModListItem("author.mod", "123456", "A & B"), ], "1.6", ) root = ET.fromstring(payload) self.assertEqual(root.findtext("meta/gameVersion"), "1.6") self.assertEqual( [node.text for node in root.findall("meta/modSteamIds/li")], ["0", "0", "123456"], ) self.assertIn(b"A & B", payload) def test_homebrew_parser_reads_active_mods(self) -> None: items = parse_active_mods( b"
  • ludeon.rimworld
  • " b"
  • author.mod
  • " ) self.assertEqual( [item.package_id for item in items], ["ludeon.rimworld", "author.mod"] ) def test_refresh_persists_download_analytics(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) settings = Settings( host="127.0.0.1", port=8000, database_path=root / "progression.db", steamcmd_path="steamcmd", workshop_content_dir=root / "workshop" / "294100", steamcmd_batch_size=25, steamcmd_timeout_seconds=60, refresh_interval_seconds=3600, refresh_on_startup=False, initial_download_timeout_seconds=43200, game_version="1.6", admin_token=None, collections={"Core": "1", "Content": "2", "Cosmetics": "3"}, static_dir=root, ) database = Database(settings.database_path) database.initialize() runner = FakeRunner(settings.workshop_content_dir) service = RefreshService( settings, database, steam=FakeSteam(), runner=runner, ) progress_events: list[dict] = [] service.refresh( "test-run", "test", initial_download=True, overall_timeout_seconds=10, progress=progress_events.append, ) run = database.fetch_one( "SELECT * FROM refresh_runs WHERE id = ?", ("test-run",) ) self.assertIsNotNone(run) self.assertEqual(run["status"], "completed", run) self.assertEqual(run["unique_items"], 1) self.assertEqual(run["downloads_succeeded"], 1) mod = database.fetch_one("SELECT * FROM mods WHERE workshop_id = '123456'") self.assertEqual(mod["package_id"], "author.test") self.assertEqual(mod["steam_app_id"], "294100") generated = workshop_items([mod]) self.assertEqual(generated[0].workshop_id, "123456") self.assertIsNotNone(runner.last_timeout_seconds) self.assertLessEqual(runner.last_timeout_seconds or 0, 10) self.assertEqual(progress_events[-1]["stage"], "completed") self.assertEqual(progress_events[-1]["percent"], 100.0) def test_initial_download_stops_at_overall_timeout(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) settings = Settings( host="127.0.0.1", port=8000, database_path=root / "progression.db", steamcmd_path="steamcmd", workshop_content_dir=root / "workshop" / "294100", steamcmd_batch_size=25, steamcmd_timeout_seconds=60, refresh_interval_seconds=3600, refresh_on_startup=False, initial_download_timeout_seconds=43200, game_version="1.6", admin_token=None, collections={"Core": "1", "Content": "2", "Cosmetics": "3"}, static_dir=root, ) database = Database(settings.database_path) database.initialize() runner = FailingRunner() service = RefreshService( settings, database, steam=FakeSteam(), runner=runner # type: ignore[arg-type] ) service.refresh( "timeout-run", "startup", initial_download=True, overall_timeout_seconds=0.25, # type: ignore[arg-type] ) run = database.fetch_one( "SELECT * FROM refresh_runs WHERE id = ?", ("timeout-run",) ) self.assertIsNotNone(run) self.assertEqual(run["status"], "failed") self.assertIn("initial Workshop download reached", run["error"]) self.assertEqual(runner.calls, 1) self.assertLessEqual(runner.last_timeout_seconds or 0, 0.25) def test_api_serves_dashboard_and_reports_empty_modlist(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) project_root = Path(__file__).resolve().parents[1] settings = Settings( host="127.0.0.1", port=8000, database_path=root / "progression.db", steamcmd_path="missing-steamcmd", workshop_content_dir=root / "workshop" / "294100", steamcmd_batch_size=25, steamcmd_timeout_seconds=60, refresh_interval_seconds=3600, refresh_on_startup=False, initial_download_timeout_seconds=43200, game_version="1.6", admin_token=None, collections={"Core": "1", "Content": "2", "Cosmetics": "3"}, static_dir=project_root / "progression_backend" / "static", ) with TestClient(create_app(settings)) as client: self.assertEqual(client.get("/").status_code, 200) self.assertEqual(client.get("/art/GameTitle.png").status_code, 200) self.assertEqual(client.get("/api/health").json()["status"], "ok") dashboard = client.get("/api/status").json() self.assertIn("rml_updated_at", dashboard) self.assertIn("initial_download", dashboard["scheduler"]) self.assertEqual( dashboard["scheduler"]["initial_download_timeout_seconds"], 43200, ) response = client.get("/api/modlists/vanilla") self.assertEqual(response.status_code, 409) release_url = ( "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/" "releases/download/0.2.2/ProgressionLoader.exe" ) with patch( "progression_backend.app.latest_windows_release_url", return_value=release_url, ): response = client.get( "/api/downloads/windows", follow_redirects=False ) self.assertEqual(response.status_code, 307) self.assertEqual(response.headers["location"], release_url) class SlowInitialService: def __init__(self) -> None: self.started = threading.Event() self.release = threading.Event() self.needs_download = True self.received_timeout: int | None = None def needs_initial_download(self) -> bool: return self.needs_download def refresh(self, run_id: str, trigger: str, **options: object) -> None: self.received_timeout = options.get("overall_timeout_seconds") # type: ignore[assignment] progress = options.get("progress") if callable(progress): progress( { "stage": "downloading", "completed_mods": 1, "total_mods": 4, "percent": 25.0, } ) self.started.set() self.release.wait(timeout=2) self.needs_download = False class CoordinatorTests(unittest.IsolatedAsyncioTestCase): async def test_initial_download_pauses_hourly_scheduler(self) -> None: service = SlowInitialService() coordinator = RefreshCoordinator( service, 3600, True, initial_download_timeout_seconds=43200 # type: ignore[arg-type] ) await coordinator.start() started = await asyncio.to_thread(service.started.wait, 1) self.assertTrue(started) active = coordinator.state() self.assertTrue(active["initial_download"]) self.assertIsNone(active["next_refresh_at"]) self.assertEqual(active["progress"]["percent"], 25.0) self.assertEqual(service.received_timeout, 43200) service.release.set() if coordinator._refresh_task: await asyncio.wait_for(coordinator._refresh_task, timeout=1) await asyncio.sleep(0.01) completed = coordinator.state() self.assertFalse(completed["initial_download"]) self.assertIsNotNone(completed["next_refresh_at"]) await coordinator.stop() if __name__ == "__main__": unittest.main()