mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 15:53:53 +01:00
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
188 lines
6.3 KiB
Python
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
|