Files
GarminHealthLab/backend/tests/test_health.py
ericwyuan 8e37e5a551 [阶段3.1] pytest 测试基建 + auth/health/analysis 单元测试 - 102 用例全绿
测试基建:
- pytest.ini / requirements-dev.txt
- tests/conftest.py: 每个用例独立 SQLite 库,提供
  db / app / client / user / auth / seed_health 六个 fixture

tests/test_auth.py (38 通过, 1 跳过):
- 密码哈希: 加盐唯一性、格式自描述、明文不入库、畸形哈希不抛异常
- 回归用例: 无 scrypt 的解释器上也能哈希(覆盖上一个 commit 的 bug)
- JWT: 过期/换密钥/篡改签名均拒绝
- 登录错误不区分"邮箱不存在"与"密码错误"(防用户枚举)
- require_auth: 缺失/畸形/过期 header 一律 401 而非 500

tests/test_health.py (30 通过):
- 日期范围上下界均为闭区间
- 数据按 user_id 隔离,查不到他人数据
- 重复 upsert 同一天不产生重复行
- 各指标端点正确剔除 NULL 行

tests/test_analysis.py (34 通过):
- 5 条建议规则的阈值边界逐条固化(8000 步 / 7 小时 / 50 压力 /
  65 静息心率 / 40 HRV)
- 均值按窗口计算而非逐日;只取最近 14 天
- 无睡眠记录的日子不被当作 0 拉低均值
- metric 名走白名单,SQL 注入串不生效
- 建议按 high/medium/low 排序

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 12:34:18 +08:00

190 lines
7.1 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
assert row["sleep"] == {"duration": 6, "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_missing_metrics_stored_as_null(self, db, user):
health_svc.upsert_health_daily(
user["id"], {"date": "2026-08-20", "steps": 100}
)
row = health_svc.get_summary(user["id"])[0]
assert row["heartRate"] is None
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