steam-backlog-enforcer/steam_backlog_enforcer/tests/test_web_daemon.py
Krzysztof kuhy Rudnicki dc22bd557c fast install: never report a failed run as success; queue the manifest first
The BG3 fast install of 2026-10-23 downloaded 160 GB, was refused by the
installer, and the web job still ended on a full bar, "Done!" and Succeeded
while Steam restarted the download from 0%.

- A failed run's reason is read from the installer's own run log (its ERROR
  lines). A waiting job prints "Failed: fast install of <game>: <reason>",
  which fails the job; `install` no longer says "Done!" after it.
- Daemon-started failures stay on the Dashboard's Fast install card (and in
  ctl status, `error`) until a new run starts or the game is installed.
- install_game writes Steam's "needs downloading" manifest before the
  installer runs: it only finishes a manifest it finds, so fresh installs
  were refused for want of one.
- Command building moved to _fast_install_cmd (250-line cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYmCezqhUjUmMisF5YtM4F
2026-10-24 08:36:57 +02:00

186 lines
6.8 KiB
Python

"""Tests for _web_daemon — daemon status and the two-phase gaming reset."""
from __future__ import annotations
from http import HTTPStatus
from unittest.mock import patch
import pytest
from steam_backlog_enforcer import _web_daemon
from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError
from steam_backlog_enforcer._ctl_types import (
DaemonInfo,
FastInstallInfo,
PendingReset,
ResetResult,
)
from steam_backlog_enforcer._web_errors import ApiError
from steam_backlog_enforcer.jobs._view import list_jobs
from steam_backlog_enforcer.tests._web_request import make_request, payload_of
_CLIENT = "steam_backlog_enforcer._web_daemon._ctl_client"
_PENDING = PendingReset("p1", "type me", "t0", "t1", 5)
_INFO = DaemonInfo("running", "2026-01-01T00:00:00Z", 77, None)
class TestDaemonCall:
def test_passes_the_result_through(self) -> None:
assert _web_daemon.daemon_call(max, 1, 3) == 3
def test_unreachable_becomes_503(self) -> None:
with (
patch(f"{_CLIENT}.ping", side_effect=DaemonUnreachableError("no socket")),
pytest.raises(ApiError) as err,
):
_web_daemon.daemon_call(_web_daemon._ctl_client.ping)
assert err.value.status == HTTPStatus.SERVICE_UNAVAILABLE
def test_refusal_keeps_its_code(self) -> None:
with (
patch(f"{_CLIENT}.ping", side_effect=CtlError("locked", "no")),
pytest.raises(ApiError) as err,
):
_web_daemon.daemon_call(_web_daemon._ctl_client.ping)
assert err.value.code == "locked"
class TestDaemonView:
def test_unreachable_is_a_state_not_an_error(self) -> None:
with patch(f"{_CLIENT}.status", side_effect=DaemonUnreachableError):
reply = _web_daemon.daemon_view(make_request())
assert payload_of(reply)["state"] == "unreachable"
assert payload_of(reply)["fast_installs"] == []
def test_status_refusal_is_an_error(self) -> None:
with (
patch(f"{_CLIENT}.status", side_effect=CtlError("busy", "m")),
pytest.raises(ApiError) as err,
):
_web_daemon.daemon_view(make_request())
assert err.value.code == "busy"
def test_reachable_includes_the_journal(self) -> None:
with (
patch(f"{_CLIENT}.status", return_value=_INFO),
patch(f"{_CLIENT}.journal_tail", return_value=["a", "b"]) as tail,
):
reply = _web_daemon.daemon_view(make_request())
tail.assert_called_once_with(_web_daemon._JOURNAL_LINES)
assert reply.payload == {
"state": "running",
"started_at": "2026-01-01T00:00:00Z",
"pid": 77,
"journal_tail": ["a", "b"],
"restart_available_at": None,
"fast_installs": [],
}
def test_fast_installs_pass_through(self) -> None:
run = FastInstallInfo(7, "G", "downloading depot 3/8", 46.9, 60)
info = DaemonInfo("running", "t", 77, None, fast_installs=(run,))
with (
patch(f"{_CLIENT}.status", return_value=info),
patch(f"{_CLIENT}.journal_tail", return_value=[]),
):
reply = _web_daemon.daemon_view(make_request())
assert payload_of(reply)["fast_installs"] == [
{
"app_id": 7,
"name": "G",
"phase": "downloading depot 3/8",
"percent": 46.9,
"elapsed_seconds": 60,
"error": None,
}
]
@pytest.mark.parametrize(
"failure", [DaemonUnreachableError(), CtlError("busy", "m")]
)
def test_status_stands_without_its_journal(self, failure: Exception) -> None:
with (
patch(f"{_CLIENT}.status", return_value=_INFO),
patch(f"{_CLIENT}.journal_tail", side_effect=failure),
):
reply = _web_daemon.daemon_view(make_request())
assert payload_of(reply)["journal_tail"] == []
class TestPendingRoutes:
def test_pending_action_maps_the_contract_fields(self) -> None:
assert _web_daemon.pending_action(_PENDING) == {
"id": "p1",
"command": "gaming-reset",
"phrase": "type me",
"armed_at": "t0",
"ready_at": "t1",
"heartbeat_interval_seconds": 5,
}
def test_heartbeat(self) -> None:
with patch(f"{_CLIENT}.gaming_reset_heartbeat", return_value=_PENDING) as hb:
reply = _web_daemon.heartbeat_view(make_request("p1"))
hb.assert_called_once_with("p1")
assert payload_of(reply)["id"] == "p1"
def test_cancel(self) -> None:
with patch(f"{_CLIENT}.gaming_reset_cancel") as cancel:
reply = _web_daemon.cancel_pending_view(make_request("p1"))
cancel.assert_called_once_with("p1")
assert reply.status == HTTPStatus.NO_CONTENT
class TestConfirmPhrase:
@pytest.mark.parametrize(
("body", "expected"),
[
({}, ""),
({"confirm_phrase": "a"}, "a"),
({"confirmPhrase": "b"}, "b"),
],
)
def test_reads_either_spelling(self, body: dict[str, str], expected: str) -> None:
assert _web_daemon.confirm_phrase_of(make_request(body=body)) == expected
def test_rejects_a_non_string(self) -> None:
with pytest.raises(ApiError) as err:
_web_daemon.confirm_phrase_of(make_request(body={"confirm_phrase": 3}))
assert err.value.code == "invalid_params"
class TestCommit:
def test_records_a_finished_job(self) -> None:
result = ResetResult("2026-01-01", 1800.0, was_blocked=True, released=["m"])
with patch(f"{_CLIENT}.gaming_reset_commit", return_value=result) as commit:
reply = _web_daemon.commit_view(
make_request("p1", body={"confirm_phrase": "go"})
)
commit.assert_called_once_with("p1", "go")
assert reply.status == HTTPStatus.CREATED
assert "30 min billed" in payload_of(reply)["summary"]
assert [j["id"] for j in list_jobs()] == [payload_of(reply)["id"]]
def test_failed_op_is_recorded_and_raised(self) -> None:
failure = CtlError("op_failed", "disk full")
with (
patch(f"{_CLIENT}.gaming_reset_commit", side_effect=failure),
pytest.raises(ApiError) as err,
):
_web_daemon.commit_view(make_request("p1"))
assert err.value.code == "op_failed"
(job,) = list_jobs()
assert job["summary"] == "disk full"
assert job["exit_code"] == 1
def test_refusal_leaves_no_job(self) -> None:
with (
patch(
f"{_CLIENT}.gaming_reset_commit",
side_effect=CtlError("wrong_phrase", "no"),
),
pytest.raises(ApiError),
):
_web_daemon.commit_view(make_request("p1"))
assert list_jobs() == []