steam-backlog-enforcer/steam_backlog_enforcer/tests/_ledger_fixtures.py
Krzysztof kuhy Rudnicki 63321f16cc
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Pay an hour of gaming budget for a LeetCode solve
The budget was binary: 8h with a counted workout, 6h without. There was no
base/bonus structure anywhere -- resolve_budget_seconds returned one of two
absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that
appeared in no line of code. A second earner could not be bolted onto that
shape without coupling the two.

It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7
or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to
7h, and a day that earns nothing drops 6h to 5h.

The two earners are read independently and added. That is what "must not
interfere with the workout" means in code: an unreadable LeetCode ledger leaves
a workout day at 7h, not 5h, and there is a test asserting exactly that.

LeetCode is read from leetcode-guard's ledger directly -- it answers even when
leetcode-guard is not running, which is most of the day -- and falls back to its
loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces
gatelock's canonicalisation in eight lines because gatelock lives in the user's
site-packages and this daemon runs as root, and a pinned vector fails loudly if
that ever drifts.

Failing to get an answer grants nothing AND raises an incident: desktop
notification, warning log, and a line in leetcode_bonus_incidents.log
(gitignored, so it never becomes an uncommitted change), rate-limited to once
per reason per day. A silently missing hour looks exactly like an unearned one.

resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate
the second resolution seam this module's docstring warns about. rules_for calls
it once and carries the breakdown on PlaytimeRules, so /api/budget reports why
today is what it is without resolving again.

Known limitation, accepted: leetcode-guard only writes credits from its lock
window's poll loop, so a voluntary extra solve after the day is settled never
reaches the ledger.

Verified live against real services -- all four cases (5/6/7/8h), the endpoint
fallback, the fail-closed path with its notification, and the rate limit.
1782 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
2026-08-30 15:22:18 +02:00

97 lines
2.4 KiB
Python

"""Shared builders for the LeetCode ledger tests.
Split out of ``test_leetcode_ledger.py`` to keep every file inside the 250-line
cap. The ``key_file`` fixture is imported by name into each test module, which
is what registers it there.
"""
from __future__ import annotations
import hashlib
import hmac
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
LEDGER_PKG = "steam_backlog_enforcer._leetcode_ledger"
KEY = b"0123456789abcdef0123456789abcdef"
def sign(entry: dict[str, Any], key: bytes = KEY) -> dict[str, Any]:
"""Attach a genuine signature to an entry.
Args:
entry: The entry to sign.
key: The signing key.
Returns:
The same entry with an ``hmac`` field.
"""
payload = json.dumps(entry, sort_keys=True, separators=(",", ":"))
entry["hmac"] = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
return entry
def credit(*, when: datetime, entry_id: str = "ac:1") -> dict[str, Any]:
"""Build a signed credit entry submitted at *when*.
Args:
when: The submission time.
entry_id: The entry id, unique per credit.
Returns:
A signed credit entry.
"""
return sign(
{
"entry_id": entry_id,
"kind": "credit",
"day": when.date().isoformat(),
"created_at": when.isoformat(),
"amount": 1,
"device": "pc",
"detail": {
"source": "leetcode",
"submitted_at": str(int(when.timestamp())),
},
}
)
def write_ledger(path: Path, entries: list[dict[str, Any]]) -> Path:
"""Write a ledger file.
Args:
path: Directory to write into.
entries: The entries to store.
Returns:
The ledger path.
"""
ledger = path / "ledger.json"
ledger.write_text(json.dumps({"version": 1, "entries": entries}), encoding="utf-8")
return ledger
@pytest.fixture
def key_file(tmp_path: Path) -> Iterator[Path]:
"""Point the module at a throwaway signing key.
Args:
tmp_path: pytest's temporary directory.
Yields:
The key path, with the module patched to use it.
"""
path = tmp_path / "hmac.key"
path.write_bytes(KEY)
with patch(f"{LEDGER_PKG}.HMAC_KEY_FILE", path):
yield path