"""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