import io import json import struct import zipfile from types import SimpleNamespace import pytest from fitdecode.utils import compute_crc from garmin_sync import __main__ as cli from garmin_sync.service import ( SyncError, account_key, complete, decode_fit, fit_payloads, sync_activities, ) def synthetic_fit(): """Tiny FIT with one timestamped heart-rate/distance record; no real user data.""" # Definition: local message 0, little endian, global record message 20. definition = ( bytes([0x40, 0, 0]) + struct.pack("Unauthorized", zipped(b"text", "notes.txt")] ) def test_bad_download_rejected(payload): with pytest.raises(SyncError): fit_payloads(payload) def test_download_decode_deduplicate_and_repair(tmp_path): api = FakeGarmin() first = sync_activities(api, tmp_path, 5, None) assert first["downloaded"] == 1 assert first["records"] == 1 assert complete(tmp_path / "1") parsed = json.loads((tmp_path / "1" / "activity-1.json").read_text()) assert parsed["record"][0]["timestamp"].endswith("+00:00") assert sync_activities(api, tmp_path, 5, None)["skipped"] == 1 assert api.downloads == 1 (tmp_path / "1" / "activity-1.fit").write_bytes(b"corrupt") assert sync_activities(api, tmp_path, 5, None)["downloaded"] == 1 assert api.downloads == 2 def test_pagination_and_filter(tmp_path): api = FakeGarmin(count=53) result = sync_activities(api, tmp_path, 52, "running") assert result["downloaded"] == 52 assert api.requests == [(0, 50, "running"), (50, 2, "running")] def test_partial_failure_has_no_completed_manifest(tmp_path): result = sync_activities(FakeGarmin(payload=b"bad"), tmp_path, 5, None) assert result["failed"] == 1 assert not complete(tmp_path / "1") def test_invalid_activity_id_cannot_escape_directory(tmp_path): api = FakeGarmin() api.activities = [{"activityId": "../escape"}] assert sync_activities(api, tmp_path, 5, None)["failed"] == 1 assert api.downloads == 0 def test_account_isolation_and_stable_provider_id(): a = SimpleNamespace(display_name="one", profile_id=1) b = SimpleNamespace(display_name="two", profile_id=2) assert account_key(a, "garmin.com") != account_key(b, "garmin.com") a.display_name = "renamed" assert account_key(a, "garmin.com") == account_key( SimpleNamespace(display_name="original", profile_id=1), "garmin.com" ) def test_missing_session_requires_local_login(tmp_path, monkeypatch): monkeypatch.setattr(cli, "ROOT", tmp_path) with pytest.raises(SyncError, match="No saved session"): cli.run(cli.parser().parse_args(["sync"])) def test_profile_cannot_escape(tmp_path, monkeypatch): monkeypatch.setattr(cli, "ROOT", tmp_path) with pytest.raises(SyncError, match="Profile"): cli.run(cli.parser().parse_args(["--profile", "../outside", "verify"])) def test_cli_never_prints_raw_provider_exception(monkeypatch, capsys): def fail(_args): raise RuntimeError("SECRET_TOKEN_AND_PASSWORD") monkeypatch.setattr(cli, "run", fail) monkeypatch.setattr("sys.argv", ["garmin_sync", "verify"]) assert cli.main() == 1 captured = capsys.readouterr() assert "SECRET_TOKEN" not in captured.err + captured.out def test_saved_session_sync_persists_rotated_credentials_even_on_failure(tmp_path, monkeypatch): store = tmp_path / "instance" / "garmin" / "default" store.mkdir(parents=True) token = store / "garmin_tokens.json" token.write_text('{"di_token":"old"}') api = FakeGarmin() api.client = SimpleNamespace(dumps=lambda: '{"di_token":"new"}') api.login = lambda path: None monkeypatch.setattr(cli, "ROOT", tmp_path) monkeypatch.setattr(cli, "Garmin", lambda **kwargs: api) def fail(*args): raise SyncError("download failed") monkeypatch.setattr(cli, "sync_activities", fail) with pytest.raises(SyncError, match="download failed"): cli.run(cli.parser().parse_args(["sync"])) assert json.loads(token.read_text())["di_token"] == "new"