逐个接口计时后的两处改动: - 健康摘要不再下发空值。一天 40 项指标里大部分是这块表没有的传感器, 全按 null 发出去占了约两成体积。客户端本来就把「键不存在」和 null 当同一回事。 - 今日页首屏由 365 天改为 60 天,往前翻越界时再加载 180 天。 一次取一年是为了让翻页不发请求,代价是首屏 394 KB,手机上不划算。 日历选到窗口外的日期同样会自动加载。 顺带把加载逻辑收成一个 loadFrom:原来初始 effect 的依赖是空数组, 日历里改 from 不会触发重新拉取,是个还没被触发的 bug。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""Unit tests for the health data service and its endpoints."""
|
|
import pytest
|
|
|
|
from services import health as health_svc
|
|
|
|
|
|
DAYS = [
|
|
{"date": "2026-08-20", "steps": 6500, "heart_rate": 70, "hrv": 45,
|
|
"sleep_duration": 6, "sleep_quality": 80, "stress": 55, "calories": 260},
|
|
{"date": "2026-08-21", "steps": 9000, "heart_rate": 62, "hrv": 46,
|
|
"sleep_duration": 8, "sleep_quality": 79, "stress": 40, "calories": 360},
|
|
{"date": "2026-08-22", "steps": 7500, "heart_rate": 68, "hrv": 47,
|
|
"sleep_duration": 7, "sleep_quality": 78, "stress": 48, "calories": 300},
|
|
]
|
|
|
|
|
|
class TestGetSummary:
|
|
def test_empty_for_new_user(self, db, user):
|
|
assert health_svc.get_summary(user["id"]) == []
|
|
|
|
def test_returns_all_rows_ordered_by_date(self, seed_health, user):
|
|
seed_health(DAYS)
|
|
rows = health_svc.get_summary(user["id"])
|
|
assert [r["date"] for r in rows] == [
|
|
"2026-08-20", "2026-08-21", "2026-08-22"
|
|
]
|
|
|
|
def test_maps_to_camel_case(self, seed_health, user):
|
|
seed_health(DAYS)
|
|
row = health_svc.get_summary(user["id"])[0]
|
|
assert row["heartRate"] == 70
|
|
assert row["heartRateVariability"] == 45
|
|
assert row["caloriesBurned"] == 260
|
|
# Sleep carries stage detail too; the core two must be right.
|
|
assert row["sleep"]["duration"] == 6
|
|
assert row["sleep"]["quality"] == 80
|
|
|
|
def test_sleep_is_none_when_absent(self, seed_health, user):
|
|
seed_health([{"date": "2026-08-20", "steps": 100}])
|
|
assert health_svc.get_summary(user["id"])[0]["sleep"] is None
|
|
|
|
def test_start_date_filter_is_inclusive(self, seed_health, user):
|
|
seed_health(DAYS)
|
|
rows = health_svc.get_summary(user["id"], start="2026-08-21")
|
|
assert [r["date"] for r in rows] == ["2026-08-21", "2026-08-22"]
|
|
|
|
def test_end_date_filter_is_inclusive(self, seed_health, user):
|
|
seed_health(DAYS)
|
|
rows = health_svc.get_summary(user["id"], end="2026-08-21")
|
|
assert [r["date"] for r in rows] == ["2026-08-20", "2026-08-21"]
|
|
|
|
def test_both_bounds(self, seed_health, user):
|
|
seed_health(DAYS)
|
|
rows = health_svc.get_summary(user["id"], "2026-08-21", "2026-08-21")
|
|
assert len(rows) == 1
|
|
|
|
def test_range_with_no_matches(self, seed_health, user):
|
|
seed_health(DAYS)
|
|
assert health_svc.get_summary(user["id"], "2027-01-01") == []
|
|
|
|
def test_scoped_to_the_requesting_user(self, seed_health, user, db):
|
|
seed_health(DAYS)
|
|
db.execute(
|
|
"INSERT INTO users (id, email, garmin_email, garmin_password_hash) "
|
|
"VALUES (?,?,?,?)",
|
|
["other-user", "other@example.com", "o@example.com", "x"],
|
|
)
|
|
db.execute(
|
|
"INSERT INTO health_data (id, user_id, date, steps) VALUES (?,?,?,?)",
|
|
["other-1", "other-user", "2026-08-20", 99999],
|
|
)
|
|
rows = health_svc.get_summary(user["id"])
|
|
assert all(r["steps"] != 99999 for r in rows)
|
|
assert len(rows) == 3
|
|
|
|
|
|
class TestMetricReads:
|
|
"""Metric endpoints must drop rows where that metric is NULL."""
|
|
|
|
def test_steps_excludes_nulls(self, seed_health, user):
|
|
seed_health(DAYS + [{"date": "2026-08-23", "heart_rate": 60}])
|
|
rows = health_svc.get_steps(user["id"])
|
|
assert len(rows) == 3
|
|
assert all(r["steps"] is not None for r in rows)
|
|
|
|
def test_heart_rate_excludes_nulls(self, seed_health, user):
|
|
seed_health(DAYS + [{"date": "2026-08-23", "steps": 100}])
|
|
rows = health_svc.get_heart_rate(user["id"])
|
|
assert len(rows) == 3
|
|
|
|
def test_sleep_excludes_nulls(self, seed_health, user):
|
|
seed_health(DAYS + [{"date": "2026-08-23", "steps": 100}])
|
|
rows = health_svc.get_sleep(user["id"])
|
|
assert len(rows) == 3
|
|
assert rows[0] == {"date": "2026-08-20", "duration": 6, "quality": 80}
|
|
|
|
|
|
class TestUpsert:
|
|
def test_insert_creates_row(self, db, user):
|
|
health_svc.upsert_health_daily(
|
|
user["id"], {"date": "2026-08-20", "steps": 5000}
|
|
)
|
|
rows = health_svc.get_summary(user["id"])
|
|
assert len(rows) == 1 and rows[0]["steps"] == 5000
|
|
|
|
def test_second_upsert_updates_instead_of_duplicating(self, db, user):
|
|
health_svc.upsert_health_daily(
|
|
user["id"], {"date": "2026-08-20", "steps": 5000}
|
|
)
|
|
health_svc.upsert_health_daily(
|
|
user["id"], {"date": "2026-08-20", "steps": 8000}
|
|
)
|
|
rows = health_svc.get_summary(user["id"])
|
|
assert len(rows) == 1, "re-syncing a day must not duplicate it"
|
|
assert rows[0]["steps"] == 8000
|
|
|
|
def test_upsert_is_deterministic_by_user_and_date(self, db, user):
|
|
a = health_svc.upsert_health_daily(user["id"], {"date": "2026-08-20"})
|
|
b = health_svc.upsert_health_daily(user["id"], {"date": "2026-08-20"})
|
|
assert a == b
|
|
|
|
def test_metrics_with_no_reading_are_omitted(self, db, user):
|
|
"""Absent rather than null: a year of 40 metrics was 394 KB over the
|
|
tunnel and most of it was nulls for sensors this watch lacks. The UI
|
|
reads a missing key and a null the same way."""
|
|
health_svc.upsert_health_daily(
|
|
user["id"], {"date": "2026-08-20", "steps": 100}
|
|
)
|
|
row = health_svc.get_summary(user["id"])[0]
|
|
assert row["steps"] == 100
|
|
assert "heartRate" not in row
|
|
assert row.get("heartRate") is None, "reading it must still be falsy"
|
|
assert row["sleep"] is None
|
|
|
|
|
|
class TestActivities:
|
|
def test_insert_and_read_back(self, db, user):
|
|
health_svc.insert_activity(
|
|
user["id"],
|
|
{
|
|
"activityType": "running",
|
|
"startTime": "2026-08-20T07:00:00",
|
|
"endTime": "2026-08-20T07:30:00",
|
|
"duration": 1800,
|
|
"distance": 5.0,
|
|
"calories": 320,
|
|
"heartRateAverage": 140,
|
|
"heartRateMax": 165,
|
|
},
|
|
)
|
|
rows = health_svc.get_activities(user["id"])
|
|
assert len(rows) == 1
|
|
assert rows[0]["activity_type"] == "running"
|
|
assert rows[0]["distance"] == 5.0
|
|
|
|
def test_ids_are_unique_per_insert(self, db, user):
|
|
base = {
|
|
"activityType": "running",
|
|
"startTime": "2026-08-20T07:00:00",
|
|
"endTime": "2026-08-20T07:30:00",
|
|
}
|
|
assert health_svc.insert_activity(user["id"], base) != health_svc.insert_activity(
|
|
user["id"], base
|
|
)
|
|
|
|
|
|
class TestEndpoints:
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
["/api/health/summary", "/api/health/steps", "/api/health/heart-rate",
|
|
"/api/health/sleep", "/api/health/activities"],
|
|
)
|
|
def test_require_authentication(self, client, path):
|
|
assert client.get(path).status_code == 401
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
["/api/health/summary", "/api/health/steps", "/api/health/heart-rate",
|
|
"/api/health/sleep", "/api/health/activities"],
|
|
)
|
|
def test_return_json_list_when_authenticated(self, client, auth, path):
|
|
r = client.get(path, headers=auth)
|
|
assert r.status_code == 200
|
|
assert isinstance(r.get_json(), list)
|
|
|
|
def test_summary_reflects_seeded_data(self, client, auth, seed_health):
|
|
seed_health(DAYS)
|
|
body = client.get("/api/health/summary", headers=auth).get_json()
|
|
assert len(body) == 3
|
|
|
|
def test_query_params_are_applied(self, client, auth, seed_health):
|
|
seed_health(DAYS)
|
|
body = client.get(
|
|
"/api/health/summary?startDate=2026-08-22", headers=auth
|
|
).get_json()
|
|
assert len(body) == 1
|
|
|
|
|
|
class TestBadgesAndRecords:
|
|
"""Badges ("奖励") and personal records are account-wide, not per-day."""
|
|
|
|
BADGE = {
|
|
"id": "1822", "badgeKey": "sleep_30_days", "name": "Sleep Savant",
|
|
"categoryId": 3, "difficultyId": 2, "earnedDate": "2026-08-01T10:00:00",
|
|
"earnedCount": 1, "points": 5,
|
|
}
|
|
|
|
def test_badge_round_trips(self, db, user):
|
|
health_svc.upsert_badge(user["id"], self.BADGE)
|
|
rows = health_svc.get_badges(user["id"])
|
|
assert len(rows) == 1
|
|
assert rows[0]["name"] == "Sleep Savant"
|
|
assert rows[0]["badge_key"] == "sleep_30_days"
|
|
|
|
def test_resync_updates_rather_than_duplicating(self, db, user):
|
|
health_svc.upsert_badge(user["id"], self.BADGE)
|
|
health_svc.upsert_badge(user["id"], {**self.BADGE, "earnedCount": 2})
|
|
rows = health_svc.get_badges(user["id"])
|
|
assert len(rows) == 1
|
|
assert rows[0]["earned_count"] == 2
|
|
|
|
def test_badges_are_per_user(self, db, user, client):
|
|
health_svc.upsert_badge(user["id"], self.BADGE)
|
|
other = client.post(
|
|
"/api/auth/register",
|
|
json={"email": "b@example.com", "garminEmail": "bg@example.com",
|
|
"garminPassword": "pw123456"},
|
|
).get_json()
|
|
assert health_svc.get_badges(other["id"]) == []
|
|
|
|
def test_two_users_may_hold_the_same_badge_id(self, db, user, client):
|
|
"""The key is (user, badge), so the same Garmin badge on two accounts
|
|
must not collide."""
|
|
other = client.post(
|
|
"/api/auth/register",
|
|
json={"email": "c@example.com", "garminEmail": "cg@example.com",
|
|
"garminPassword": "pw123456"},
|
|
).get_json()
|
|
health_svc.upsert_badge(user["id"], self.BADGE)
|
|
health_svc.upsert_badge(other["id"], self.BADGE)
|
|
assert len(health_svc.get_badges(user["id"])) == 1
|
|
assert len(health_svc.get_badges(other["id"])) == 1
|
|
|
|
def test_personal_record_round_trips(self, db, user):
|
|
health_svc.upsert_personal_record(user["id"], {
|
|
"id": "2538883970", "typeId": 1, "activityId": "17446848459",
|
|
"activityName": "晨跑", "activityType": "running",
|
|
"value": 1234.5, "achievedAt": "2026-08-01T07:00:00",
|
|
})
|
|
rows = health_svc.get_personal_records(user["id"])
|
|
assert len(rows) == 1 and rows[0]["activity_name"] == "晨跑"
|
|
|
|
def test_endpoints_require_auth(self, client):
|
|
assert client.get("/api/health/badges").status_code == 401
|
|
assert client.get("/api/health/personal-records").status_code == 401
|
|
|
|
def test_endpoints_return_lists(self, client, auth):
|
|
assert isinstance(client.get("/api/health/badges", headers=auth).get_json(), list)
|
|
assert isinstance(
|
|
client.get("/api/health/personal-records", headers=auth).get_json(), list
|
|
)
|
|
|
|
|
|
class TestActivityDateRange:
|
|
"""Regression: activities were filtered with the daily-metrics range
|
|
clause, which references a `date` column the activities table does not
|
|
have. It only failed once a caller actually passed a range."""
|
|
|
|
def seed(self, user):
|
|
for stamp in ("2026-08-20T07:00:00", "2026-08-21T18:30:00",
|
|
"2026-08-22T09:15:00"):
|
|
health_svc.insert_activity(user["id"], {
|
|
"activityType": "running",
|
|
"startTime": stamp,
|
|
"endTime": stamp,
|
|
"duration": 1800,
|
|
})
|
|
|
|
def test_single_day_range_does_not_raise(self, db, user):
|
|
self.seed(user)
|
|
rows = health_svc.get_activities(user["id"], "2026-08-21", "2026-08-21")
|
|
assert len(rows) == 1
|
|
assert rows[0]["start_time"].startswith("2026-08-21")
|
|
|
|
def test_range_bounds_are_inclusive_of_the_whole_day(self, db, user):
|
|
"""An activity at 18:30 must fall inside its own day."""
|
|
self.seed(user)
|
|
rows = health_svc.get_activities(user["id"], "2026-08-20", "2026-08-22")
|
|
assert len(rows) == 3
|
|
|
|
def test_start_only(self, db, user):
|
|
self.seed(user)
|
|
assert len(health_svc.get_activities(user["id"], "2026-08-21")) == 2
|
|
|
|
def test_end_only(self, db, user):
|
|
self.seed(user)
|
|
assert len(health_svc.get_activities(user["id"], None, "2026-08-21")) == 2
|
|
|
|
def test_no_range_returns_everything(self, db, user):
|
|
self.seed(user)
|
|
assert len(health_svc.get_activities(user["id"])) == 3
|
|
|
|
def test_endpoint_with_a_date_range(self, client, auth, user, db):
|
|
self.seed(user)
|
|
r = client.get(
|
|
"/api/health/activities?startDate=2026-08-21&endDate=2026-08-21",
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200
|
|
assert len(r.get_json()) == 1
|