测试基建: - 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>
190 lines
7.1 KiB
Python
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
|