757 lines
28 KiB
Python
757 lines
28 KiB
Python
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 SteamWorkshopClient, WorkshopDetails
|
|
from progression_backend.workshop import (
|
|
SteamCmdBatchResult,
|
|
SteamCmdRunner,
|
|
parse_about_xml,
|
|
utc_now,
|
|
)
|
|
|
|
|
|
class FakeSteam:
|
|
def get_collections(
|
|
self, collection_ids: list[str]
|
|
) -> tuple[dict[str, list[str]], set[str]]:
|
|
return (
|
|
{collection_id: ["123456"] for collection_id in collection_ids},
|
|
set(),
|
|
)
|
|
|
|
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(
|
|
"<ModMetaData><name>Test & Mod</name>"
|
|
"<packageId>Author.Test</packageId>"
|
|
"<steamAppId>294100</steamAppId></ModMetaData>",
|
|
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 MultiModSteam:
|
|
workshop_ids = ["100", "200", "300"]
|
|
|
|
def get_collections(
|
|
self, collection_ids: list[str]
|
|
) -> tuple[dict[str, list[str]], set[str]]:
|
|
return (
|
|
{
|
|
collection_id: list(self.workshop_ids)
|
|
for collection_id in collection_ids
|
|
},
|
|
set(),
|
|
)
|
|
|
|
def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]:
|
|
return {
|
|
workshop_id: WorkshopDetails(
|
|
workshop_id=workshop_id,
|
|
title=f"Test mod {workshop_id}",
|
|
time_updated=123,
|
|
file_size=456,
|
|
consumer_app_id=294100,
|
|
result=1,
|
|
)
|
|
for workshop_id in workshop_ids
|
|
}
|
|
|
|
|
|
class CollectionReferenceSteam:
|
|
def get_collections(
|
|
self, collection_ids: list[str]
|
|
) -> tuple[dict[str, list[str]], set[str]]:
|
|
core, content, cosmetics = collection_ids
|
|
return (
|
|
{
|
|
core: ["100", content, cosmetics, "999"],
|
|
content: [core, "100", cosmetics],
|
|
cosmetics: [core, content, "100"],
|
|
},
|
|
set(collection_ids),
|
|
)
|
|
|
|
def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]:
|
|
return {
|
|
workshop_id: WorkshopDetails(
|
|
workshop_id=workshop_id,
|
|
title=(
|
|
"Unavailable mod"
|
|
if workshop_id == "999"
|
|
else f"Test entry {workshop_id}"
|
|
),
|
|
time_updated=123,
|
|
file_size=456,
|
|
consumer_app_id=294100 if workshop_id != "999" else 0,
|
|
result=9 if workshop_id == "999" else 1,
|
|
)
|
|
for workshop_id in workshop_ids
|
|
}
|
|
|
|
|
|
class TimeoutBlockingRunner:
|
|
def __init__(self, content_dir: Path):
|
|
self.content_dir = content_dir
|
|
self.calls: list[list[str]] = []
|
|
self.blocker_attempts = 0
|
|
|
|
def available(self) -> bool:
|
|
return True
|
|
|
|
def _install(self, workshop_id: str) -> None:
|
|
about = self.content_dir / workshop_id / "About"
|
|
about.mkdir(parents=True)
|
|
(about / "About.xml").write_text(
|
|
"<ModMetaData><name>Test Mod</name>"
|
|
"<packageId>Author.Test</packageId>"
|
|
"<steamAppId>294100</steamAppId></ModMetaData>",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def run_batch(
|
|
self, workshop_ids: list[str], timeout_seconds: float | None = None
|
|
) -> SteamCmdBatchResult:
|
|
del timeout_seconds
|
|
self.calls.append(list(workshop_ids))
|
|
failed_ids: set[str] = set()
|
|
output = "Success"
|
|
|
|
if workshop_ids == ["100", "200", "300"]:
|
|
self._install("100")
|
|
failed_ids = {"200", "300"}
|
|
output = "ERROR! Timeout downloading item 200"
|
|
elif workshop_ids == ["200"]:
|
|
self.blocker_attempts += 1
|
|
if self.blocker_attempts == 1:
|
|
failed_ids = {"200"}
|
|
output = "ERROR! Timeout downloading item 200"
|
|
else:
|
|
self._install("200")
|
|
else:
|
|
for workshop_id in workshop_ids:
|
|
self._install(workshop_id)
|
|
|
|
timestamp = utc_now()
|
|
return SteamCmdBatchResult(
|
|
workshop_ids=workshop_ids,
|
|
started_at=timestamp,
|
|
finished_at=timestamp,
|
|
elapsed_seconds=0.001,
|
|
exit_code=0,
|
|
status="failed" if failed_ids else "completed",
|
|
failed_ids=failed_ids,
|
|
output_tail=output,
|
|
)
|
|
|
|
|
|
class BackendTests(unittest.TestCase):
|
|
def test_collection_api_tracks_nested_collection_references(self) -> None:
|
|
client = SteamWorkshopClient()
|
|
response = Mock()
|
|
response.json.return_value = {
|
|
"response": {
|
|
"collectiondetails": [
|
|
{
|
|
"publishedfileid": "1",
|
|
"result": 1,
|
|
"children": [
|
|
{"publishedfileid": "100", "sortorder": 0, "filetype": 0},
|
|
{"publishedfileid": "2", "sortorder": 1, "filetype": 2},
|
|
],
|
|
},
|
|
{
|
|
"publishedfileid": "2",
|
|
"result": 1,
|
|
"children": [
|
|
{"publishedfileid": "1", "sortorder": 0},
|
|
],
|
|
},
|
|
]
|
|
}
|
|
}
|
|
client.session.post = Mock(return_value=response)
|
|
|
|
collections, references = client.get_collections(["1", "2"])
|
|
|
|
self.assertEqual(collections, {"1": ["100", "2"], "2": ["1"]})
|
|
self.assertEqual(references, {"1", "2"})
|
|
response.raise_for_status.assert_called_once_with()
|
|
|
|
@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(
|
|
"<ModMetaData><name>A & B</name><packageId>Author.Mod</packageId></ModMetaData>",
|
|
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("<ModMetaData />")
|
|
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"<ModsConfigData><activeMods><li>ludeon.rimworld</li>"
|
|
b"<li>author.mod</li></activeMods></ModsConfigData>"
|
|
)
|
|
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_collection_references_and_unavailable_items_are_skipped(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()
|
|
service = RefreshService(
|
|
settings,
|
|
database,
|
|
steam=CollectionReferenceSteam(),
|
|
runner=FakeRunner(settings.workshop_content_dir),
|
|
)
|
|
progress_events: list[dict] = []
|
|
|
|
service.refresh(
|
|
"collection-reference-run",
|
|
"startup",
|
|
initial_download=True,
|
|
overall_timeout_seconds=60,
|
|
progress=progress_events.append,
|
|
)
|
|
|
|
run = database.fetch_one(
|
|
"SELECT * FROM refresh_runs WHERE id = ?",
|
|
("collection-reference-run",),
|
|
)
|
|
self.assertIsNotNone(run)
|
|
self.assertEqual(run["status"], "completed", run)
|
|
self.assertEqual(run["unique_items"], 5)
|
|
self.assertEqual(run["downloads_requested"], 1)
|
|
self.assertEqual(run["ready_items"], 1)
|
|
dashboard = database.dashboard()
|
|
self.assertEqual(dashboard["totals"]["total_mods"], 5)
|
|
self.assertEqual(dashboard["totals"]["ready_mods"], 1)
|
|
self.assertEqual(dashboard["totals"]["skipped_mods"], 4)
|
|
skipped = database.fetch_all(
|
|
"SELECT workshop_id FROM mods WHERE status = 'skipped' "
|
|
"ORDER BY CAST(workshop_id AS INTEGER)"
|
|
)
|
|
self.assertEqual(
|
|
[row["workshop_id"] for row in skipped], ["1", "2", "3", "999"]
|
|
)
|
|
self.assertFalse(service.needs_initial_download())
|
|
self.assertEqual(progress_events[-1]["completed_mods"], 5)
|
|
self.assertEqual(progress_events[-1]["total_mods"], 5)
|
|
self.assertEqual(progress_events[-1]["percent"], 100.0)
|
|
|
|
with TestClient(create_app(settings)) as client:
|
|
response = client.get("/api/modlists/vanilla")
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertIn(b"100", response.content)
|
|
self.assertNotIn(b"999", response.content)
|
|
|
|
@patch("progression_backend.refresh.time.sleep", return_value=None)
|
|
def test_initial_download_isolates_items_after_batch_timeout(
|
|
self, sleep: Mock
|
|
) -> 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 = TimeoutBlockingRunner(settings.workshop_content_dir)
|
|
service = RefreshService(
|
|
settings,
|
|
database,
|
|
steam=MultiModSteam(),
|
|
runner=runner,
|
|
)
|
|
|
|
service.refresh(
|
|
"blocking-timeout-run",
|
|
"startup",
|
|
initial_download=True,
|
|
overall_timeout_seconds=60,
|
|
)
|
|
|
|
run = database.fetch_one(
|
|
"SELECT * FROM refresh_runs WHERE id = ?",
|
|
("blocking-timeout-run",),
|
|
)
|
|
self.assertIsNotNone(run)
|
|
self.assertEqual(run["status"], "completed", run)
|
|
self.assertEqual(run["ready_items"], 3)
|
|
self.assertEqual(
|
|
runner.calls,
|
|
[["100", "200", "300"], ["200"], ["300"], ["200"]],
|
|
)
|
|
sleep.assert_called()
|
|
|
|
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()
|