From 2c5fee24be1870080bef36c22cf0a010981ce167 Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:08:30 +0300 Subject: [PATCH] Fix Garmin pruning after resumed backfills --- scripts/sync_garmin.py | 5 +- tests/test_sync_garmin_pruning.py | 119 ++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/test_sync_garmin_pruning.py diff --git a/scripts/sync_garmin.py b/scripts/sync_garmin.py index 3207ae5424..d126c71e70 100644 --- a/scripts/sync_garmin.py +++ b/scripts/sync_garmin.py @@ -631,6 +631,7 @@ def sync_garmin(dry_run: bool, prune_deleted: bool) -> Dict[str, Any]: next_offset = _safe_int(state.get("next_offset")) if state else None if next_offset is None: next_offset = 0 + used_resume_cursor = next_offset != 0 if not rate_limited and not skip_backfill: offset = next_offset @@ -675,6 +676,7 @@ def sync_garmin(dry_run: bool, prune_deleted: bool) -> Dict[str, Any]: prune_deleted and not dry_run and not skip_backfill + and not used_resume_cursor and exhausted and not rate_limited ) @@ -689,7 +691,8 @@ def sync_garmin(dry_run: bool, prune_deleted: bool) -> Dict[str, Any]: deleted += 1 elif prune_deleted and not dry_run: print( - "Skipping prune_deleted for Garmin: pruning requires a full backfill scan in this run." + "Skipping prune_deleted for Garmin: pruning requires a full backfill scan in this run " + "(no resume cursor, no rate-limit)." ) completed = True if skip_backfill else (exhausted and not rate_limited) diff --git a/tests/test_sync_garmin_pruning.py b/tests/test_sync_garmin_pruning.py new file mode 100644 index 0000000000..19a957aeb5 --- /dev/null +++ b/tests/test_sync_garmin_pruning.py @@ -0,0 +1,119 @@ +import os +import sys +import tempfile +import types +import unittest +from types import SimpleNamespace +from unittest import mock + + +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SCRIPTS_DIR = os.path.join(ROOT_DIR, "scripts") +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +yaml_stub = types.ModuleType("yaml") +yaml_stub.safe_load = lambda *_args, **_kwargs: {} +sys.modules.setdefault("yaml", yaml_stub) + +import sync_garmin # noqa: E402 + + +class SyncGarminPruningTests(unittest.TestCase): + def setUp(self) -> None: + tmpdir = self.enterContext(tempfile.TemporaryDirectory()) + self.addCleanup(os.chdir, os.getcwd()) + os.chdir(tmpdir) + self.config = {"sync": {"per_page": 2, "recent_days": 0}} + self.client = SimpleNamespace(get_activities=mock.Mock()) + self.enterContext(mock.patch("sync_garmin.load_config", return_value=self.config)) + self.enterContext(mock.patch("sync_garmin._load_garmin_client", return_value=self.client)) + os.makedirs(sync_garmin.RAW_DIR) + + @staticmethod + def _activity(activity_id: int) -> dict: + return { + "activityId": activity_id, + "startTimeGMT": f"2026-01-{activity_id:02d}T12:00:00Z", + "activityType": {"typeKey": "running"}, + "duration": 600, + } + + def _cached_ids(self) -> set: + return {name[:-5] for name in os.listdir(sync_garmin.RAW_DIR) if name.endswith(".json")} + + def test_resumed_backfill_preserves_activities_from_previous_pages(self) -> None: + self.client.get_activities.side_effect = [ + [self._activity(3), self._activity(2)], + RuntimeError("429 Too Many Requests"), + ] + first = sync_garmin.sync_garmin(dry_run=False, prune_deleted=True) + self.assertTrue(first["rate_limited"]) + self.assertEqual(first["backfill_next_offset"], 2) + self.assertEqual(self._cached_ids(), {"2", "3"}) + + self.client.get_activities.reset_mock() + self.client.get_activities.side_effect = [[self._activity(1)]] + resumed = sync_garmin.sync_garmin(dry_run=False, prune_deleted=True) + + self.client.get_activities.assert_called_once_with(2, 2) + self.assertTrue(resumed["backfill_completed"]) + self.assertEqual(self._cached_ids(), {"1", "2", "3"}) + self.assertEqual(resumed["deleted"], 0) + + def test_empty_resumed_page_preserves_cached_activities(self) -> None: + sync_garmin._write_activity(sync_garmin._normalize_activity(self._activity(2))) + sync_garmin._save_state({ + "after": 0, + "activity_scope": sync_garmin._activity_scope(self.config), + "next_offset": 2, + "completed": False, + }) + self.client.get_activities.return_value = [] + + summary = sync_garmin.sync_garmin(dry_run=False, prune_deleted=True) + + self.client.get_activities.assert_called_once_with(2, 2) + self.assertTrue(summary["backfill_completed"]) + self.assertEqual(self._cached_ids(), {"2"}) + self.assertEqual(summary["deleted"], 0) + + def test_full_scan_still_prunes_when_starting_from_zero(self) -> None: + for saved_offset in (None, 0): + with self.subTest(saved_offset=saved_offset): + sync_garmin._write_activity(sync_garmin._normalize_activity(self._activity(2))) + sync_garmin._save_state({ + "after": 0, + "activity_scope": sync_garmin._activity_scope(self.config), + "next_offset": saved_offset, + "completed": False, + }) + self.client.get_activities.reset_mock() + self.client.get_activities.side_effect = [[self._activity(1)]] + + summary = sync_garmin.sync_garmin(dry_run=False, prune_deleted=True) + + self.client.get_activities.assert_called_once_with(0, 2) + self.assertTrue(summary["backfill_completed"]) + self.assertEqual(self._cached_ids(), {"1"}) + self.assertEqual(summary["deleted"], 1) + + def test_changed_boundary_restarts_full_scan_and_allows_pruning(self) -> None: + sync_garmin._write_activity(sync_garmin._normalize_activity(self._activity(2))) + sync_garmin._save_state({ + "after": 1, + "activity_scope": sync_garmin._activity_scope(self.config), + "next_offset": 2, + "completed": False, + }) + self.client.get_activities.side_effect = [[self._activity(1)]] + + summary = sync_garmin.sync_garmin(dry_run=False, prune_deleted=True) + + self.client.get_activities.assert_called_once_with(0, 2) + self.assertEqual(self._cached_ids(), {"1"}) + self.assertEqual(summary["deleted"], 1) + + +if __name__ == "__main__": + unittest.main()