""" Unit tests for the Garmin sync service. A stub client stands in for `garminconnect`, so the suite runs without the library, without credentials and without touching Garmin. The stub mirrors the real 0.2.8 API shapes on purpose — the original code called `get_activities(date)` when that method actually takes `(start, limit)` pagination arguments, a mistake that only surfaces once something exercises it. """ import datetime import pytest from services import garmin as garmin_svc from services import health as health_svc def day(offset=0): return (datetime.date.today() - datetime.timedelta(days=offset)).isoformat() def summary(steps=8000, rhr=60, stress=40, kcal=2200): return { "totalSteps": steps, "restingHeartRate": rhr, "averageStressLevel": stress, "totalKilocalories": kcal, } def sleep(hours=7.5, score=82): return { "dailySleepDTO": { "sleepTimeSeconds": int(hours * 3600), "sleepScores": {"overall": {"value": score}}, } } def hrv(value=48): return {"hrvSummary": {"lastNightAvg": value}} def activity(activity_id=1001, type_key="running", duration=1800): return { "activityId": activity_id, "activityType": {"typeKey": type_key}, "startTimeLocal": f"{day()}T07:00:00", "duration": duration, "distance": 5000.0, "calories": 320.0, "averageHR": 145, "maxHR": 168, } class StubClient: """Stands in for garminconnect.Garmin, recording how it was called.""" def __init__(self, summaries=None, sleeps=None, hrvs=None, activities=None, fail_days=(), fail_activities=False): self._summaries = summaries if summaries is not None else {} self._sleeps = sleeps if sleeps is not None else {} self._hrvs = hrvs if hrvs is not None else {} self._activities = activities if activities is not None else [] self._fail_days = set(fail_days) self._fail_activities = fail_activities self.calls = [] def get_user_summary(self, cdate): self.calls.append(("summary", cdate)) if cdate in self._fail_days: raise RuntimeError(f"upstream error for {cdate}") return self._summaries.get(cdate, summary()) def get_sleep_data(self, cdate): self.calls.append(("sleep", cdate)) return self._sleeps.get(cdate, sleep()) def get_hrv_data(self, cdate): self.calls.append(("hrv", cdate)) return self._hrvs.get(cdate, hrv()) def get_activities_by_date(self, startdate, enddate, activitytype=None): self.calls.append(("activities", startdate, enddate)) if self._fail_activities: raise RuntimeError("activities endpoint down") return self._activities CREDS = {"garminEmail": "g@example.com", "garminPassword": "pw"} class TestHappyPath: def test_reports_success(self, db, user): out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient()) assert out["status"] == "success" assert out["recordsSynced"] == 3 def test_stores_the_days(self, db, user): garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient()) rows = health_svc.get_summary(user["id"]) assert len(rows) == 3 def test_maps_every_metric(self, db, user): client = StubClient( summaries={day(): summary(steps=9500, rhr=57, stress=33, kcal=2450)}, sleeps={day(): sleep(hours=8.0, score=91)}, hrvs={day(): hrv(52)}, ) garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) row = health_svc.get_summary(user["id"])[0] assert row["steps"] == 9500 assert row["heartRate"] == 57 assert row["stress"] == 33 assert row["caloriesBurned"] == 2450 assert row["heartRateVariability"] == 52 assert row["sleep"]["duration"] == 8.0 assert row["sleep"]["quality"] == 91 def test_sleep_and_hrv_come_from_their_own_endpoints(self, db, user): """Regression: both live outside get_user_summary. Reading only the summary recorded every night as having no sleep data.""" client = StubClient() garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) kinds = {c[0] for c in client.calls} assert "sleep" in kinds assert "hrv" in kinds def test_seconds_are_converted_to_hours(self, db, user): client = StubClient(sleeps={day(): sleep(hours=6.5)}) garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) assert health_svc.get_summary(user["id"])[0]["sleep"]["duration"] == 6.5 class TestActivities: def test_fetched_by_date_range_in_one_call(self, db, user): """Regression: the old code called get_activities(date), but that method takes (start, limit) pagination arguments, not a date.""" client = StubClient(activities=[activity()]) garmin_svc.sync_data(user["id"], CREDS, days=7, client=client) activity_calls = [c for c in client.calls if c[0] == "activities"] assert len(activity_calls) == 1, "one range call, not one call per day" _, start, end = activity_calls[0] assert start == day(6) and end == day(0) def test_stored_with_fields_mapped(self, db, user): garmin_svc.sync_data( user["id"], CREDS, days=1, client=StubClient(activities=[activity()]) ) rows = health_svc.get_activities(user["id"]) assert len(rows) == 1 assert rows[0]["activity_type"] == "running" assert rows[0]["heart_rate_average"] == 145 def test_count_is_reported(self, db, user): client = StubClient(activities=[activity(1), activity(2)]) out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) assert out["activitiesSynced"] == 2 def test_resync_does_not_duplicate(self, db, user): """Garmin activity ids are stable, so a re-synced window must skip what is already stored.""" client = StubClient(activities=[activity(1001)]) garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) assert len(health_svc.get_activities(user["id"])) == 1 assert out["activitiesSynced"] == 0 def test_end_time_derived_from_duration(self, db, user): client = StubClient(activities=[activity(duration=1800)]) garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) row = health_svc.get_activities(user["id"])[0] assert row["start_time"] != row["end_time"] def test_failure_does_not_lose_the_daily_data(self, db, user): out = garmin_svc.sync_data( user["id"], CREDS, days=2, client=StubClient(fail_activities=True) ) assert out["status"] == "success" assert len(health_svc.get_summary(user["id"])) == 2 class TestResync: def test_same_day_is_updated_not_duplicated(self, db, user): garmin_svc.sync_data( user["id"], CREDS, days=1, client=StubClient(summaries={day(): summary(steps=5000)}), ) garmin_svc.sync_data( user["id"], CREDS, days=1, client=StubClient(summaries={day(): summary(steps=9000)}), ) rows = health_svc.get_summary(user["id"]) assert len(rows) == 1 assert rows[0]["steps"] == 9000 class TestPartialAndTotalFailure: def test_one_bad_day_is_skipped_not_fatal(self, db, user): client = StubClient(fail_days=[day(1)]) out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=client) assert out["status"] == "success" assert out["recordsSynced"] == 2 assert "跳过" in out["message"] def test_every_day_failing_is_reported_as_an_error(self, db, user): """A systemic failure reported as a clean success would hide it.""" client = StubClient(fail_days=[day(0), day(1), day(2)]) out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=client) assert out["status"] == "error" assert out["recordsSynced"] == 0 def test_login_failure_is_reported(self, db, user, monkeypatch): def boom(_creds, _uid=None): raise RuntimeError("401 Unauthorized") monkeypatch.setattr(garmin_svc, "_connect", boom) out = garmin_svc.sync_data(user["id"], CREDS, days=1) assert out["status"] == "error" assert "401" in out["message"] def test_days_without_data_are_not_stored(self, db, user): """Garmin returns all-None for a day it has nothing for; an empty row would just have to be filtered back out by every read endpoint.""" client = StubClient( summaries={day(): {}}, sleeps={day(): {}}, hrvs={day(): {}} ) out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) assert out["recordsSynced"] == 0 assert health_svc.get_summary(user["id"]) == [] class TestSyncStatus: def test_idle_before_any_sync(self, db, user): assert garmin_svc.get_sync_status(user["id"])["status"] == "idle" def test_success_leaves_status_idle(self, db, user): garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient()) status = garmin_svc.get_sync_status(user["id"]) assert status["status"] == "idle" assert status["recordsSynced"] == 1 assert status["lastSyncTime"] def test_total_failure_leaves_status_error(self, db, user): garmin_svc.sync_data( user["id"], CREDS, days=2, client=StubClient(fail_days=[day(0), day(1)]) ) status = garmin_svc.get_sync_status(user["id"]) assert status["status"] == "error" assert status["lastError"] def test_a_later_success_clears_the_error(self, db, user): garmin_svc.sync_data( user["id"], CREDS, days=1, client=StubClient(fail_days=[day(0)]) ) garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient()) status = garmin_svc.get_sync_status(user["id"]) assert status["status"] == "idle" assert not status["lastError"] class TestEndpoint: def test_requires_auth(self, client): assert client.post("/api/garmin/sync", json={}).status_code == 401 def test_missing_password_is_refused_with_a_reason(self, client, auth): r = client.post("/api/garmin/sync", headers=auth, json={}) assert r.status_code == 400 assert "garminPassword" in r.get_json()["message"] def test_status_endpoint(self, client, auth): r = client.get("/api/garmin/status", headers=auth) assert r.status_code == 200 assert r.get_json()["status"] == "idle" class TestTokenStore: """Tokens are what make unattended sync possible on an MFA-protected account: the web worker has no stdin to type a code into.""" def test_absent_before_any_login(self, db, user): assert garmin_svc.has_token(user["id"]) is False def test_saved_token_round_trips(self, db, user): garmin_svc.save_token(user["id"], "token-blob", "g@example.com") assert garmin_svc.has_token(user["id"]) is True assert garmin_svc.load_token(user["id"]) == "token-blob" def test_re_login_replaces_rather_than_accumulates(self, db, user): garmin_svc.save_token(user["id"], "first", "g@example.com") garmin_svc.save_token(user["id"], "second", "g@example.com") rows = db.query_all( "SELECT * FROM garmin_tokens WHERE user_id = ?", [user["id"]] ) assert len(rows) == 1 assert garmin_svc.load_token(user["id"]) == "second" def test_tokens_are_per_user(self, db, user, make_user): garmin_svc.save_token(user["id"], "mine", "g@example.com") other = make_user("o@example.com") assert garmin_svc.has_token(other["id"]) is False class TestRememberedEmail: """`garmin_tokens` is the live Garmin binding; `users.garmin_email` is a legacy column kept only for accounts that bound Garmin before that table existed and have not signed in again since (see services/garmin.py).""" def test_none_when_never_bound(self, db, user): assert garmin_svc.get_remembered_email(user["id"]) == "" def test_reads_from_the_current_binding(self, db, user): garmin_svc.save_token(user["id"], "tok", "current@example.com") assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com" def test_current_binding_wins_over_the_legacy_column(self, db, user): db.execute( "UPDATE users SET garmin_email = ? WHERE id = ?", ["legacy@example.com", user["id"]], ) garmin_svc.save_token(user["id"], "tok", "current@example.com") assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com" def test_falls_back_to_the_legacy_column_when_never_bound_since(self, db, user): db.execute( "UPDATE users SET garmin_email = ? WHERE id = ?", ["legacy@example.com", user["id"]], ) assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com" def test_disconnecting_drops_the_current_binding_but_not_the_legacy_value( self, db, user ): db.execute( "UPDATE users SET garmin_email = ? WHERE id = ?", ["legacy@example.com", user["id"]], ) garmin_svc.save_token(user["id"], "tok", "current@example.com") garmin_svc.delete_token(user["id"]) assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com" class TestMfaHandling: def test_eof_from_the_mfa_prompt_becomes_an_actionable_error( self, db, user, monkeypatch ): """garth's default MFA prompt calls input(); with no stdin that raises a bare EOFError, which says nothing about what to do about it.""" class StubGarth: # _connect widens garth's 10s timeout before any call. def configure(self, **kw): pass def loads(self, s): pass def refresh_oauth2(self): pass class StubGarmin: def __init__(self, *a, **k): self.garth = StubGarth() self.username = None self.password = None def login(self, *a, **k): raise EOFError("EOF when reading a line") monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) with pytest.raises(garmin_svc.MFARequired, match="两步验证"): garmin_svc._connect(CREDS, user["id"]) def test_sync_flags_mfa_so_the_ui_can_explain(self, db, user, monkeypatch): def boom(_creds, _uid=None): raise garmin_svc.MFARequired("需要两步验证") monkeypatch.setattr(garmin_svc, "_connect", boom) out = garmin_svc.sync_data(user["id"], CREDS, days=1) assert out["status"] == "error" assert out["mfaRequired"] is True def test_ordinary_failures_are_not_flagged_as_mfa(self, db, user, monkeypatch): def boom(_creds, _uid=None): raise RuntimeError("401 Unauthorized") monkeypatch.setattr(garmin_svc, "_connect", boom) assert garmin_svc.sync_data(user["id"], CREDS, days=1)["mfaRequired"] is False def test_stored_token_is_used_instead_of_logging_in(self, db, user, monkeypatch): garmin_svc.save_token(user["id"], "saved-blob", "g@example.com") loaded = {} class StubGarth: profile = {"displayName": "Tester"} # _connect widens garth's 10s timeout before any call. def configure(self, **kw): pass def loads(self, s): loaded["blob"] = s def refresh_oauth2(self): loaded["refreshed"] = True class StubGarmin: def __init__(self, *a, **k): self.garth = StubGarth() def login(self, *a, **k): raise AssertionError("must not log in when a token exists") monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) garmin_svc._connect({}, user["id"]) assert loaded["blob"] == "saved-blob" assert loaded["refreshed"] is True def test_no_token_and_no_password_is_refused_clearly(self, db, user, monkeypatch): class StubGarmin: def __init__(self, *a, **k): self.garth = type("G", (), {"configure": lambda *a, **k: None})() monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) with pytest.raises(RuntimeError, match="缺少 Garmin 密码"): garmin_svc._connect({}, user["id"]) class TestAuthStatusEndpoint: def test_requires_auth(self, client): assert client.get("/api/garmin/auth-status").status_code == 401 def test_reports_false_then_true(self, client, auth, user, db): assert client.get("/api/garmin/auth-status", headers=auth).get_json()[ "hasToken"] is False garmin_svc.save_token(user["id"], "blob", "g@example.com") assert client.get("/api/garmin/auth-status", headers=auth).get_json()[ "hasToken"] is True def test_sync_without_password_allowed_once_a_token_exists( self, client, auth, user, db ): """The password field exists only because no token is stored yet.""" garmin_svc.save_token(user["id"], "blob", "g@example.com") r = client.post("/api/garmin/sync", headers=auth, json={}) assert r.status_code != 400 class TestApiUserAgent: """Regression: garth keeps its browser User-Agent after login, and the Garmin data API answers that UA with HTTP 200 and an empty array — every endpoint silently returns nothing.""" def test_a_browser_user_agent_is_not_used_for_the_api(self): assert "Mozilla" not in garmin_svc.API_USER_AGENT assert "iPhone" not in garmin_svc.API_USER_AGENT def test_header_is_swapped_after_loading_a_token(self, db, user): garmin_svc.save_token(user["id"], "blob", "g@example.com") headers = {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5)"} class StubSess: def __init__(self): self.headers = headers class StubGarth: profile = {"displayName": "Tester"} # _connect widens garth's 10s timeout before any call. def configure(self, **kw): pass def __init__(self): self.sess = StubSess() def loads(self, s): pass def refresh_oauth2(self): pass class StubGarmin: def __init__(self, *a, **k): self.garth = StubGarth() monkey = pytest.MonkeyPatch() monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) try: garmin_svc._connect({}, user["id"]) finally: monkey.undo() assert headers["User-Agent"] == garmin_svc.API_USER_AGENT def test_swap_is_harmless_on_a_client_without_a_session(self): class Bare: garth = object() garmin_svc._use_api_user_agent(Bare()) # must not raise def test_display_name_is_populated(self, db, user): """garminconnect builds URLs from display_name; unset sends every request to '.../None'.""" class StubGarth: profile = {"displayName": "Tester"} # _connect widens garth's 10s timeout before any call. def configure(self, **kw): pass sess = type("S", (), {"headers": {}})() def loads(self, s): pass def refresh_oauth2(self): pass class StubGarmin: def __init__(self, *a, **k): self.garth = StubGarth() garmin_svc.save_token(user["id"], "blob", "g@example.com") monkey = pytest.MonkeyPatch() monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) try: client = garmin_svc._connect({}, user["id"]) finally: monkey.undo() assert client.display_name == "Tester" class TestErrorMessagesAreNeverEmpty: """Regression: a bare `assert` inside garth raised AssertionError with an empty str(), which was stored as the sync's reason — a failed sync with a blank explanation cannot be diagnosed.""" def test_exception_without_text_still_describes_itself(self): assert garmin_svc.describe(AssertionError()) == "AssertionError" def test_exception_with_text_keeps_it(self): assert "boom" in garmin_svc.describe(RuntimeError("boom")) assert "RuntimeError" in garmin_svc.describe(RuntimeError("boom")) def test_whitespace_only_text_is_treated_as_empty(self): assert garmin_svc.describe(ValueError(" ")) == "ValueError" def test_connect_failure_records_a_non_empty_reason(self, db, user, monkeypatch): def boom(_creds, _uid=None): raise AssertionError() # no message at all monkeypatch.setattr(garmin_svc, "_connect", boom) out = garmin_svc.sync_data(user["id"], CREDS, days=1) assert out["status"] == "error" assert out["message"].strip() assert garmin_svc.get_sync_status(user["id"])["lastError"].strip() class TestTimestampNormalisation: """Regression: Garmin mixes ISO strings and epoch milliseconds in one payload. Writing the numeric form to a DATETIME column is rejected, which failed the entire personal-records batch.""" def test_iso_string_passes_through(self): assert garmin_svc._to_datetime("2019-10-13T10:10:12.0").startswith( "2019-10-13T10:10:12" ) def test_epoch_milliseconds_are_converted(self): assert garmin_svc._to_datetime(1570961412000).startswith("2019-10-13") def test_epoch_seconds_are_converted(self): assert garmin_svc._to_datetime(1570961412).startswith("2019-10-13") def test_first_usable_value_wins(self): assert garmin_svc._to_datetime(None, "", "2020-01-01T00:00:00") == ( "2020-01-01T00:00:00" ) def test_all_empty_gives_none(self): assert garmin_svc._to_datetime(None, "") is None def test_nonsense_value_does_not_raise(self): assert garmin_svc._to_datetime(float("inf")) is None class TestBackgroundSync: """A full backfill runs for ~20 minutes at ~3s per day, so the request must not block on it and the UI needs progress rather than a spinner.""" def test_progress_is_reported_during_the_run(self, db, user): garmin_svc.sync_data(user["id"], CREDS, days=10, client=StubClient()) status = garmin_svc.get_sync_status(user["id"]) assert status["progressTotal"] == 10 assert status["progressCurrent"] == 10 def test_progress_total_matches_the_requested_window(self, db, user): garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient()) assert garmin_svc.get_sync_status(user["id"])["progressTotal"] == 3 def test_start_sync_returns_immediately(self, db, user, monkeypatch): import threading release = threading.Event() def slow(uid, creds, days=None, client=None): release.wait(5) monkeypatch.setattr(garmin_svc, "sync_data", slow) out = garmin_svc.start_sync(user["id"], CREDS, days=365) # Returns before the work finishes. assert out["status"] == "syncing" assert out["days"] == 365 assert garmin_svc.get_sync_status(user["id"])["status"] == "syncing" release.set() def test_start_sync_marks_total_before_any_work(self, db, user, monkeypatch): import threading release = threading.Event() monkeypatch.setattr( garmin_svc, "sync_data", lambda *a, **k: release.wait(5) ) garmin_svc.start_sync(user["id"], CREDS, days=200) status = garmin_svc.get_sync_status(user["id"]) assert status["progressTotal"] == 200 assert status["progressCurrent"] == 0 release.set() def test_previous_error_is_cleared_when_a_new_sync_starts( self, db, user, monkeypatch ): garmin_svc.sync_data( user["id"], CREDS, days=1, client=StubClient(fail_days=[day(0)]) ) assert garmin_svc.get_sync_status(user["id"])["lastError"] import threading release = threading.Event() monkeypatch.setattr(garmin_svc, "sync_data", lambda *a, **k: release.wait(5)) garmin_svc.start_sync(user["id"], CREDS, days=7) assert not garmin_svc.get_sync_status(user["id"])["lastError"] release.set() def test_endpoint_returns_202_without_waiting(self, client, auth, user, db, monkeypatch): garmin_svc.save_token(user["id"], "blob", "g@example.com") monkeypatch.setattr(garmin_svc, "start_sync", lambda *a, **k: {"status": "syncing", "days": 30}) r = client.post("/api/garmin/sync", headers=auth, json={"days": 30}) assert r.status_code == 202 assert r.get_json()["status"] == "syncing" def test_days_is_clamped_to_a_sane_range(self, client, auth, user, db, monkeypatch): garmin_svc.save_token(user["id"], "blob", "g@example.com") seen = {} monkeypatch.setattr( garmin_svc, "start_sync", lambda uid, creds, days=None: seen.setdefault("days", days) or {"status": "syncing"}, ) client.post("/api/garmin/sync", headers=auth, json={"days": 99999}) assert seen["days"] == garmin_svc.MAX_HISTORY_DAYS class TestRateLimiting: """Garmin answers 429 with the plain text "Rate limited". garth feeds that body to json.loads, so it surfaced as `JSONDecodeError: Expecting value: line 1 column 1` — indistinguishable from a parsing bug. The sync status said nothing useful while the real problem was that we were asking too often, and every retry deepened it. """ def setup_method(self): garmin_svc._rate_limited_until.clear() def teardown_method(self): garmin_svc._rate_limited_until.clear() def test_the_real_shape_is_recognised(self): """garth calls .json() on the 429, so what actually reaches us is a JSONDecodeError whose message says nothing and whose `doc` holds the body. An earlier version of this test asserted on a hand-made ValueError("Rate limited") — a shape that never occurs — and passed while the detector missed every real one.""" import json err = json.JSONDecodeError("Expecting value", "Rate limited", 0) assert "rate limit" not in str(err).lower(), "the message alone is not enough" assert garmin_svc._is_rate_limited(err) def test_status_code_is_recognised(self): class Resp: status_code = 429 err = RuntimeError("nope") err.response = Resp() assert garmin_svc._is_rate_limited(err) def test_an_ordinary_error_is_not_mistaken_for_one(self): assert not garmin_svc._is_rate_limited(ValueError("boom")) def test_backoff_is_recorded_and_reported(self, db, user): garmin_svc._note_rate_limit(user["id"]) until = garmin_svc.rate_limited_until(user["id"]) assert until is not None and until > datetime.datetime.utcnow() def test_a_blocked_account_does_not_call_garmin_again(self, db, user, monkeypatch): """The retry is what deepens the limit, so it must not happen.""" garmin_svc.save_token(user["id"], "token-blob") garmin_svc._note_rate_limit(user["id"]) class Stub: def __init__(self, *a, **k): self.garth = type("G", (), { "configure": lambda *a, **k: None, "loads": lambda *a: None, "refresh_oauth2": lambda *a: (_ for _ in ()).throw( AssertionError("must not reach Garmin while backing off")), "oauth2_token": None, "sess": type("S", (), {"headers": {}})(), })() monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: Stub) with pytest.raises(garmin_svc.RateLimited): garmin_svc._connect({}, user_id=user["id"]) def test_a_valid_token_is_not_refreshed(self, db, user, monkeypatch): """Refreshing on every connect spends quota for nothing — and that is what walked the account into a 429 in the first place.""" garmin_svc.save_token(user["id"], "token-blob") calls = [] class Stub: def __init__(self, *a, **k): unexpired = type("T", (), {"expired": False})() self.garth = type("G", (), { "configure": lambda *a, **k: None, "loads": lambda *a: None, "refresh_oauth2": lambda *a: calls.append(1), "oauth2_token": unexpired, "profile": {"displayName": "x"}, "sess": type("S", (), {"headers": {}})(), })() monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: Stub) garmin_svc._connect({}, user_id=user["id"]) assert calls == [], "an unexpired token needs no refresh" class TestSyncWindow: """How many days a sync actually covers. The 历史范围 picker offers 全部历史 (0) and 自上次同步 (-1); both used to end up pulling the 7-day default instead — the first because the client dropped a falsy `days`, the second because the incremental lookup read a table that does not exist and died inside the background thread. """ def setup_method(self): garmin_svc._rate_limited_until.clear() def teardown_method(self): garmin_svc._rate_limited_until.clear() def test_incremental_resumes_from_the_latest_stored_day( self, db, user, seed_health ): seed_health([{"date": day(5), "steps": 4000}]) client = StubClient() out = garmin_svc.sync_data(user["id"], CREDS, days=-1, client=client) pulled = {c[1] for c in client.calls if c[0] == "summary"} assert out["status"] == "success" assert pulled == {day(i) for i in range(5)} def test_incremental_without_history_falls_back_to_the_default(self, db, user): client = StubClient() garmin_svc.sync_data(user["id"], CREDS, days=-1, client=client) pulled = {c[1] for c in client.calls if c[0] == "summary"} assert len(pulled) == garmin_svc.DEFAULT_SYNC_DAYS def test_full_history_asks_for_the_maximum_window(self, db, user, monkeypatch): """`days=0` is 全部历史, not "unset" — and not a fixed two years.""" monkeypatch.setattr(garmin_svc, "MAX_HISTORY_DAYS", 50) client = StubClient() garmin_svc.sync_data(user["id"], CREDS, days=0, client=client) assert len([c for c in client.calls if c[0] == "summary"]) == 50 def test_days_already_stored_are_not_refetched(self, db, user, seed_health): """What makes a multi-year backfill resumable: a run that stopped on a 429 must continue where it left off, not start over from today.""" seed_health([{"date": day(i), "steps": 4000} for i in range(5, 20)]) client = StubClient() garmin_svc.sync_data(user["id"], CREDS, days=20, client=client) pulled = sorted(c[1] for c in client.calls if c[0] == "summary") assert pulled == sorted(day(i) for i in range(5)), "only the gap" def test_the_most_recent_days_are_always_refetched(self, db, user, seed_health): """Today is still being written to; a stored row for it is not final.""" seed_health([{"date": day(i), "steps": 4000} for i in range(0, 10)]) client = StubClient() garmin_svc.sync_data(user["id"], CREDS, days=10, client=client) pulled = {c[1] for c in client.calls if c[0] == "summary"} assert pulled == {day(i) for i in range(garmin_svc.ALWAYS_REFETCH_DAYS)} def test_a_long_backfill_stops_at_the_start_of_the_account(self, db, user, monkeypatch): """全部历史 must not spend thousands of requests on years that predate the watch.""" monkeypatch.setattr(garmin_svc, "EMPTY_RUN_STOP", 10) empty = {"totalSteps": None, "restingHeartRate": None, "averageStressLevel": None, "totalKilocalories": None} client = StubClient( summaries={day(i): empty for i in range(3, 400)}, sleeps={day(i): {} for i in range(3, 400)}, hrvs={day(i): {} for i in range(3, 400)}, ) out = garmin_svc.sync_data(user["id"], CREDS, days=365, client=client) assert out["recordsSynced"] == 3, "the three days that had data" pulled = len([c for c in client.calls if c[0] == "summary"]) assert pulled == 13, f"3 real days + 10 empties, not 365 ({pulled})" def test_the_sync_endpoint_passes_a_zero_through( self, client, auth, user, db, monkeypatch ): garmin_svc.save_token(user["id"], "blob") seen = {} def record(uid, creds, days=None): seen["days"] = days return {"status": "syncing", "days": days} monkeypatch.setattr(garmin_svc, "start_sync", record) r = client.post("/api/garmin/sync", headers=auth, json={"days": 0}) assert r.status_code == 202 # 0 travels all the way to the service, which walks back to the real # end of the account rather than to a fixed ceiling. assert seen["days"] == 0, "全部历史 must not fall back to the default" def test_a_429_mid_run_stops_the_sync(self, db, user): """Filing a 429 as one more skipped day meant a 730-day backfill kept hammering Garmin for another 700 days and dug the throttle deeper.""" class Throttled(StubClient): def get_user_summary(self, cdate): self.calls.append(("summary", cdate)) if cdate == day(2): raise RuntimeError("too many 429 error responses") return summary() client = Throttled() out = garmin_svc.sync_data(user["id"], CREDS, days=365, client=client) assert out["status"] == "rate_limited" assert out["recordsSynced"] == 2, "the days pulled before the 429 are kept" assert len([c for c in client.calls if c[0] == "summary"]) == 3 assert garmin_svc.rate_limited_until(user["id"]) is not None