refactor(rcon): harden _parse_duration; surface fixture handler errors

- _parse_duration wraps int() in try/except so malformed connected
  durations raise RconError (not ValueError leaking past the poller's
  except RconError).
- fake_rcon_server captures handler exceptions and re-raises at context
  exit, so a buggy test handler surfaces as a real failure instead of
  silently degrading into a client-side timeout.
- Two new parser tests: HH:MM:SS duration parsing and malformed input
  coverage.
- Fix Steam ID formula typo in the spec doc (Z*2 + Y, not Y*2 + Z; Y is
  the low bit). Code was already correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-12 21:39:32 +02:00
parent b95a82b8a4
commit 83d2a9932c
No known key found for this signature in database
3 changed files with 32 additions and 5 deletions

View file

@ -191,7 +191,7 @@ Implementation notes:
- Single TCP connection per query (loopback, ~10-20ms total round-trip — pooling not worth it at this scale).
- Header regex on `map :` and `players :` lines (the `(hibernating|not hibernating)` token is in `players :`).
- Roster regex: split lines starting with `#`, skip the column-header line, robustly extract the quoted name + the `STEAM_X:Y:Z` token + `MM:SS` or `HH:MM:SS` connected duration + ping. Tolerate the two-numeric-prefix L4D2 variant (`# 2 1 "Crone" STEAM_1:0:...`).
- Steam ID conversion: `STEAM_X:Y:Z``76561197960265728 + (Y * 2) + Z` (returned as string).
- Steam ID conversion: `STEAM_X:Y:Z``76561197960265728 + (Z * 2) + Y` (Y is the low bit; returned as string).
### `l4d2web/services/steam_users.py` (new)

View file

@ -168,7 +168,10 @@ def parse_status(body: str) -> StatusResponse:
def _parse_duration(text: str) -> int:
"""Parse Source's connected duration: HH:MM:SS or MM:SS -> seconds."""
parts = [int(p) for p in text.split(":")]
try:
parts = [int(p) for p in text.split(":")]
except ValueError as exc:
raise RconError(f"unparseable connected duration: {text!r}") from exc
if len(parts) == 2:
return parts[0] * 60 + parts[1]
if len(parts) == 3:

View file

@ -42,7 +42,11 @@ def _unpack_one(conn: socket.socket) -> tuple[int, int, str]:
@contextmanager
def fake_rcon_server(handler) -> Iterator[int]:
"""Start a TCP server on an ephemeral port; handler(conn) runs in a thread."""
"""Start a TCP server on an ephemeral port; handler(conn) runs in a thread.
Handler exceptions propagate at context exit so a buggy handler surfaces
as a real test failure instead of degrading into a client-side timeout.
"""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 0))
@ -50,6 +54,8 @@ def fake_rcon_server(handler) -> Iterator[int]:
port = server.getsockname()[1]
server.settimeout(3.0)
handler_error: list[BaseException] = []
def serve() -> None:
try:
conn, _ = server.accept()
@ -57,8 +63,8 @@ def fake_rcon_server(handler) -> Iterator[int]:
handler(conn)
finally:
conn.close()
except Exception:
pass
except BaseException as exc:
handler_error.append(exc)
t = threading.Thread(target=serve, daemon=True)
t.start()
@ -67,6 +73,8 @@ def fake_rcon_server(handler) -> Iterator[int]:
finally:
server.close()
t.join(timeout=1.0)
if handler_error:
raise AssertionError("fake_rcon_server handler raised") from handler_error[0]
def test_auth_success_then_status(monkeypatch: pytest.MonkeyPatch) -> None:
@ -129,3 +137,19 @@ def test_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None:
with fake_rcon_server(handler) as port:
with pytest.raises(RconError):
query_status("127.0.0.1", port, "x", timeout=0.3)
def test_parse_duration_handles_hours() -> None:
from l4d2web.services.rcon import _parse_duration
assert _parse_duration("00:21") == 21
assert _parse_duration("01:23:45") == 5025
assert _parse_duration("12:00") == 720
def test_parse_duration_rejects_malformed_as_rcon_error() -> None:
from l4d2web.services.rcon import _parse_duration
for bad in ["", ":", "abc", "1:", ":5", "1:2:3:4"]:
with pytest.raises(RconError):
_parse_duration(bad)