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()