Files
GarminHealthLab/backend/tests/test_settings.py
ericwyuan 9503fca370 feat(auth): 接入 auth-hub 统一登录,网页登录与 Garmin 同步彻底分离
网页身份改由 auth-hub 做 OAuth2 + PKCE 单点登录,本地邮箱/密码登录与注册整条链路删除
(routes/auth.py、auth.py 的密码哈希、config.py 的 ALLOW_REGISTRATION)。Garmin 账号绑定/
同步保持完全独立、可选:routes/garmin.py 不再直接查 users 表,Garmin 邮箱回退统一走新增
的 services/garmin.py::get_remembered_email()(优先读 garmin_tokens 当前绑定,兼容早期账号
落在 users.garmin_email 的历史值),彻底把「你是谁」和「你绑没绑 Garmin」两件事拆开。

- db.py: users 表新增 auth_hub_sub/auth_hub_username,MIGRATIONS 补上这两列(此前遗漏导致
  已存在的生产 MariaDB 表永远不会自动加列);同时把历史遗留的 garmin_email/
  garmin_password_hash NOT NULL 约束在线迁移为可空,因为新账号不再在注册时收集这些字段。
- routes/auth.py: 修掉 /callback 路由重复拼接 /api/auth 前缀导致 404 的 bug。
- client: LoginPage 去掉本地登录/注册标签页,只保留 auth-hub 统一登录;登录成功/失败后都
  用 history.replaceState 清理地址栏,修掉 Framework7 browserHistory 读取
  /auth/callback?code=... 导致「找不到页面」的问题。
- 新增 test_auth_hub_client.py 锁定 find_or_create_user 按 auth_hub_sub 幂等——生产上曾经因为
  这个函数在没有该测试保护时被测试触发,误建过一个空账号,靠手工核对 health_data 计数才发现。
- 生产 auth-hub 侧另行为该项目注册了正式 client(未随本次提交变更,凭证只存在服务器 .env)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 23:12:17 +08:00

247 lines
9.9 KiB
Python

