""" 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, client): other = client.post( "/api/auth/register", json={"email": "b@example.com", "garminEmail": "bg@example.com", "garminPassword": "pw123456"}, ).get_json() 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"]