steam-backlog-enforcer/steam_backlog_enforcer/tests/test_research_open.py
Krzysztof kuhy Rudnicki 87c6f200e3 Research window: keep Steam dir, extend once
kuhy 2026-11-11, multi-day research without re-setup.

W1: once a window was used in a lock, the block's purge spares
~/.local/share/Steam (Librarian's install and local saves) for the
rest of the lock; ~/steam, ~/.steam and the rest still go. The
package stays removed, hosts/iptables are re-applied, and since the
kept install could otherwise be launched without the package, every
Steam game is killed on sight. A record that cannot be read also
keeps the folder (an irreversible delete never rests on unknown
state); the block itself stays fully applied.

W2: the 48 h cap is gone; --until is bounded by the lock's end. An
open window may be extended ONCE, to the lock end at most, never
shortened, never reopened after it ended. The extension is a
separate extended_until field beside until, so a daemon still on
the previous code reads the original window instead of rejecting the
record as corrupt (which would have purged the folder).

Failure modes unchanged: missing/corrupt record = no window, and a
corrupt one refuses a new window or extension.

Checked: 3325 tests at 100% coverage; pylint 10/10 in parts; vmbox:
extension accepted once, refused beyond the lock end, earlier or
equal, second time and non-root; extended end honoured past the
original end; at close the Steam dir and a save survive, ~/steam and
~/.steam go, a game started after close is killed; extension to
exactly the lock end accepted. Not verified on the host.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWWzuCEg1m87jpKyRrw
2026-11-11 16:28:43 +01:00

235 lines
9.2 KiB
Python

"""Opening a research window: up to the lock end, one extension, fail closed."""
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime
import os
import time
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from steam_backlog_enforcer import _research_record as store
from steam_backlog_enforcer import _research_window as window
from steam_backlog_enforcer.tests._research_helpers import (
HOUR,
LOCK_END,
LOCK_START,
NOW,
lock,
make_record,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from steam_backlog_enforcer._research_record import ResearchRecord
from steam_backlog_enforcer.tests._total_block_paths import Paths
NOW_PATCH = "steam_backlog_enforcer._research_window.now"
def _at(offset: float) -> datetime:
"""The instant NOW + *offset* seconds, timezone-aware."""
return datetime.fromtimestamp(NOW + offset, tz=UTC)
def _open(offset: float) -> ResearchRecord:
with patch(NOW_PATCH, return_value=NOW):
return window.open_research_window(_at(offset))
@pytest.fixture(name="live")
def _live(total_block_paths: Paths) -> None:
"""An active 6-day lock, two days in."""
lock(total_block_paths)
@pytest.mark.usefixtures("live")
class TestOpen:
"""The happy path and the bounds around it."""
def test_records_the_window_for_this_lock(self) -> None:
"""Test that a valid expiry is stored with the lock's identity."""
record = _open(10 * HOUR)
assert record == window.read_record()
assert (record.lock_started_at, record.lock_until) == (LOCK_START, LOCK_END)
assert (record.opened_at, record.until) == (NOW, NOW + 10 * HOUR)
assert (record.closed, record.store_reblocked) == (False, False)
def test_up_to_the_lock_end_is_allowed(self) -> None:
"""Test that the only cap is the end of the lock (no 48 h limit)."""
record = _open(LOCK_END - NOW)
assert record.until == LOCK_END
assert record.until - record.opened_at > 48 * HOUR
def test_one_second_past_the_lock_end_is_refused(self) -> None:
"""Test that the lock's end is a hard ceiling and nothing is written."""
with pytest.raises(window.ResearchRefusedError, match="end of the total"):
_open(LOCK_END - NOW + 1)
assert window.read_record() is None
@pytest.mark.parametrize("offset", [0.0, -1.0, -3 * HOUR])
def test_not_in_the_future_is_refused(self, offset: float) -> None:
"""Test that now and the past are refused."""
with pytest.raises(window.ResearchRefusedError, match="future"):
_open(offset)
assert window.read_record() is None
def test_window_may_not_outlive_the_lock(self) -> None:
"""Test that an expiry after the lock end is refused."""
with (
patch(NOW_PATCH, return_value=LOCK_END - HOUR),
pytest.raises(window.ResearchRefusedError, match="lock"),
):
window.open_research_window(datetime.fromtimestamp(LOCK_END + 1, tz=UTC))
assert window.read_record() is None
def test_write_failure_is_a_refusal(self) -> None:
"""Test that an unwritable state dir is reported, not a traceback."""
with (
patch.object(window, "write_record", side_effect=OSError("ro")),
pytest.raises(window.ResearchRefusedError, match="write"),
):
_open(HOUR)
class TestNoLock:
"""Without an active total block there is nothing to research."""
def test_no_lock_file(self) -> None:
"""Test that the command has no meaning without a block."""
with pytest.raises(window.ResearchRefusedError, match="total block"):
_open(HOUR)
def test_expired_lock(self, total_block_paths: Paths) -> None:
"""Test that a lock that already ended cannot host a window."""
lock(total_block_paths)
with (
patch(NOW_PATCH, return_value=LOCK_END + 1),
pytest.raises(window.ResearchRefusedError, match="total block"),
):
window.open_research_window(datetime.fromtimestamp(LOCK_END + 2, UTC))
@pytest.mark.usefixtures("live")
class TestOncePerLock:
"""One window per lock; the open one may be extended once, never more."""
def test_extension_moves_only_the_deadline(self) -> None:
"""Test that a later expiry extends an open window, nothing else."""
first = _open(2 * HOUR)
extended = _open(40 * HOUR)
assert extended == replace(first, extended_until=NOW + 40 * HOUR)
assert window.read_record() == extended
def test_extension_up_to_the_lock_end(self) -> None:
"""Test that the extension may reach the lock end exactly."""
_open(2 * HOUR)
assert _open(LOCK_END - NOW).extended_until == LOCK_END
def test_extension_past_the_lock_end_is_refused(self) -> None:
"""Test that no extension goes beyond the lock, even by a second."""
first = _open(2 * HOUR)
with pytest.raises(window.ResearchRefusedError, match="end of the total"):
_open(LOCK_END - NOW + 1)
assert window.read_record() == first
@pytest.mark.parametrize("offset", [2 * HOUR, 2 * HOUR - 1, HOUR, 60.0])
def test_same_or_earlier_expiry_is_refused(self, offset: float) -> None:
"""Test that an extension can only make the window longer."""
first = _open(2 * HOUR)
before = store.RESEARCH_FILE.read_bytes()
with pytest.raises(window.ResearchRefusedError, match="later"):
_open(offset)
assert store.RESEARCH_FILE.read_bytes() == before
assert window.read_record() == first
def test_only_one_extension(self) -> None:
"""Test that the second extension is refused and changes nothing."""
_open(2 * HOUR)
_open(40 * HOUR)
before = store.RESEARCH_FILE.read_bytes()
with pytest.raises(window.ResearchRefusedError, match="once"):
_open(60 * HOUR)
assert store.RESEARCH_FILE.read_bytes() == before
def test_an_ended_window_cannot_be_extended(self) -> None:
"""Test that extending after the deadline would be a second window."""
_open(2 * HOUR)
with (
patch(NOW_PATCH, return_value=NOW + 3 * HOUR),
pytest.raises(window.ResearchRefusedError, match="already"),
):
window.open_research_window(_at(40 * HOUR))
def test_extension_write_failure_is_a_refusal(self) -> None:
"""Test that a failing write on extension is reported."""
first = _open(2 * HOUR)
with (
patch.object(window, "write_record", side_effect=OSError("ro")),
pytest.raises(window.ResearchRefusedError, match="write"),
):
_open(40 * HOUR)
assert window.read_record() == first
def test_used_window_stays_used_after_it_closed(self) -> None:
"""Test that an expired, latched window is not a free second window."""
window.write_record(make_record(closed=True, store_reblocked=True))
with pytest.raises(window.ResearchRefusedError, match="already"):
_open(HOUR)
def test_a_new_lock_gets_its_own_window(self, total_block_paths: Paths) -> None:
"""Test that last lock's record does not burn the next lock's window."""
window.write_record(make_record(lock_started_at=LOCK_START - 7 * 86400))
lock(total_block_paths)
assert _open(HOUR).lock_started_at == LOCK_START
def test_corrupt_state_refuses_instead_of_resetting(self) -> None:
"""Test that corruption cannot be used to get a second window."""
store.RESEARCH_FILE.parent.mkdir(parents=True)
store.RESEARCH_FILE.write_text("{oops", encoding="utf-8")
with pytest.raises(window.ResearchRefusedError, match="unreadable"):
_open(HOUR)
assert store.RESEARCH_FILE.read_text(encoding="utf-8") == "{oops"
@pytest.fixture(name="warsaw")
def _warsaw(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Run the test in Europe/Warsaw local time, restoring the zone after."""
monkeypatch.setenv("TZ", "Europe/Warsaw")
time.tzset()
yield
monkeypatch.undo()
time.tzset()
class TestParseUntil:
"""--until is local time unless it carries an offset."""
@pytest.mark.usefixtures("warsaw")
def test_naive_means_local_time(self) -> None:
"""Test that 2026-11-13T00:00 is midnight in the local zone."""
parsed = window.parse_until("2026-11-13T00:00")
assert parsed == datetime(2026, 11, 12, 23, 0, tzinfo=UTC)
assert os.environ["TZ"] == "Europe/Warsaw"
def test_offset_is_honoured(self) -> None:
"""Test that an explicit offset is not reinterpreted."""
parsed = window.parse_until("2026-11-13T00:00:00+02:00")
assert parsed == datetime(2026, 11, 12, 22, 0, tzinfo=UTC)
def test_z_suffix(self) -> None:
"""Test that Z means UTC."""
assert window.parse_until("2026-11-13T00:00:00Z") == datetime(
2026, 11, 13, tzinfo=UTC
)
@pytest.mark.parametrize("raw", ["", "tomorrow", "2026-13-40", "13:00"])
def test_garbage_is_a_value_error(self, raw: str) -> None:
"""Test that an unparsable expiry is rejected."""
with pytest.raises(ValueError, match="until"):
window.parse_until(raw)