diet-guard/diet_guard/tests/test_sync_events.py
Krzysztof kuhy Rudnicki 8871408b5b perf(cli): publish off-thread so logging a meal returns immediately
`diet-guard log ...` blocked for ~15.5s after every meal. The publish itself
has to happen -- until it lands the phone still believes the slot is unlogged
and nags for it -- but only ~2.5s of that is the 293KB push the phone needs;
the rest is pulling peers' banks, which publishing does not require.

The local write is already durable before the publish starts, so the worst
case is a late publish, which is the trade `_sync_events` already documents
for the event-driven model. Moving it to a worker thread lets the caller
print and return at once. Measured: 15.5s -> 2ms before the prompt comes back.

The thread is deliberately non-daemon: the CLI's output is already flushed,
so the process lingers only as long as the push, and the publish is never
killed halfway. A failure still surfaces, via a callback the caller supplies,
just after its own output rather than in the middle of it.

Deliberately NOT applied to the gate. `diet-guard-gate.service` is
`Type=oneshot`, so systemd reaps the unit the moment the main thread exits
and a background publish would be killed mid-push. The gate keeps calling
`publish_after_log` and waiting, which is correct there -- its publish
already runs after the window is gone, where nobody is waiting on it.

`conftest._isolate_state` follows the rename to
`publish_after_log_detached`; without that every meal-logging test would
reach the network through a newly unpatched seam. Verified with
`scripts/check_patch_targets.py` and by timing the real command against a
temporary data dir, never the live log.

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

97 lines
3.3 KiB
Python

"""Tests for the event-driven post-log sync trigger.
These exercise the real :func:`publish_after_log`, not the conftest stub: the
autouse fixture patches it at each *call site*, so importing it here still
reaches the genuine helper (see ``conftest._isolate_state``).
"""
from __future__ import annotations
import logging
import threading
from typing import TYPE_CHECKING
from unittest.mock import patch
from diet_guard import _sync_events
from diet_guard._sync_events import publish_after_log
if TYPE_CHECKING:
import pytest
class TestPublishAfterLog:
"""A full tick fired by a local write, which must never raise."""
def test_success_returns_none(self) -> None:
"""A completed tick reports no reason."""
with patch.object(
_sync_events,
"pull_shared_log",
return_value=None,
) as pull:
assert publish_after_log() is None
pull.assert_called_once_with()
def test_outage_returns_reason(self) -> None:
"""A failed tick surfaces the reason instead of raising."""
with patch.object(
_sync_events,
"pull_shared_log",
return_value="sync unavailable (no token)",
):
assert publish_after_log() == "sync unavailable (no token)"
def test_outage_is_logged(self, caplog: pytest.LogCaptureFixture) -> None:
"""The reason reaches the log for callers with nowhere to show it."""
with (
patch.object(
_sync_events,
"pull_shared_log",
return_value="sync unavailable (boom)",
),
caplog.at_level(logging.INFO, logger="diet_guard._sync_events"),
):
publish_after_log()
assert "boom" in caplog.text
class TestPublishAfterLogDetached:
"""The CLI's non-blocking publish."""
def test_runs_the_publish_off_thread(self) -> None:
"""The caller must not wait ~15.5s for the tick to finish."""
done = threading.Event()
def _publish() -> None:
done.set()
with patch.object(_sync_events, "publish_after_log", side_effect=_publish):
_sync_events.publish_after_log_detached(lambda _reason: None)
assert done.wait(timeout=5), "publish never ran"
def test_reports_a_failure_through_the_callback(self) -> None:
"""A publish outage still surfaces, just after the caller returned."""
seen: list[str] = []
finished = threading.Event()
def _record(reason: str) -> None:
seen.append(reason)
finished.set()
with patch.object(_sync_events, "publish_after_log", return_value="no token"):
_sync_events.publish_after_log_detached(_record)
assert finished.wait(timeout=5), "callback never fired"
assert seen == ["no token"]
def test_stays_quiet_on_success(self) -> None:
"""Nothing to report means the callback is never called."""
calls: list[str] = []
with patch.object(_sync_events, "publish_after_log", return_value=None):
_sync_events.publish_after_log_detached(calls.append)
for thread in threading.enumerate():
if thread is not threading.current_thread():
thread.join(timeout=5)
assert calls == []