""" Unit tests for the auto-sync scheduler. The interesting behaviour is the claim: gunicorn runs several workers, each of which starts its own timer, so without coordination one hourly tick would fire a sync per worker. """ import datetime import pytest from services import scheduler @pytest.fixture(autouse=True) def _clean(db): db.execute("DELETE FROM job_locks") def set_last_run(db, when): db.execute( "UPDATE job_locks SET last_run_at = ? WHERE name = ?", [when.isoformat(timespec="seconds"), scheduler.JOB_NAME], ) class TestClaim: def test_first_caller_gets_the_job(self, db): assert scheduler.claim() is True def test_second_caller_is_turned_away_while_the_first_holds_it(self, db, monkeypatch): monkeypatch.setattr(scheduler.os, "getpid", lambda: 111) assert scheduler.claim() is True # A different worker, same instant. monkeypatch.setattr(scheduler.os, "getpid", lambda: 222) assert scheduler.claim() is False, "two workers must not run the same tick" def test_not_due_again_within_the_interval(self, db): scheduler.claim() scheduler.release() assert scheduler.claim() is False def test_due_again_after_the_interval(self, db): scheduler.claim() scheduler.release() set_last_run(db, datetime.datetime.utcnow() - datetime.timedelta(seconds=7200)) assert scheduler.claim() is True def test_abandoned_claim_expires(self, db, monkeypatch): """A worker that dies mid-run must not block the job forever.""" monkeypatch.setattr(scheduler.os, "getpid", lambda: 111) scheduler.claim() stale = datetime.datetime.utcnow() - datetime.timedelta( seconds=scheduler.CLAIM_TIMEOUT_SECONDS + 60 ) db.execute( "UPDATE job_locks SET claimed_at = ? WHERE name = ?", [stale.isoformat(timespec="seconds"), scheduler.JOB_NAME], ) monkeypatch.setattr(scheduler.os, "getpid", lambda: 222) assert scheduler.claim() is True def test_release_without_running_leaves_it_due(self, db): scheduler.claim() scheduler.release(ran=False) assert scheduler.claim() is True def test_release_after_running_records_the_time(self, db): scheduler.claim() scheduler.release() assert scheduler.status()["lastRunAt"] is not None class TestSyncAllAccounts: def test_no_accounts_is_a_no_op(self, db, user): assert scheduler.sync_all_accounts() == [] def test_syncs_every_account_holding_a_token(self, db, user, monkeypatch, make_user): from services import garmin as garmin_svc other = make_user("b@example.com") garmin_svc.save_token(user["id"], "t1", "a@example.com") garmin_svc.save_token(other["id"], "t2", "b@example.com") seen = [] monkeypatch.setattr( garmin_svc, "sync_data", lambda uid, creds, days=None, client=None: seen.append(uid) or {"status": "success", "recordsSynced": days}, ) results = scheduler.sync_all_accounts(days=2) assert set(seen) == {user["id"], other["id"]} assert all(r["status"] == "success" for r in results) def test_one_failing_account_does_not_stop_the_others( self, db, user, monkeypatch, make_user ): from services import garmin as garmin_svc other = make_user("c@example.com") garmin_svc.save_token(user["id"], "t1") garmin_svc.save_token(other["id"], "t2") def flaky(uid, creds, days=None, client=None): if uid == user["id"]: raise RuntimeError("token expired") return {"status": "success", "recordsSynced": 2} monkeypatch.setattr(garmin_svc, "sync_data", flaky) results = scheduler.sync_all_accounts() assert len(results) == 2 assert {r["status"] for r in results} == {"error", "success"} def test_accounts_without_a_token_are_skipped(self, db, user, monkeypatch): from services import garmin as garmin_svc called = [] monkeypatch.setattr( garmin_svc, "sync_data", lambda *a, **k: called.append(1) or {"status": "success"}, ) scheduler.sync_all_accounts() assert called == [], "an account with no token cannot be synced" class TestStatus: def test_reports_configuration(self, db): s = scheduler.status() assert s["intervalSeconds"] == scheduler.INTERVAL_SECONDS assert s["days"] == scheduler.SYNC_DAYS def test_next_run_follows_the_last(self, db): scheduler.claim() scheduler.release() s = scheduler.status() assert s["lastRunAt"] and s["nextRunAt"] assert s["nextRunAt"] > s["lastRunAt"] def test_no_run_yet(self, db): s = scheduler.status() assert s["lastRunAt"] is None assert s["nextRunAt"] is None class TestEndpoints: def test_sync_latest_requires_auth(self, client): assert client.post("/api/garmin/sync-latest", json={}).status_code == 401 def test_sync_latest_needs_a_bound_account(self, client, auth): r = client.post("/api/garmin/sync-latest", headers=auth, json={}) assert r.status_code == 400 assert "绑定" in r.get_json()["error"] def test_sync_latest_runs_inline(self, client, auth, user, db, monkeypatch): from services import garmin as garmin_svc garmin_svc.save_token(user["id"], "blob") monkeypatch.setattr( garmin_svc, "sync_data", lambda uid, creds, days=None, client=None: { "status": "success", "recordsSynced": days, "message": "ok", }, ) r = client.post("/api/garmin/sync-latest", headers=auth, json={"days": 3}) assert r.status_code == 200 assert r.get_json()["recordsSynced"] == 3 def test_sync_latest_window_is_clamped(self, client, auth, user, db, monkeypatch): from services import garmin as garmin_svc garmin_svc.save_token(user["id"], "blob") monkeypatch.setattr( garmin_svc, "sync_data", lambda uid, creds, days=None, client=None: { "status": "success", "recordsSynced": days, }, ) r = client.post("/api/garmin/sync-latest", headers=auth, json={"days": 999}) assert r.get_json()["recordsSynced"] == 7 def test_auto_sync_status_endpoint(self, client, auth): r = client.get("/api/garmin/auto-sync", headers=auth) assert r.status_code == 200 assert "intervalSeconds" in r.get_json() class TestSyncWindowResolution: """Which window each account is synced over. `query_one` returns a dict, so reading the saved 历史范围 as `s[0]` raised KeyError — swallowed by the per-account handler, which meant every scheduled tick failed for every account that had ever opened 设置, and nothing was synced automatically at all. """ def _account(self, user, monkeypatch): from services import garmin as garmin_svc garmin_svc.save_token(user["id"], "t1") seen = {} def record(uid, creds, days=None, client=None): seen["days"] = days return {"status": "success", "recordsSynced": 0} monkeypatch.setattr(garmin_svc, "sync_data", record) return seen def test_saved_history_days_is_honoured(self, db, user, monkeypatch): from services import settings as settings_svc seen = self._account(user, monkeypatch) settings_svc.save_settings(user["id"], {"historyDays": 90}) results = scheduler.sync_all_accounts() assert [r["status"] for r in results] == ["success"] assert seen["days"] == 90 def test_full_history_becomes_the_maximum(self, db, user, monkeypatch): from services import settings as settings_svc seen = self._account(user, monkeypatch) settings_svc.save_settings(user["id"], {"historyDays": 0}) scheduler.sync_all_accounts() assert seen["days"] == 730 def test_the_scheduled_tick_ignores_the_history_setting( self, db, user, monkeypatch ): """历史范围 is the window for the manual 全量同步. A half-hourly tick re-pulling 全部历史 is what kept the account in a 429 loop.""" from services import settings as settings_svc seen = self._account(user, monkeypatch) settings_svc.save_settings(user["id"], {"historyDays": 0, "autoSync": True}) scheduler.sync_all_accounts(days=scheduler.SYNC_DAYS, respect_schedule=True) assert seen["days"] == scheduler.SYNC_DAYS