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:
parent
b95a82b8a4
commit
83d2a9932c
3 changed files with 32 additions and 5 deletions
|
|
@ -191,7 +191,7 @@ Implementation notes:
|
||||||
- Single TCP connection per query (loopback, ~10-20ms total round-trip — pooling not worth it at this scale).
|
- 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 :`).
|
- 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:...`).
|
- 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)
|
### `l4d2web/services/steam_users.py` (new)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,10 @@ def parse_status(body: str) -> StatusResponse:
|
||||||
|
|
||||||
def _parse_duration(text: str) -> int:
|
def _parse_duration(text: str) -> int:
|
||||||
"""Parse Source's connected duration: HH:MM:SS or MM:SS -> seconds."""
|
"""Parse Source's connected duration: HH:MM:SS or MM:SS -> seconds."""
|
||||||
|
try:
|
||||||
parts = [int(p) for p in text.split(":")]
|
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:
|
if len(parts) == 2:
|
||||||
return parts[0] * 60 + parts[1]
|
return parts[0] * 60 + parts[1]
|
||||||
if len(parts) == 3:
|
if len(parts) == 3:
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,11 @@ def _unpack_one(conn: socket.socket) -> tuple[int, int, str]:
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def fake_rcon_server(handler) -> Iterator[int]:
|
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 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
server.bind(("127.0.0.1", 0))
|
server.bind(("127.0.0.1", 0))
|
||||||
|
|
@ -50,6 +54,8 @@ def fake_rcon_server(handler) -> Iterator[int]:
|
||||||
port = server.getsockname()[1]
|
port = server.getsockname()[1]
|
||||||
server.settimeout(3.0)
|
server.settimeout(3.0)
|
||||||
|
|
||||||
|
handler_error: list[BaseException] = []
|
||||||
|
|
||||||
def serve() -> None:
|
def serve() -> None:
|
||||||
try:
|
try:
|
||||||
conn, _ = server.accept()
|
conn, _ = server.accept()
|
||||||
|
|
@ -57,8 +63,8 @@ def fake_rcon_server(handler) -> Iterator[int]:
|
||||||
handler(conn)
|
handler(conn)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except BaseException as exc:
|
||||||
pass
|
handler_error.append(exc)
|
||||||
|
|
||||||
t = threading.Thread(target=serve, daemon=True)
|
t = threading.Thread(target=serve, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
|
|
@ -67,6 +73,8 @@ def fake_rcon_server(handler) -> Iterator[int]:
|
||||||
finally:
|
finally:
|
||||||
server.close()
|
server.close()
|
||||||
t.join(timeout=1.0)
|
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:
|
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 fake_rcon_server(handler) as port:
|
||||||
with pytest.raises(RconError):
|
with pytest.raises(RconError):
|
||||||
query_status("127.0.0.1", port, "x", timeout=0.3)
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue