steam-backlog-enforcer/steam_backlog_enforcer/tests/test_leetcode_ledger.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

188 lines
6.3 KiB
Python

"""Tests for _leetcode_ledger: which entries count as a solve today.
The signature check is load-bearing. That module reproduces gatelock's
canonicalisation rather than importing it, because gatelock lives in the user's
site-packages and the enforcer runs as root. ``TestSignatureCanonicalisation``
pins it to a fixed vector so the duplication cannot drift silently -- if
leetcode-guard ever changes how it signs, that test fails instead of every
credit quietly ceasing to count.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from steam_backlog_enforcer._leetcode_ledger import (
_verified,
read_ledger_solved_today,
)
from steam_backlog_enforcer.tests._ledger_fixtures import (
KEY,
credit,
key_file,
sign,
write_ledger,
)
if TYPE_CHECKING:
from pathlib import Path
import pytest
__all__ = ["key_file"]
class TestSignatureCanonicalisation:
"""The duplicated eight lines must keep matching gatelock's."""
def test_a_known_vector_still_verifies(self) -> None:
"""Pinned so a change in how entries are signed fails loudly here."""
entry = {
"entry_id": "ac:1",
"kind": "credit",
"day": "2026-08-30",
"created_at": "2026-08-30T14:00:00+02:00",
"amount": 1,
"device": "pc",
"detail": {
"title_slug": "two-sum",
"lang": "python3",
"source": "leetcode",
"submitted_at": "1788090000",
},
"hmac": "4f6986bdb019bf28f607918ffb1b5bc79d252f887ade2e25aa83a31d1e49423e",
}
assert _verified(entry, KEY) is True
def test_a_tampered_entry_fails(self) -> None:
"""Changing any field must invalidate the signature."""
entry = credit(when=datetime.now().astimezone())
entry["amount"] = 99
assert _verified(entry, KEY) is False
def test_a_missing_signature_fails(self) -> None:
"""An entry with no hmac field is not a signed entry."""
assert _verified({"kind": "credit"}, KEY) is False
class TestReadingTheLedger:
"""Which entries count, and which are ignored."""
def test_a_credit_today_counts(self, tmp_path: Path, key_file: Path) -> None:
"""The ordinary case.
Args:
tmp_path: pytest's temporary directory.
key_file: The patched signing key.
"""
assert key_file.exists()
ledger = write_ledger(tmp_path, [credit(when=datetime.now().astimezone())])
assert read_ledger_solved_today(ledger) is True
def test_a_credit_yesterday_does_not(self, tmp_path: Path, key_file: Path) -> None:
"""Yesterday's solve bought yesterday's hour, not today's.
Args:
tmp_path: pytest's temporary directory.
key_file: The patched signing key.
"""
assert key_file.exists()
yesterday = datetime.now().astimezone() - timedelta(days=1)
ledger = write_ledger(tmp_path, [credit(when=yesterday)])
assert read_ledger_solved_today(ledger) is False
def test_charges_and_seen_entries_do_not_count(
self, tmp_path: Path, key_file: Path
) -> None:
"""A settled day is not a solved day, and seeding is worth nothing.
Args:
tmp_path: pytest's temporary directory.
key_file: The patched signing key.
"""
assert key_file.exists()
now = datetime.now().astimezone()
today = now.date().isoformat()
rows = [
sign(
{
"entry_id": f"charge:{today}",
"kind": "charge",
"day": today,
"created_at": now.isoformat(),
"amount": 2,
"device": "pc",
"detail": {"source": "escape"},
}
),
sign(
{
"entry_id": "ac:9",
"kind": "seen",
"day": today,
"created_at": now.isoformat(),
"amount": 0,
"device": "pc",
"detail": {"submitted_at": str(int(now.timestamp()))},
}
),
]
assert read_ledger_solved_today(write_ledger(tmp_path, rows)) is False
def test_a_forged_credit_does_not_count(
self, tmp_path: Path, key_file: Path
) -> None:
"""Appending JSON must not buy an hour.
Args:
tmp_path: pytest's temporary directory.
key_file: The patched signing key.
"""
assert key_file.exists()
forged = credit(when=datetime.now().astimezone())
forged["hmac"] = "00" * 32
assert read_ledger_solved_today(write_ledger(tmp_path, [forged])) is False
def test_a_missing_submitted_at_falls_back_to_the_day_key(
self, tmp_path: Path, key_file: Path
) -> None:
"""Older credits predate the field and must still count.
Args:
tmp_path: pytest's temporary directory.
key_file: The patched signing key.
"""
assert key_file.exists()
now = datetime.now().astimezone()
entry = sign(
{
"entry_id": "ac:2",
"kind": "credit",
"day": now.date().isoformat(),
"created_at": now.isoformat(),
"amount": 1,
"device": "pc",
"detail": {"source": "leetcode"},
}
)
assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is True
def test_an_unparsable_submitted_at_is_reported(
self, tmp_path: Path, key_file: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Corrupt data falls back rather than crashing, and says so.
Args:
tmp_path: pytest's temporary directory.
key_file: The patched signing key.
caplog: pytest's log capture.
"""
assert key_file.exists()
now = datetime.now().astimezone()
entry = credit(when=now, entry_id="ac:3")
entry["detail"]["submitted_at"] = "not-a-number"
entry = sign({k: v for k, v in entry.items() if k != "hmac"})
assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is True
assert "unparsable submitted_at" in caplog.text