Files
GarminHealthLab/backend/tests/test_activity_detail.py
ericwyuan 9503fca370 feat(auth): 接入 auth-hub 统一登录,网页登录与 Garmin 同步彻底分离
网页身份改由 auth-hub 做 OAuth2 + PKCE 单点登录,本地邮箱/密码登录与注册整条链路删除
(routes/auth.py、auth.py 的密码哈希、config.py 的 ALLOW_REGISTRATION)。Garmin 账号绑定/
同步保持完全独立、可选:routes/garmin.py 不再直接查 users 表,Garmin 邮箱回退统一走新增
的 services/garmin.py::get_remembered_email()(优先读 garmin_tokens 当前绑定,兼容早期账号
落在 users.garmin_email 的历史值),彻底把「你是谁」和「你绑没绑 Garmin」两件事拆开。

- db.py: users 表新增 auth_hub_sub/auth_hub_username,MIGRATIONS 补上这两列(此前遗漏导致
  已存在的生产 MariaDB 表永远不会自动加列);同时把历史遗留的 garmin_email/
  garmin_password_hash NOT NULL 约束在线迁移为可空,因为新账号不再在注册时收集这些字段。
- routes/auth.py: 修掉 /callback 路由重复拼接 /api/auth 前缀导致 404 的 bug。
- client: LoginPage 去掉本地登录/注册标签页,只保留 auth-hub 统一登录;登录成功/失败后都
  用 history.replaceState 清理地址栏,修掉 Framework7 browserHistory 读取
  /auth/callback?code=... 导致「找不到页面」的问题。
- 新增 test_auth_hub_client.py 锁定 find_or_create_user 按 auth_hub_sub 幂等——生产上曾经因为
  这个函数在没有该测试保护时被测试触发,误建过一个空账号,靠手工核对 health_data 计数才发现。
- 生产 auth-hub 侧另行为该项目注册了正式 client(未随本次提交变更,凭证只存在服务器 .env)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 23:12:17 +08:00

391 lines
15 KiB
Python