"""
Profile, units and sync preferences.
The interesting behaviour is the validation boundary: a body measurement that
is out of range is a user mistake and must be rejected with a message they can
act on, while an off-list picker value is a stale client and must be snapped
rather than refused.
"""
import datetime
import pytest
from services import settings as svc
class TestDefaults:
def test_unset_account_gets_defaults(self, db, user):
s = svc.get_settings(user["id"])
assert s["units"] == "metric"
assert s["autoSync"] is True
assert s["autoSyncMinutes"] == 60
assert s["historyDays"] == 365
def test_body_fields_start_empty(self, db, user):
s = svc.get_settings(user["id"])
assert s["heightCm"] is None
assert s["weightKg"] is None
assert s["birthDate"] is None
assert s["sex"] is None
def test_derived_fields_are_none_without_inputs(self, db, user):
s = svc.get_settings(user["id"])
assert s["age"] is None
assert s["bmi"] is None
class TestSaving:
def test_round_trip(self, db, user):
svc.save_settings(user["id"], {"heightCm": 178, "weightKg": 72})
s = svc.get_settings(user["id"])
assert s["heightCm"] == 178
assert s["weightKg"] == 72
def test_partial_patch_leaves_the_rest_alone(self, db, user):
svc.save_settings(user["id"], {"heightCm": 178, "sex": "male"})
svc.save_settings(user["id"], {"weightKg": 70})
s = svc.get_settings(user["id"])
assert s["heightCm"] == 178, "an unrelated field must survive a patch"
assert s["sex"] == "male"
assert s["weightKg"] == 70
def test_clearing_a_field(self, db, user):
svc.save_settings(user["id"], {"heightCm": 178})
svc.save_settings(user["id"], {"heightCm": None})
assert svc.get_settings(user["id"])["heightCm"] is None
def test_unknown_keys_are_ignored_not_rejected(self, db, user):
"""An older or newer client posting a field we do not know about must
still save the fields we do."""
svc.save_settings(user["id"], {"heightCm": 170, "favouriteColour": "blue"})
assert svc.get_settings(user["id"])["heightCm"] == 170
def test_empty_patch_is_a_no_op(self, db, user):
svc.save_settings(user["id"], {"heightCm": 180})
svc.save_settings(user["id"], {})
assert svc.get_settings(user["id"])["heightCm"] == 180
def test_settings_are_per_account(self, db, user, make_user):
other = make_user("b@example.com")
svc.save_settings(user["id"], {"heightCm": 178})
svc.save_settings(other["id"], {"heightCm": 160})
assert svc.get_settings(user["id"])["heightCm"] == 178
assert svc.get_settings(other["id"])["heightCm"] == 160
class TestValidation:
@pytest.mark.parametrize("value", [79, 251, -5, 0])
def test_height_out_of_range(self, db, user, value):
with pytest.raises(svc.InvalidSetting):
svc.save_settings(user["id"], {"heightCm": value})
@pytest.mark.parametrize("value", [24, 301])
def test_weight_out_of_range(self, db, user, value):
with pytest.raises(svc.InvalidSetting):
svc.save_settings(user["id"], {"weightKg": value})
def test_non_numeric_height(self, db, user):
with pytest.raises(svc.InvalidSetting):
svc.save_settings(user["id"], {"heightCm": "tall"})
def test_message_names_the_field(self, db, user):
with pytest.raises(svc.InvalidSetting) as e:
svc.save_settings(user["id"], {"heightCm": 500})
assert "身高" in str(e.value)
def test_birth_date_must_be_a_date(self, db, user):
with pytest.raises(svc.InvalidSetting):
svc.save_settings(user["id"], {"birthDate": "1990/05/04"})
def test_birth_date_cannot_be_in_the_future(self, db, user):
future = (datetime.date.today() + datetime.timedelta(days=1)).isoformat()
with pytest.raises(svc.InvalidSetting):
svc.save_settings(user["id"], {"birthDate": future})
def test_birth_date_cannot_be_absurdly_old(self, db, user):
with pytest.raises(svc.InvalidSetting):
svc.save_settings(user["id"], {"birthDate": "1850-01-01"})
def test_unknown_sex_rejected(self, db, user):
with pytest.raises(svc.InvalidSetting):
svc.save_settings(user["id"], {"sex": "yes"})
def test_unknown_units_rejected(self, db, user):
with pytest.raises(svc.InvalidSetting):
svc.save_settings(user["id"], {"units": "furlongs"})
class TestSnapping:
"""Picker values come from a list the backend published, so an off-list
value means a stale client — snap it rather than fail the whole save."""
def test_off_list_interval_snaps_to_nearest(self, db, user):
svc.save_settings(user["id"], {"autoSyncMinutes": 45})
assert svc.get_settings(user["id"])["autoSyncMinutes"] in svc.INTERVALS
def test_snaps_to_the_actual_nearest(self, db, user):
svc.save_settings(user["id"], {"autoSyncMinutes": 200})
assert svc.get_settings(user["id"])["autoSyncMinutes"] == 180
def test_history_zero_means_everything(self, db, user):
svc.save_settings(user["id"], {"historyDays": 0})
assert svc.get_settings(user["id"])["historyDays"] == 0
def test_negative_history_becomes_everything(self, db, user):
svc.save_settings(user["id"], {"historyDays": -30})
assert svc.get_settings(user["id"])["historyDays"] == 0
def test_garbage_interval_falls_back_to_default(self, db, user):
svc.save_settings(user["id"], {"autoSyncMinutes": "soon"})
assert svc.get_settings(user["id"])["autoSyncMinutes"] == 60
class TestDerived:
def test_age_from_birth_date(self, db, user):
born = datetime.date.today().replace(year=datetime.date.today().year - 30)
svc.save_settings(user["id"], {"birthDate": born.isoformat()})
assert svc.get_settings(user["id"])["age"] == 30
def test_age_on_the_day_before_a_birthday(self, monkeypatch):
real = datetime.date
class Frozen(real):
@classmethod
def today(cls):
return real(2026, 12, 24)
monkeypatch.setattr(svc.datetime, "date", Frozen)
assert svc.age_from("1990-12-25") == 35
def test_age_on_the_birthday(self, monkeypatch):
real = datetime.date
class Frozen(real):
@classmethod
def today(cls):
return real(2026, 12, 25)
monkeypatch.setattr(svc.datetime, "date", Frozen)
assert svc.age_from("1990-12-25") == 36
def test_bmi(self):
assert svc.bmi_from(178, 72) == 22.7
def test_bmi_needs_both(self):
assert svc.bmi_from(178, None) is None
assert svc.bmi_from(None, 72) is None
def test_bmi_appears_in_settings(self, db, user):
svc.save_settings(user["id"], {"heightCm": 180, "weightKg": 81})
assert svc.get_settings(user["id"])["bmi"] == 25.0
class TestEndpoints:
def test_get_requires_auth(self, client):
assert client.get("/api/settings").status_code == 401
def test_put_requires_auth(self, client):
assert client.put("/api/settings", json={}).status_code == 401
def test_get_returns_defaults(self, client, auth):
body = client.get("/api/settings", headers=auth).get_json()
assert body["units"] == "metric"
def test_put_saves_and_returns_the_new_state(self, client, auth):
body = client.put(
"/api/settings", headers=auth, json={"heightCm": 175, "sex": "female"}
).get_json()
assert body["heightCm"] == 175
assert body["sex"] == "female"
def test_put_rejects_bad_input_with_400(self, client, auth):
r = client.put("/api/settings", headers=auth, json={"weightKg": 999})
assert r.status_code == 400
assert "体重" in r.get_json()["error"]
def test_options_lists_only_acceptable_values(self, client, auth):
opts = client.get("/api/settings/options", headers=auth).get_json()
assert set(opts["sexes"]) == set(svc.SEXES)
assert set(opts["autoSyncMinutes"]) == set(svc.INTERVALS)
def test_every_offered_option_is_actually_accepted(self, client, auth):
"""The picker must never offer something the validator would reject."""
opts = client.get("/api/settings/options", headers=auth).get_json()
for sex in opts["sexes"]:
assert client.put("/api/settings", headers=auth,
json={"sex": sex}).status_code == 200
for unit in opts["units"]:
assert client.put("/api/settings", headers=auth,
json={"units": unit}).status_code == 200
for minutes in opts["autoSyncMinutes"]:
r = client.put("/api/settings", headers=auth,
json={"autoSyncMinutes": minutes})
assert r.get_json()["autoSyncMinutes"] == minutes
for days in opts["historyDays"]:
r = client.put("/api/settings", headers=auth, json={"historyDays": days})
assert r.get_json()["historyDays"] == days
class TestRatingBasis:
def test_requires_auth(self, client):
assert client.get("/api/settings/rating-basis").status_code == 401
def test_lists_a_source_for_every_band(self, client, auth):
body = client.get("/api/settings/rating-basis", headers=auth).get_json()
assert body["bands"], "the screen would be empty"
for entry in body["bands"]:
assert entry["metric"] and entry["bands"] and entry["source"], entry
def test_includes_the_fitness_age_method(self, client, auth):
body = client.get("/api/settings/rating-basis", headers=auth).get_json()
assert body["fitnessAge"]["steps"]
assert body["fitnessAge"]["caveat"]
def test_states_that_ai_does_not_set_thresholds(self, client, auth):
body = client.get("/api/settings/rating-basis", headers=auth).get_json()
assert "AI" in body["note"]