screen-locker/screen_locker/tests/test_workout_sync_part4.py
Krzysztof kuhy Rudnicki 965b9c4ebb Sync: Firebase only — drop the GitHub transport (kuhyx/syncs stays a frozen archive)
The PC push aborted before it ever tried Firebase whenever the GitHub PAT
was missing ("Workouts NOT synced: no sync token"), so once the PAT was lost
this machine published nothing although Firebase was configured and live.
The phone has not written the GitHub repo since 2026-08-21.

- sync_client() / sync_client_or_reason(): firebase_client_for("screen_locker")
  only, proven with can_access_remote(), healed once from a sibling app's
  session, else a warning naming what will not sync (push AND pull) plus a
  recorded degraded source. No config -> warning, no fallback.
- _manual_push and scripts/tombstone_sync_record.py take their client from
  sync_client_or_reason(); the push result carries the reason.
- Deleted: read_sync_token, remote_client, the mirror client, _push_outcome
  (the half-landed-push case no longer exists), SYNC_TOKEN_FILE and
  SYNC_REPO_OWNER/NAME, the sync_token test fixture. Status backend is
  "firebase" or "none".
- crdt-sync pinned to v0.9.2 in pyproject, requirements.txt and
  install_systemd.sh (the installer still said v0.9.0).

kuhyx/syncs/screen-locker-sync is left untouched as an archive; no code
reads it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:18:56 +02:00

174 lines
6.1 KiB
Python

"""Tests for the phone-workout sync pull (crdt-sync transport).
Covers the payload predicates, the per-log record extractors and the
cross-device merge. The backend selection (``sync_client``) and the two
public pulls live in ``_part2``.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
from crdt_sync import GitHubSyncError
import pytest
from screen_locker import _workout_sync
from screen_locker.tests._workout_sync_fixtures import (
_multi_device_client,
_session_payload,
_session_record_dict,
)
class TestMergeDeviceRecords:
"""The walk over EVERY ``devices/<id>/log.json``, not one hardcoded path."""
def test_merges_records_across_every_device(self) -> None:
"""Merges records across every device."""
client = _multi_device_client(
{
"phone": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"pc": json.dumps(
{"b": _session_record_dict("session:b", _session_payload())}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert sorted(merged) == ["session:a", "session:b"]
def test_dedups_the_same_id_keeping_the_highest_clock(self) -> None:
"""Dedups the same ID keeping the highest clock."""
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="OLD"),
wall_time_ms=100,
)
}
),
"pc": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="NEW"),
wall_time_ms=200,
)
}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert len(merged) == 1
assert merged["session:a"][0]["note"] == "NEW"
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
"""Ignores a lower clock duplicate seen later."""
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="NEW"),
wall_time_ms=200,
)
}
),
"pc": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="OLD"),
wall_time_ms=100,
)
}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert merged["session:a"][0]["note"] == "NEW"
def test_skips_a_corrupt_device_log(self) -> None:
"""Skips a corrupt device log."""
client = _multi_device_client(
{
"good": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"corrupt": "{not json",
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert list(merged) == ["session:a"]
def test_skips_an_unreachable_device(self) -> None:
"""Skips an unreachable device."""
client = _multi_device_client(
{
"good": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"gone": GitHubSyncError("404"),
"empty": None,
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert list(merged) == ["session:a"]
def test_returns_empty_on_a_listing_failure_when_not_strict(self) -> None:
"""Returns empty on a listing failure when not strict."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert merged == {}
def test_reraises_on_a_listing_failure_when_strict(self) -> None:
"""Callers separating "nothing to sync" from "sync broke" need the raise."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with pytest.raises(GitHubSyncError, match="offline"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions", strict=True
)
def test_warns_about_a_listing_failure(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""A total pull failure must never be silent."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with caplog.at_level("WARNING"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert "offline" in caplog.text
def test_warns_about_a_skipped_device(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Warns about a skipped device."""
client = _multi_device_client({"gone": GitHubSyncError("404")})
with caplog.at_level("WARNING"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert "SKIPPING that device" in caplog.text