[阶段6] 同步 Garmin 全量数据:31 项日指标 + 奖励 + 个人纪录
原来每天只存 7 个指标,而 get_user_summary 一次就返回 60+ 字段, 另有睡眠分期、训练准备度、耐力分等独立端点从未被调用。 db.py: - health_data 新增 31 列(距离/活动卡路里/基础代谢/爬楼/强度分钟/ 久坐时长/最高最低心率/最大压力/身体电量四项/血氧/呼吸/ 睡眠深浅REM清醒分期/睡眠血氧/睡眠呼吸/睡眠压力/训练准备度/ VO2max/耐力分) - 新增 badges 与 personal_records 两张表,均以 (user_id, garmin_id) 为主键,重复同步更新而非累积 - 新增增量迁移: CREATE TABLE IF NOT EXISTS 对已存在的表不生效, 新列必须显式 ALTER,否则生产库上永远不会出现。按列名比对后 逐个补齐,SQLite 与 MariaDB 都幂等 services/garmin.py: - _extract_daily 改为汇总 user_summary + sleep + hrv + training_readiness + training_status + endurance_score 五个端点 - 每个可选端点用 _safe 包裹:某项设备不记录时留 NULL,不影响当天其余数据 - 新增 sync_badges / sync_personal_records(账号级,每次同步取一次) fix(garmin): 个人纪录整批写入失败 - Garmin 在同一份数据里混用 ISO 字符串和 Unix 毫秒时间戳, prStartTimeGmt 是 1570961412000,写进 DATETIME 列被 MariaDB 以 1292 拒绝,导致 11 项个人纪录一条都没存进去 - 新增 _to_datetime 统一处理 ISO / 毫秒 / 秒三种形状,并优先取 Garmin 自己提供的 *Formatted 字段 services/ai.py: - 送给模型的 CSV 从 7 列扩到 23 列,纳入身体电量、血氧、呼吸、 训练准备度、耐力分和睡眠分期 接口: GET /api/health/badges、/api/health/personal-records tests (+13, 共 292): - 徽章/纪录的往返、重复同步不累积、按用户隔离 - 两个用户可持有同一个 Garmin 徽章 id 而不冲突 - 时间戳三种形状的归一化及无效值不抛异常 NAS 实测: 7 天数据每天 31 项指标、65 个奖励、11 项个人纪录 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -117,7 +117,8 @@ class TestHappyPath:
|
||||
assert row["stress"] == 33
|
||||
assert row["caloriesBurned"] == 2450
|
||||
assert row["heartRateVariability"] == 52
|
||||
assert row["sleep"] == {"duration": 8.0, "quality": 91}
|
||||
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
|
||||
@@ -408,3 +409,121 @@ class TestAuthStatusEndpoint:
|
||||
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"}
|
||||
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"}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user