"""
One activity, in full.
Garmin returns the sampled series as a column store — a descriptor list plus
rows of parallel values — so the fragile part is reading each metric out by the
index its descriptor names. Getting that wrong silently plots the wrong sensor.
"""
import json
import pytest
from services import garmin as svc
def descriptors(*keys):
return [{"key": k, "metricsIndex": i} for i, k in enumerate(keys)]
def rows(*tuples):
return [{"metrics": list(t)} for t in tuples]
class FakeClient:
"""Stands in for garminconnect.Garmin with recorded responses."""
def __init__(self, **responses):
self.responses = responses
self.calls = []
def _get(self, name, default):
self.calls.append(name)
value = self.responses.get(name, default)
if isinstance(value, Exception):
raise value
return value
def get_activity_evaluation(self, _id):
return self._get("evaluation", {})
def get_activity_details(self, _id, maxchart=None, maxpoly=None):
self.maxchart = maxchart
self.maxpoly = maxpoly
return self._get("details", {})
def get_activity_splits(self, _id):
return self._get("splits", {})
def get_activity_hr_in_timezones(self, _id):
return self._get("zones", [])
def get_activity_weather(self, _id):
return self._get("weather", {})
def get_activity_gear(self, _id):
return self._get("gear", [])
def get_activity_exercise_sets(self, _id):
return self._get("sets", {})
class TestSeriesExtraction:
def test_reads_each_metric_by_its_declared_index(self):
details = {
"metricDescriptors": descriptors(
"directTimestamp", "directHeartRate", "directSpeed"
),
"activityDetailMetrics": rows((1000, 120, 2.5), (2000, 130, 3.0)),
}
s = svc._series_from_details(details)
assert s["heartRate"] == [120, 130]
assert s["speed"] == [2.5, 3.0]
def test_index_is_taken_from_the_descriptor_not_its_position(self):
"""The descriptor list order and the metrics order are not the same
thing; assuming they are plots heart rate as elevation."""
details = {
"metricDescriptors": [
{"key": "directHeartRate", "metricsIndex": 2},
{"key": "directElevation", "metricsIndex": 0},
],
"activityDetailMetrics": rows((500, 999, 140)),
}
s = svc._series_from_details(details)
assert s["heartRate"] == [140]
assert s["elevation"] == [500]
def test_unknown_metrics_are_dropped(self):
details = {
"metricDescriptors": descriptors("directHeartRate", "sumMysteryField"),
"activityDetailMetrics": rows((120, 7)),
}
assert set(svc._series_from_details(details)) == {"heartRate"}
def test_all_null_column_is_dropped(self):
"""A column of nothing but nulls is a sensor the watch does not have,
and an empty chart is worse than no chart."""
details = {
"metricDescriptors": descriptors("directHeartRate", "directPower"),
"activityDetailMetrics": rows((120, None), (130, None)),
}
s = svc._series_from_details(details)
assert "heartRate" in s
assert "power" not in s
def test_partially_null_column_is_kept(self):
details = {
"metricDescriptors": descriptors("directHeartRate"),
"activityDetailMetrics": rows((120,), (None,), (130,)),
}
assert svc._series_from_details(details)["heartRate"] == [120, None, 130]
def test_short_row_does_not_raise(self):
"""A truncated row must not take the whole request down."""
details = {
"metricDescriptors": descriptors("directHeartRate", "directSpeed"),
"activityDetailMetrics": [{"metrics": [120, 2.5]}, {"metrics": [130]}],
}
s = svc._series_from_details(details)
assert s["speed"] == [2.5, None]
def test_empty_payload(self):
assert svc._series_from_details({}) == {}
assert svc._series_from_details({"metricDescriptors": []}) == {}
def test_cadence_variants_share_one_key(self):
"""Running, cycling and double cadence are the same chart to a reader."""
for key in ("directRunCadence", "directBikeCadence", "directDoubleCadence"):
details = {
"metricDescriptors": descriptors(key),
"activityDetailMetrics": rows((80,)),
}
assert svc._series_from_details(details)["cadence"] == [80]
class TestThinning:
def test_short_series_is_untouched(self):
assert svc._thin([1, 2, 3], limit=10) == [1, 2, 3]
def test_long_series_is_reduced_to_the_limit(self):
assert len(svc._thin(list(range(5000)), limit=300)) == 300
def test_keeps_the_first_and_last_sample(self):
thinned = svc._thin(list(range(1000)), limit=50)
assert thinned[0] == 0
assert thinned[-1] == 999
def test_stays_in_order(self):
thinned = svc._thin(list(range(1000)), limit=50)
assert thinned == sorted(thinned)
def test_series_are_thinned_together(self):
"""Thinning columns independently would pair a heart rate with another
moment's speed."""
details = {
"metricDescriptors": descriptors("directHeartRate", "directSpeed"),
"activityDetailMetrics": rows(*[(i, i * 2) for i in range(1000)]),
}
s = svc._series_from_details(details)
assert len(s["heartRate"]) == len(s["speed"])
assert all(sp == hr * 2 for hr, sp in zip(s["heartRate"], s["speed"]))
class TestLapsAndZones:
def test_laps_are_numbered_even_without_an_index(self):
laps = svc._lap_rows({"lapDTOs": [{"duration": 60}, {"duration": 70}]})
assert [l["index"] for l in laps] == [1, 2]
def test_lap_fields_are_carried_through(self):
laps = svc._lap_rows({"lapDTOs": [{
"lapIndex": 1, "duration": 2646.0, "distance": 1130.0,
"averageSpeed": 0.42, "averageHR": 105, "elevationGain": 42,
}]})
assert laps[0]["distance"] == 1130.0
assert laps[0]["averageHR"] == 105
assert laps[0]["elevationGain"] == 42
def test_no_laps(self):
assert svc._lap_rows({}) == []
assert svc._lap_rows(None) == []
def test_zones_are_sorted_by_number(self):
zones = svc._hr_zones([
{"zoneNumber": 3, "secsInZone": 10},
{"zoneNumber": 1, "secsInZone": 30},
{"zoneNumber": 2, "secsInZone": 20},
])
assert [z["zone"] for z in zones] == [1, 2, 3]
def test_zone_with_no_time_is_kept(self):
"""The empty zones are part of the picture: they show the session never
got hard, which is exactly what the reader is looking at."""
zones = svc._hr_zones([{"zoneNumber": 5, "secsInZone": 0}])
assert zones[0]["seconds"] == 0
def test_missing_seconds_becomes_zero_not_none(self):
assert svc._hr_zones([{"zoneNumber": 1}])[0]["seconds"] == 0
class TestBuildDetail:
def test_assembles_every_section(self, monkeypatch):
client = FakeClient(
evaluation={"activityName": "Shiyan 登山",
"activityTypeDTO": {"typeKey": "mountaineering"},
"summaryDTO": {"distance": 1130.0, "duration": 2646.0}},
splits={"lapDTOs": [{"lapIndex": 1, "duration": 2646.0}]},
zones=[{"zoneNumber": 1, "secsInZone": 1383}],
details={
"metricDescriptors": descriptors("directHeartRate"),
"activityDetailMetrics": rows((105,)),
},
)
d = svc._build_detail(client, "123")
assert d["activityName"] == "Shiyan 登山"
assert d["activityType"] == "mountaineering"
assert d["summary"]["distance"] == 1130.0
assert d["laps"][0]["duration"] == 2646.0
assert d["hrZones"][0]["seconds"] == 1383
assert d["series"]["heartRate"] == [105]
def test_one_failing_endpoint_does_not_lose_the_rest(self):
"""A treadmill run has no weather and no gear; those 404s must not take
the page down."""
client = FakeClient(
evaluation={"summaryDTO": {"duration": 600}},
weather=RuntimeError("404"),
gear=RuntimeError("404"),
sets=RuntimeError("404"),
)
d = svc._build_detail(client, "123")
assert d["summary"]["duration"] == 600
assert d["weather"] == {}
assert d["gear"] == []
def test_does_not_request_more_samples_than_it_keeps(self):
client = FakeClient()
svc._build_detail(client, "123")
assert client.maxchart <= 500
def test_does_not_fetch_the_map_polyline(self):
"""There is no map in the UI, and the polyline is the largest single
part of the payload."""
client = FakeClient()
svc._build_detail(client, "123")
assert client.maxpoly == 0
class TestStoreAndRead:
def test_read_returns_none_when_never_synced(self, db, user):
assert svc.read_activity_detail(user["id"], "abc") is None
def test_round_trip(self, db, user):
svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}})
assert svc.read_activity_detail(user["id"], "abc")["summary"]["duration"] == 600
def test_corrupt_row_reads_as_missing_rather_than_raising(self, db, user):
db.execute(
"INSERT INTO activity_details (activity_id, user_id, payload) "
"VALUES (?,?,?)", ["abc", user["id"], "{not json"]
)
assert svc.read_activity_detail(user["id"], "abc") is None
def test_details_are_per_account(self, db, user, make_user):
other = make_user("b@example.com")
svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}})
assert svc.read_activity_detail(other["id"], "abc") is None
def test_storing_again_replaces(self, db, user):
svc._store_detail(user["id"], "abc", {"summary": {"duration": 1}})
svc._store_detail(user["id"], "abc", {"summary": {"duration": 2}})
assert svc.read_activity_detail(user["id"], "abc")["summary"]["duration"] == 2
class TestSyncActivityDetails:
def seed(self, db, user, ids):
for i, aid in enumerate(ids):
stamp = f"2026-08-{10 + i:02d}T08:00:00"
db.execute(
"INSERT INTO activities (id, user_id, activity_type, start_time, "
"end_time) VALUES (?,?,?,?,?)",
[aid, user["id"], "running", stamp, stamp],
)
def test_fetches_every_activity_without_a_detail(self, db, user):
self.seed(db, user, ["a1", "a2", "a3"])
client = FakeClient(evaluation={"summaryDTO": {"duration": 600}})
assert svc.sync_activity_details(client, user["id"]) == 3
assert svc.read_activity_detail(user["id"], "a2") is not None
def test_skips_activities_that_already_have_one(self, db, user):
self.seed(db, user, ["a1", "a2"])
svc._store_detail(user["id"], "a1", {"summary": {}})
client = FakeClient(evaluation={"summaryDTO": {"duration": 600}})
assert svc.sync_activity_details(client, user["id"]) == 1
def test_is_a_no_op_once_everything_is_stored(self, db, user):
self.seed(db, user, ["a1"])
client = FakeClient(evaluation={"summaryDTO": {"duration": 600}})
svc.sync_activity_details(client, user["id"])
assert svc.sync_activity_details(client, user["id"]) == 0
def test_limit_caps_the_batch(self, db, user):
self.seed(db, user, ["a1", "a2", "a3", "a4"])
client = FakeClient(evaluation={"summaryDTO": {"duration": 600}})
assert svc.sync_activity_details(client, user["id"], limit=2) == 2
def test_newest_activities_are_fetched_first(self, db, user):
"""A backfill that runs for minutes should make the activities the user
is most likely to open available first."""
self.seed(db, user, ["old", "new"]) # start_time ascends with index
client = FakeClient(evaluation={"summaryDTO": {"duration": 600}})
svc.sync_activity_details(client, user["id"], limit=1)
assert svc.read_activity_detail(user["id"], "new") is not None
assert svc.read_activity_detail(user["id"], "old") is None
def test_one_failing_activity_does_not_stop_the_rest(self, db, user):
self.seed(db, user, ["a1", "a2", "a3"])
calls = {"n": 0}
def flaky(_client, activity_id):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("Garmin 挂了")
return {"activityId": activity_id, "summary": {}}
import services.garmin as g
original = g._build_detail
g._build_detail = flaky
try:
assert svc.sync_activity_details(FakeClient(), user["id"]) == 2
finally:
g._build_detail = original
def test_only_this_accounts_activities(self, db, user, make_user):
other = make_user("b@example.com")
self.seed(db, user, ["mine"])
db.execute(
"INSERT INTO activities (id, user_id, activity_type, start_time, end_time) "
"VALUES (?,?,?,?,?)",
["theirs", other["id"], "running", "2026-08-11T08:00:00",
"2026-08-11T09:00:00"],
)
fake = FakeClient(evaluation={"summaryDTO": {"duration": 600}})
assert svc.sync_activity_details(fake, user["id"]) == 1
assert svc.read_activity_detail(other["id"], "theirs") is None
def test_progress_is_reported(self, db, user):
self.seed(db, user, ["a1", "a2"])
seen = []
svc.sync_activity_details(
FakeClient(), user["id"], on_progress=lambda d, t: seen.append((d, t))
)
assert seen == [(1, 2), (2, 2)]
class TestEndpoint:
def test_requires_auth(self, client):
assert client.get("/api/garmin/activities/1/detail").status_code == 401
def test_unsynced_activity_says_so(self, client, auth):
r = client.get("/api/garmin/activities/nope/detail", headers=auth)
assert r.status_code == 404
body = r.get_json()
assert body["needsSync"] is True
assert "同步" in body["error"]
def test_returns_the_stored_detail(self, client, auth, user, db):
svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}})
body = client.get("/api/garmin/activities/abc/detail", headers=auth).get_json()
assert body["summary"]["duration"] == 600
def test_reading_never_touches_garmin(self, client, auth, user, db, monkeypatch):
"""The whole point of storing details during the sync: a tap must not
depend on Garmin being reachable."""
svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}})
def explode(*a, **k):
raise AssertionError("opening an activity must be a local read")
monkeypatch.setattr(svc, "_connect", explode)
assert client.get(
"/api/garmin/activities/abc/detail", headers=auth
).status_code == 200
def test_backfill_needs_a_bound_account(self, client, auth):
r = client.post("/api/garmin/sync-details", headers=auth, json={})
assert r.status_code == 400
def test_backfill_status_is_readable(self, client, auth):
body = client.get("/api/garmin/sync-details", headers=auth).get_json()
assert body["running"] is False