screen-locker/screen_locker/tests/test_stream_cache.py
2026-10-09 19:05:51 +02:00

170 lines
6.1 KiB
Python

"""Tests for _stream_cache: merge, tombstones, own-device rule and crediting."""
from __future__ import annotations
import json
import logging
import threading
from typing import TYPE_CHECKING
from crdt_sync import Hlc, Record
import pytest
from screen_locker import _stream_cache
from screen_locker._poke_credit import BatchOutcome
from screen_locker._rtdb_stream import SseEvent
from screen_locker._stream_cache import DeviceLogCache
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
_LEAF = "log~2Ejson"
OWN = "pc-own"
_PAYLOAD = {"exercises": [], "date": "2026-10-09"}
def log_blob(
rid: str, *, wall: int = 1, deleted: bool = False, payload: object = None
) -> str:
"""One device-log blob holding a single (possibly tombstoned) record."""
hlc = Hlc(wall, 0, "n")
record = Record(
rid,
{"payload": (_PAYLOAD if payload is None else payload, hlc)},
deleted=deleted,
deleted_hlc=hlc if deleted else None,
)
return json.dumps({rid: record.to_dict()})
def snapshot(logs: Mapping[str, str]) -> SseEvent:
"""A ``put /`` carrying every device log."""
data = {dev: {_LEAF: text} for dev, text in logs.items()}
return SseEvent("put", json.dumps({"path": "/", "data": data}))
def patch_event(logs: Mapping[str, str | None]) -> SseEvent:
"""A ``patch /`` rewriting some device logs (``None`` deletes one)."""
data = {f"{dev}/{_LEAF}": text for dev, text in logs.items()}
return SseEvent("patch", json.dumps({"path": "/", "data": data}))
class Credit:
"""Stand-in for ``credit_sessions`` that records every batch it is handed."""
def __init__(self) -> None:
self.batches: list[list[str]] = []
self.error: Exception | None = None
self.gate_held: list[bool] = []
self.gate: threading.Lock | None = None
def __call__(
self, records: Iterable[tuple[str, Mapping[str, object]]]
) -> BatchOutcome:
if self.gate is not None:
self.gate_held.append(self.gate.locked())
if self.error is not None:
raise self.error
ids = [rid for rid, _payload in records]
self.batches.append(ids)
return BatchOutcome(ingested=tuple(ids), credited=len(ids))
@pytest.fixture
def credit(monkeypatch: pytest.MonkeyPatch) -> Credit:
"""Replace the real (locker-building) credit step with a recorder."""
fake = Credit()
monkeypatch.setattr(_stream_cache._poke_credit, "credit_sessions", fake)
return fake
@pytest.fixture
def cache(credit: Credit) -> DeviceLogCache:
"""A cache whose own device dir is ``pc-own``."""
gate = threading.Lock()
credit.gate = gate
return DeviceLogCache("sync/devices", frozenset({OWN}), gate)
class TestCredit:
"""Which events credit, and how."""
def test_snapshot_credits_foreign_sessions_under_the_gate(
self, cache: DeviceLogCache, credit: Credit
) -> None:
"""A phone session in the snapshot is credited while the lock is held."""
result = cache.handle(snapshot({"phone": log_blob("s1")}))
assert result.snapshot is True
assert credit.batches == [["s1"]]
assert credit.gate_held == [True]
assert (
"1 session(s) checked, 1 newly logged ['s1'], 1 credited" in result.summary
)
def test_patch_credits_a_new_session(
self, cache: DeviceLogCache, credit: Credit
) -> None:
"""A later patch for the phone credits only what is new."""
cache.handle(snapshot({"phone": log_blob("s1")}))
result = cache.handle(patch_event({"phone": log_blob("s2")}))
assert result.snapshot is False
assert credit.batches == [["s1"], ["s2"]]
def test_already_handed_sessions_are_a_no_op(
self, cache: DeviceLogCache, credit: Credit
) -> None:
"""Re-streaming the same snapshot costs no second credit call."""
cache.handle(snapshot({"phone": log_blob("s1")}))
result = cache.handle(snapshot({"phone": log_blob("s1")}))
assert credit.batches == [["s1"]]
assert "no-op, no file I/O" in result.summary
assert result.snapshot is True
def test_a_newer_hlc_of_the_same_record_is_credited_again(
self, cache: DeviceLogCache, credit: Credit
) -> None:
"""The seen-set is keyed on (id, hlc): an edit is a new thing to check."""
cache.handle(snapshot({"phone": log_blob("s1", wall=1)}))
cache.handle(patch_event({"phone": log_blob("s1", wall=2)}))
assert credit.batches == [["s1"], ["s1"]]
def test_non_session_records_are_not_credited(
self, cache: DeviceLogCache, credit: Credit
) -> None:
"""A record without ``exercises`` (e.g. runnerup_verified) is skipped."""
cache.handle(snapshot({"phone": log_blob("v1", payload={"kind": "x"})}))
assert credit.batches == []
class TestFailures:
"""Nothing is swallowed silently; nothing is marked seen on failure."""
def test_credit_failure_is_logged_and_retried_next_event(
self,
cache: DeviceLogCache,
credit: Credit,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A crediting error logs at ERROR, keeps the stream, retries later."""
credit.error = RuntimeError("disk full")
with caplog.at_level(logging.WARNING):
result = cache.handle(snapshot({"phone": log_blob("s1")}))
assert result.summary == "crediting failed (see above)"
assert result.snapshot is True
failures = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert failures
assert failures[0].exc_info is not None
assert "Crediting 1 streamed session(s) failed" in failures[0].getMessage()
credit.error = None
cache.handle(snapshot({"phone": log_blob("s1")}))
assert credit.batches == [["s1"]]
def test_bad_json_propagates_for_the_caller_to_warn(
self, cache: DeviceLogCache
) -> None:
"""Undecodable events are the connection's to report, not ours to eat."""
with pytest.raises(json.JSONDecodeError):
cache.handle(SseEvent("put", "{nope"))
with pytest.raises(TypeError, match="without a path"):
cache.handle(SseEvent("put", "[]"))