diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..4501b85 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +addopts = -q --strict-markers +filterwarnings = + ignore::DeprecationWarning + ignore::UserWarning diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..d5b799d --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt + +pytest>=7.4 +pytest-cov>=4.1 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..74f0a08 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,98 @@ +""" +Shared pytest fixtures. + +Environment must be configured BEFORE `config` is imported, because config.py +reads os.environ at import time. Each test then gets its own SQLite file so +tests never share state. +""" +import os +import sys +import tempfile + +import pytest + +_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _BACKEND_DIR) + +os.environ.setdefault("DB_TYPE", "sqlite") +os.environ.setdefault("JWT_SECRET", "test_secret_at_least_32_bytes_long_ok") +os.environ.setdefault("CORS_ORIGIN", "http://localhost:3000") +# Point at a throwaway path; the db_path fixture overrides it per test. +os.environ.setdefault( + "DATABASE_PATH", os.path.join(tempfile.mkdtemp(), "bootstrap.db") +) + +import db as db_module # noqa: E402 +from app import create_app # noqa: E402 + + +@pytest.fixture +def db(tmp_path, monkeypatch): + """A freshly initialized, isolated SQLite database for one test.""" + path = str(tmp_path / "test.db") + monkeypatch.setattr(db_module, "SQLITE_PATH", path) + db_module.init_db() + return db_module + + +@pytest.fixture +def app(db): + application = create_app() + application.config.update(TESTING=True) + return application + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def user(client): + """A registered user: returns {id, email, token, password}.""" + password = "secret123" + resp = client.post( + "/api/auth/register", + json={ + "email": "tester@example.com", + "garminEmail": "gm@example.com", + "garminPassword": password, + }, + ) + assert resp.status_code == 201, resp.get_data(as_text=True) + body = resp.get_json() + return {**body, "password": password} + + +@pytest.fixture +def auth(user): + """Authorization headers for the registered user.""" + return {"Authorization": f"Bearer {user['token']}"} + + +@pytest.fixture +def seed_health(db, user): + """Insert daily health rows. Returns the inserted records.""" + + def _seed(records): + for r in records: + db.execute( + "INSERT INTO health_data (id, user_id, date, steps, heart_rate, " + "heart_rate_variability, sleep_duration, sleep_quality, stress, " + "calories_burned) VALUES (?,?,?,?,?,?,?,?,?,?)", + [ + f"{user['id']}-{r['date']}", + user["id"], + r["date"], + r.get("steps"), + r.get("heart_rate"), + r.get("hrv"), + r.get("sleep_duration"), + r.get("sleep_quality"), + r.get("stress"), + r.get("calories"), + ], + ) + return records + + return _seed diff --git a/backend/tests/test_analysis.py b/backend/tests/test_analysis.py new file mode 100644 index 0000000..7cdfc21 --- /dev/null +++ b/backend/tests/test_analysis.py @@ -0,0 +1,213 @@ +"""Unit tests for the trends query and the rule-based recommendation engine.""" +import pytest + +from services import analysis as analysis_svc + + +def ids(recs): + return {r["id"] for r in recs} + + +def days(n, **metrics): + """n consecutive days that all carry the same metric values.""" + return [ + {"date": f"2026-08-{d:02d}", **metrics} for d in range(1, n + 1) + ] + + +# --- trends ----------------------------------------------------------------- +class TestTrends: + def test_empty_without_data(self, db, user): + assert analysis_svc.get_trends("steps", user["id"]) == [] + + def test_returns_date_value_pairs(self, seed_health, user): + seed_health([{"date": "2026-08-20", "steps": 5000}]) + assert analysis_svc.get_trends("steps", user["id"]) == [ + {"date": "2026-08-20", "value": 5000} + ] + + @pytest.mark.parametrize( + "metric,column_value", + [ + ("steps", {"steps": 5000}), + ("heart_rate", {"heart_rate": 60}), + ("sleep_duration", {"sleep_duration": 7}), + ("stress", {"stress": 30}), + ("calories_burned", {"calories": 250}), + ], + ) + def test_each_supported_metric(self, seed_health, user, metric, column_value): + seed_health([{"date": "2026-08-20", **column_value}]) + rows = analysis_svc.get_trends(metric, user["id"]) + assert len(rows) == 1 and rows[0]["value"] is not None + + def test_unknown_metric_falls_back_to_steps(self, seed_health, user): + seed_health([{"date": "2026-08-20", "steps": 5000}]) + assert analysis_svc.get_trends("bogus", user["id"]) == [ + {"date": "2026-08-20", "value": 5000} + ] + + def test_unknown_metric_cannot_inject_sql(self, seed_health, user): + """The metric name indexes a whitelist; it is never interpolated raw.""" + seed_health([{"date": "2026-08-20", "steps": 5000}]) + rows = analysis_svc.get_trends( + "steps FROM health_data; DROP TABLE health_data --", user["id"] + ) + assert rows == [{"date": "2026-08-20", "value": 5000}] + + def test_nulls_excluded(self, seed_health, user): + seed_health([ + {"date": "2026-08-20", "steps": 5000}, + {"date": "2026-08-21"}, + ]) + assert len(analysis_svc.get_trends("steps", user["id"])) == 1 + + def test_date_range_filter(self, seed_health, user): + seed_health([ + {"date": "2026-08-20", "steps": 1}, + {"date": "2026-08-21", "steps": 2}, + {"date": "2026-08-22", "steps": 3}, + ]) + rows = analysis_svc.get_trends( + "steps", user["id"], "2026-08-21", "2026-08-21" + ) + assert rows == [{"date": "2026-08-21", "value": 2}] + + +# --- recommendations -------------------------------------------------------- +class TestNoData: + def test_returns_a_single_guidance_item(self, db, user): + recs = analysis_svc.get_recommendations(user["id"]) + assert len(recs) == 1 + assert recs[0]["id"] == "no-data" + assert recs[0]["priority"] == "low" + + +class TestStepsRule: + def test_fires_below_8000(self, seed_health, user): + seed_health(days(5, steps=6000)) + assert "steps" in ids(analysis_svc.get_recommendations(user["id"])) + + def test_silent_at_or_above_8000(self, seed_health, user): + seed_health(days(5, steps=8000)) + assert "steps" not in ids(analysis_svc.get_recommendations(user["id"])) + + def test_priority_is_medium(self, seed_health, user): + seed_health(days(5, steps=6000)) + rec = next( + r for r in analysis_svc.get_recommendations(user["id"]) if r["id"] == "steps" + ) + assert rec["priority"] == "medium" + assert rec["basedOn"] == ["steps"] + + def test_averages_across_days_not_per_day(self, seed_health, user): + # 4000 and 12000 average to 8000 -> rule must NOT fire. + seed_health([ + {"date": "2026-08-01", "steps": 4000}, + {"date": "2026-08-02", "steps": 12000}, + ]) + assert "steps" not in ids(analysis_svc.get_recommendations(user["id"])) + + +class TestSleepRule: + def test_fires_below_7_hours(self, seed_health, user): + seed_health(days(5, sleep_duration=6)) + assert "sleep" in ids(analysis_svc.get_recommendations(user["id"])) + + def test_silent_at_7_hours(self, seed_health, user): + seed_health(days(5, sleep_duration=7)) + assert "sleep" not in ids(analysis_svc.get_recommendations(user["id"])) + + def test_priority_is_high(self, seed_health, user): + seed_health(days(5, sleep_duration=5)) + rec = next( + r for r in analysis_svc.get_recommendations(user["id"]) if r["id"] == "sleep" + ) + assert rec["priority"] == "high" + + def test_days_without_sleep_do_not_drag_the_average_down(self, seed_health, user): + """Nights with no sleep record must be excluded, not counted as zero.""" + seed_health([ + {"date": "2026-08-01", "sleep_duration": 8}, + {"date": "2026-08-02", "steps": 5000}, # no sleep recorded + ]) + assert "sleep" not in ids(analysis_svc.get_recommendations(user["id"])) + + +class TestStressRule: + def test_fires_above_50(self, seed_health, user): + seed_health(days(5, stress=60)) + assert "stress" in ids(analysis_svc.get_recommendations(user["id"])) + + def test_silent_at_50(self, seed_health, user): + seed_health(days(5, stress=50)) + assert "stress" not in ids(analysis_svc.get_recommendations(user["id"])) + + +class TestRestingHeartRateRule: + def test_fires_above_65(self, seed_health, user): + seed_health(days(5, heart_rate=70)) + assert "rhr" in ids(analysis_svc.get_recommendations(user["id"])) + + def test_silent_at_65(self, seed_health, user): + seed_health(days(5, heart_rate=65)) + assert "rhr" not in ids(analysis_svc.get_recommendations(user["id"])) + + +class TestHrvRule: + def test_fires_below_40(self, seed_health, user): + seed_health(days(5, hrv=30)) + assert "hrv" in ids(analysis_svc.get_recommendations(user["id"])) + + def test_silent_at_40(self, seed_health, user): + seed_health(days(5, hrv=40)) + assert "hrv" not in ids(analysis_svc.get_recommendations(user["id"])) + + +class TestHealthyUser: + def test_all_good_returns_the_positive_message(self, seed_health, user): + seed_health(days(5, steps=10000, sleep_duration=8, stress=30, + heart_rate=55, hrv=60)) + recs = analysis_svc.get_recommendations(user["id"]) + assert ids(recs) == {"good"} + + +class TestOrderingAndWindow: + def test_sorted_high_medium_low(self, seed_health, user): + seed_health(days(5, steps=6000, sleep_duration=5, hrv=30)) + order = {"high": 0, "medium": 1, "low": 2} + priorities = [r["priority"] for r in analysis_svc.get_recommendations(user["id"])] + assert priorities == sorted(priorities, key=lambda p: order[p]) + assert priorities[0] == "high" + + def test_only_the_last_14_days_count(self, seed_health, user): + """20 lazy days then 14 active ones: the old days must not pull it down.""" + old = [{"date": f"2026-07-{d:02d}", "steps": 1000} for d in range(1, 21)] + recent = [{"date": f"2026-08-{d:02d}", "steps": 12000} for d in range(1, 15)] + seed_health(old + recent) + assert "steps" not in ids(analysis_svc.get_recommendations(user["id"])) + + def test_multiple_rules_can_fire_together(self, seed_health, user): + seed_health(days(5, steps=5000, sleep_duration=5, stress=70, heart_rate=75)) + assert {"steps", "sleep", "stress", "rhr"} <= ids( + analysis_svc.get_recommendations(user["id"]) + ) + + +class TestEndpoints: + def test_trends_requires_auth(self, client): + assert client.get("/api/analysis/trends").status_code == 401 + + def test_recommendations_requires_auth(self, client): + assert client.get("/api/analysis/recommendations").status_code == 401 + + def test_trends_endpoint(self, client, auth, seed_health): + seed_health(days(3, steps=5000)) + r = client.get("/api/analysis/trends?metricType=steps", headers=auth) + assert r.status_code == 200 and len(r.get_json()) == 3 + + def test_recommendations_endpoint(self, client, auth, seed_health): + seed_health(days(3, steps=5000)) + r = client.get("/api/analysis/recommendations", headers=auth) + assert r.status_code == 200 + assert "steps" in {x["id"] for x in r.get_json()} diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..89fd5d8 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,233 @@ +"""Unit tests for password hashing, JWT handling, and the auth endpoints.""" +import datetime +import hashlib +import os + +import jwt +import pytest + +import auth +from config import JWT_SECRET + + +# --- password hashing ------------------------------------------------------- +class TestPasswordHashing: + def test_hash_is_verifiable(self): + stored = auth.hash_password("correct horse") + assert auth.verify_password("correct horse", stored) is True + + def test_wrong_password_rejected(self): + stored = auth.hash_password("correct horse") + assert auth.verify_password("wrong horse", stored) is False + + def test_salt_makes_hashes_unique(self): + a = auth.hash_password("same") + b = auth.hash_password("same") + assert a != b, "identical passwords must not produce identical hashes" + + def test_hash_format_is_self_describing(self): + stored = auth.hash_password("pw") + prefix, iterations, salt, digest = stored.split("$") + assert prefix == auth.PBKDF2_PREFIX + assert int(iterations) == auth.PBKDF2_ITERATIONS + assert len(bytes.fromhex(salt)) == 16 + assert len(bytes.fromhex(digest)) == 64 + + def test_password_never_appears_in_hash(self): + stored = auth.hash_password("supersecret") + assert "supersecret" not in stored + + @pytest.mark.parametrize( + "stored", + ["", None, "garbage", "pbkdf2_sha256$notanint$aa$bb", "nothex:nothex"], + ) + def test_malformed_hashes_rejected_not_raised(self, stored): + assert auth.verify_password("anything", stored) is False + + def test_hashing_does_not_require_scrypt(self, monkeypatch): + """Regression: macOS system Python (LibreSSL) has no hashlib.scrypt. + + Registration used to raise AttributeError -> HTTP 500 on those builds. + """ + monkeypatch.delattr(hashlib, "scrypt", raising=False) + stored = auth.hash_password("pw") + assert auth.verify_password("pw", stored) is True + + def test_unicode_password(self): + stored = auth.hash_password("密码🔒") + assert auth.verify_password("密码🔒", stored) is True + assert auth.verify_password("密码", stored) is False + + @pytest.mark.skipif( + not hasattr(hashlib, "scrypt"), reason="interpreter built without scrypt" + ) + def test_legacy_scrypt_hash_still_verifies(self): + salt = os.urandom(16) + digest = hashlib.scrypt( + b"legacy", salt=salt, n=16384, r=8, p=1, dklen=64 + ).hex() + assert auth.verify_password("legacy", f"{salt.hex()}:{digest}") is True + assert auth.verify_password("nope", f"{salt.hex()}:{digest}") is False + + +# --- JWT -------------------------------------------------------------------- +class TestTokens: + def test_sign_and_verify_roundtrip(self): + token = auth.sign_token("user-123") + assert auth.verify_token(token) == {"user_id": "user-123"} + + def test_expired_token_rejected(self): + past = datetime.datetime.utcnow() - datetime.timedelta(days=1) + token = jwt.encode( + {"sub": "u", "iat": past, "exp": past}, JWT_SECRET, algorithm="HS256" + ) + with pytest.raises(jwt.ExpiredSignatureError): + auth.verify_token(token) + + def test_token_signed_with_other_secret_rejected(self): + token = jwt.encode({"sub": "u"}, "a-different-secret", algorithm="HS256") + with pytest.raises(jwt.InvalidTokenError): + auth.verify_token(token) + + def test_tampered_token_rejected(self): + token = auth.sign_token("user-123") + head, payload, sig = token.split(".") + with pytest.raises(jwt.InvalidTokenError): + auth.verify_token(f"{head}.{payload}.{sig[:-2]}xx") + + +# --- register --------------------------------------------------------------- +class TestRegister: + def test_returns_201_and_token(self, client): + r = client.post( + "/api/auth/register", + json={ + "email": "a@example.com", + "garminEmail": "g@example.com", + "garminPassword": "pw123456", + }, + ) + assert r.status_code == 201 + body = r.get_json() + assert body["email"] == "a@example.com" + assert auth.verify_token(body["token"])["user_id"] == body["id"] + + def test_duplicate_email_conflicts(self, client, user): + r = client.post( + "/api/auth/register", + json={ + "email": user["email"], + "garminEmail": "other@example.com", + "garminPassword": "pw123456", + }, + ) + assert r.status_code == 409 + + @pytest.mark.parametrize( + "payload", + [ + {}, + {"email": "a@example.com"}, + {"email": "a@example.com", "garminEmail": "g@example.com"}, + {"email": "", "garminEmail": "g@example.com", "garminPassword": "x"}, + ], + ) + def test_missing_fields_rejected(self, client, payload): + assert client.post("/api/auth/register", json=payload).status_code == 400 + + def test_password_stored_only_as_hash(self, client, db): + client.post( + "/api/auth/register", + json={ + "email": "h@example.com", + "garminEmail": "g@example.com", + "garminPassword": "plaintext-secret", + }, + ) + row = db.query_one( + "SELECT garmin_password_hash FROM users WHERE email = ?", ["h@example.com"] + ) + assert "plaintext-secret" not in row["garmin_password_hash"] + assert auth.verify_password("plaintext-secret", row["garmin_password_hash"]) + + +# --- login ------------------------------------------------------------------ +class TestLogin: + def test_valid_credentials(self, client, user): + r = client.post( + "/api/auth/login", + json={"email": user["email"], "password": user["password"]}, + ) + assert r.status_code == 200 + assert r.get_json()["id"] == user["id"] + + def test_wrong_password(self, client, user): + r = client.post( + "/api/auth/login", json={"email": user["email"], "password": "nope"} + ) + assert r.status_code == 401 + + def test_unknown_email(self, client): + r = client.post( + "/api/auth/login", json={"email": "ghost@example.com", "password": "pw"} + ) + assert r.status_code == 401 + + def test_error_does_not_reveal_which_field_was_wrong(self, client, user): + unknown = client.post( + "/api/auth/login", json={"email": "ghost@example.com", "password": "pw"} + ).get_json() + bad_pw = client.post( + "/api/auth/login", json={"email": user["email"], "password": "nope"} + ).get_json() + assert unknown == bad_pw, "responses must not distinguish the two cases" + + +# --- require_auth ----------------------------------------------------------- +class TestRequireAuth: + def test_missing_header(self, client): + assert client.get("/api/health/summary").status_code == 401 + + @pytest.mark.parametrize( + "header", + ["", "Bearer", "Token abc", "bearer abc", "Bearer not.a.jwt"], + ) + def test_malformed_headers(self, client, header): + r = client.get("/api/health/summary", headers={"Authorization": header}) + assert r.status_code == 401 + + def test_expired_token_gets_401_not_500(self, client): + past = datetime.datetime.utcnow() - datetime.timedelta(days=1) + token = jwt.encode( + {"sub": "u", "iat": past, "exp": past}, JWT_SECRET, algorithm="HS256" + ) + r = client.get( + "/api/health/summary", headers={"Authorization": f"Bearer {token}"} + ) + assert r.status_code == 401 + assert "expired" in r.get_json()["error"] + + def test_valid_token_passes(self, client, auth): + assert client.get("/api/health/summary", headers=auth).status_code == 200 + + +class TestLogoutAndRefresh: + def test_logout_clears_stored_token(self, client, auth, user, db): + assert client.post("/api/auth/logout", headers=auth).status_code == 200 + row = db.query_one("SELECT jwt_token FROM users WHERE id = ?", [user["id"]]) + assert row["jwt_token"] is None + + def test_refresh_returns_usable_token(self, client, auth): + r = client.post("/api/auth/refresh", headers=auth) + assert r.status_code == 200 + new_token = r.get_json()["token"] + assert ( + client.get( + "/api/health/summary", + headers={"Authorization": f"Bearer {new_token}"}, + ).status_code + == 200 + ) + + def test_refresh_requires_auth(self, client): + assert client.post("/api/auth/refresh").status_code == 401 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..3f5687e --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,189 @@ +"""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