"""Contract tests of the vape module API (datamodel-health-vape.md §8.4).""" from collections.abc import Callable from datetime import UTC, datetime, timedelta from typing import Any from zoneinfo import ZoneInfo import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.modules.auth.models import User PARIS = ZoneInfo("Europe/Paris") BASE = "/api/vape" def _today() -> Any: return datetime.now(PARIS).date() def _day(offset: int) -> str: return (_today() + timedelta(days=offset)).isoformat() def _settings_payload(**overrides: Any) -> dict[str, Any]: payload = { "quit_date": _day(-10), "cigs_per_day_before": 15, "cig_pack_price_cents": 1250, "cigs_per_pack": 20, "default_nicotine_mg_ml": 6, } payload.update(overrides) return payload def _put_settings(client: TestClient, headers: dict[str, str], **overrides: Any) -> Any: response = client.put( f"{BASE}/settings", json=_settings_payload(**overrides), headers=headers ) assert response.status_code == 200, response.text return response.json() def _create_product( client: TestClient, headers: dict[str, str], **payload: Any ) -> dict[str, Any]: response = client.post(f"{BASE}/products", json=payload, headers=headers) assert response.status_code == 201, response.text return response.json() def _catalog(client: TestClient, headers: dict[str, str]) -> dict[str, dict[str, Any]]: """Base 1 L / booster 10 ml 20 mg / aroma 30 ml / coil box of 5.""" return { "base": _create_product( client, headers, kind="base", name="Base 50/50 1 L", price_cents=1200, size_value=1000, size_unit="ml", vg_pct=50, ), "booster": _create_product( client, headers, kind="booster", name="Booster 20 mg", price_cents=90, size_value=10, size_unit="ml", nicotine_mg_ml=20, vg_pct=50, ), "aroma": _create_product( client, headers, kind="aroma", name="Arôme fraise", price_cents=600, size_value=30, size_unit="ml", vg_pct=0, ), "coil": _create_product( client, headers, kind="coil", name="GT Mesh 0,6 Ω", price_cents=1950, size_value=5, size_unit="unit", ohm="0.6", ), } def _create_mix( client: TestClient, headers: dict[str, str], catalog: dict[str, dict[str, Any]] ) -> dict[str, Any]: response = client.post( f"{BASE}/mixes", json={ "name": "Fraise 6 mg 50/50", "total_ml": 260, "target_nicotine_mg_ml": 6, "components": [ {"product_id": catalog["base"]["id"], "quantity": 156}, {"product_id": catalog["booster"]["id"], "quantity": 78}, {"product_id": catalog["aroma"]["id"], "quantity": 26}, ], }, headers=headers, ) assert response.status_code == 201, response.text return response.json() # --- Settings --------------------------------------------------------------- def test_settings_404_then_upsert( client: TestClient, auth_headers: dict[str, str] ) -> None: missing = client.get(f"{BASE}/settings", headers=auth_headers) assert missing.status_code == 404 error = missing.json()["error"] assert error["code"] == "not_found" assert error["message"] == "Paramètres vape non configurés." created = _put_settings(client, auth_headers) assert created["cig_pack_price_cents"] == 1250 assert created["cigs_per_pack"] == 20 updated = _put_settings(client, auth_headers, cig_pack_price_cents=1300) assert updated["id"] == created["id"] # upsert, not a second row assert updated["cig_pack_price_cents"] == 1300 read = client.get(f"{BASE}/settings", headers=auth_headers) assert read.status_code == 200 assert read.json()["cig_pack_price_cents"] == 1300 def test_endpoints_require_authentication(client: TestClient) -> None: for path in ("/settings", "/products", "/dashboard", "/milestones"): assert client.get(f"{BASE}{path}").status_code == 401 # --- Products --------------------------------------------------------------- def test_products_crud_pagination_and_conflicts( client: TestClient, auth_headers: dict[str, str] ) -> None: catalog = _catalog(client, auth_headers) assert catalog["coil"]["unit_price_cents"] == 390.0 # 19,50 € / 5 listing = client.get(f"{BASE}/products", headers=auth_headers).json() assert listing["total"] == 4 assert {"items", "total", "page", "page_size"} <= set(listing) filtered = client.get(f"{BASE}/products?kind=coil", headers=auth_headers).json() assert [item["kind"] for item in filtered["items"]] == ["coil"] duplicate = client.post( f"{BASE}/products", json={ "kind": "base", "name": "Base 50/50 1 L", "price_cents": 1200, "size_value": 1000, "size_unit": "ml", }, headers=auth_headers, ) assert duplicate.status_code == 409 booster_without_rate = client.post( f"{BASE}/products", json={ "kind": "booster", "name": "Booster sans taux", "price_cents": 90, "size_value": 10, "size_unit": "ml", }, headers=auth_headers, ) assert booster_without_rate.status_code == 422 patched = client.patch( f"{BASE}/products/{catalog['coil']['id']}", json={"price_cents": 2000, "is_archived": True}, headers=auth_headers, ) assert patched.status_code == 200 assert patched.json()["unit_price_cents"] == 400.0 hidden = client.get(f"{BASE}/products", headers=auth_headers).json() assert hidden["total"] == 3 shown = client.get( f"{BASE}/products?include_archived=true", headers=auth_headers ).json() assert shown["total"] == 4 unknown_sort = client.get(f"{BASE}/products?sort=secret", headers=auth_headers) assert unknown_sort.status_code == 422 assert ( client.delete( f"{BASE}/products/{catalog['aroma']['id']}", headers=auth_headers ).status_code == 204 ) def test_delete_referenced_product_conflicts( client: TestClient, auth_headers: dict[str, str] ) -> None: catalog = _catalog(client, auth_headers) _create_mix(client, auth_headers, catalog) response = client.delete( f"{BASE}/products/{catalog['base']['id']}", headers=auth_headers ) assert response.status_code == 409 assert "archivez" in response.json()["error"]["message"] # --- Mixes ------------------------------------------------------------------ def test_mix_cost_nicotine_check_and_activation( client: TestClient, auth_headers: dict[str, str] ) -> None: catalog = _catalog(client, auth_headers) mix = _create_mix(client, auth_headers, catalog) # 156×1,2 + 78×9 + 26×20 = 1409,2 cents over 260 ml assert mix["cost_total_cents"] == 1409.2 assert mix["cost_per_ml_cents"] == 5.42 assert mix["nicotine_check_mg_ml"] == 6.0 assert mix["warning"] is None assert mix["is_active"] is False assert len(mix["components"]) == 3 assert mix["components"][1]["cost_cents"] == 702.0 activated = client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) assert activated.status_code == 200 assert activated.json()["is_active"] is True second = _create_mix(client, auth_headers, catalog) client.patch( f"{BASE}/mixes/{second['id']}", json={"name": "Fraise 3 mg", "target_nicotine_mg_ml": 3}, headers=auth_headers, ) activated_second = client.post( f"{BASE}/mixes/{second['id']}/activate", headers=auth_headers ).json() assert activated_second["is_active"] is True assert activated_second["warning"] == "nicotine_mismatch" # 6 mg/ml for a 3 target listing = client.get(f"{BASE}/mixes", headers=auth_headers).json() assert [item["is_active"] for item in listing["items"]].count(True) == 1 def test_archiving_the_active_mix_deactivates_it( client: TestClient, auth_headers: dict[str, str] ) -> None: """An archived recipe is hidden from the lists: it must stop being active, otherwise it keeps driving cost_per_ml while the UI shows no active mix.""" _put_settings(client, auth_headers) catalog = _catalog(client, auth_headers) mix = _create_mix(client, auth_headers, catalog) activated = client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) assert activated.json()["is_active"] is True archived = client.patch( f"{BASE}/mixes/{mix['id']}", json={"is_archived": True}, headers=auth_headers ) assert archived.status_code == 200, archived.text assert archived.json()["is_archived"] is True assert archived.json()["is_active"] is False listing = client.get(f"{BASE}/mixes?include_archived=true", headers=auth_headers) assert [item["is_active"] for item in listing.json()["items"]].count(True) == 0 dashboard = client.get(f"{BASE}/dashboard", headers=auth_headers).json() assert dashboard["cost_per_ml_cents"] is None # no active mix, no purchase def test_mix_component_replacement_and_validation( client: TestClient, auth_headers: dict[str, str] ) -> None: catalog = _catalog(client, auth_headers) mix = _create_mix(client, auth_headers, catalog) replaced = client.patch( f"{BASE}/mixes/{mix['id']}", json={ "components": [ {"product_id": catalog["base"]["id"], "quantity": 100}, {"product_id": catalog["booster"]["id"], "quantity": 30}, ] }, headers=auth_headers, ) assert replaced.status_code == 200 assert len(replaced.json()["components"]) == 2 hardware_component = client.patch( f"{BASE}/mixes/{mix['id']}", json={"components": [{"product_id": catalog["coil"]["id"], "quantity": 1}]}, headers=auth_headers, ) assert hardware_component.status_code == 422 twice = client.patch( f"{BASE}/mixes/{mix['id']}", json={ "components": [ {"product_id": catalog["base"]["id"], "quantity": 10}, {"product_id": catalog["base"]["id"], "quantity": 20}, ] }, headers=auth_headers, ) assert twice.status_code == 422 assert ( client.delete(f"{BASE}/mixes/{mix['id']}", headers=auth_headers).status_code == 204 ) assert client.get(f"{BASE}/mixes", headers=auth_headers).json()["total"] == 0 def test_mix_calculator_is_stateless( client: TestClient, auth_headers: dict[str, str] ) -> None: catalog = _catalog(client, auth_headers) response = client.post( f"{BASE}/mixes/calculator", json={ "total_ml": 260, "target_nicotine_mg_ml": 6, "booster_product_id": catalog["booster"]["id"], "base_product_id": catalog["base"]["id"], "aroma_pct": 10, "aroma_product_id": catalog["aroma"]["id"], }, headers=auth_headers, ) assert response.status_code == 200, response.text body = response.json() assert body["booster_ml"] == 78.0 assert body["aroma_ml"] == 26.0 assert body["base_ml"] == 156.0 assert body["nicotine_check_mg_ml"] == 6.0 assert body["cost_per_ml_cents"] == 5.42 assert client.get(f"{BASE}/mixes", headers=auth_headers).json()["total"] == 0 impossible = client.post( f"{BASE}/mixes/calculator", json={ "total_ml": 100, "target_nicotine_mg_ml": 18, "booster_product_id": catalog["booster"]["id"], "base_product_id": catalog["base"]["id"], "aroma_pct": 20, }, headers=auth_headers, ) assert impossible.status_code == 422 # --- Liquid entries --------------------------------------------------------- def test_daily_total_overrides_refills_and_is_unique( client: TestClient, auth_headers: dict[str, str] ) -> None: _put_settings(client, auth_headers) for ml in (4, 3): created = client.post( f"{BASE}/liquids", json={"entry_date": _day(-1), "ml": ml, "kind": "refill"}, headers=auth_headers, ) assert created.status_code == 201, created.text assert created.json()["nicotine_effective_mg_ml"] == 6.0 stats = client.get( f"{BASE}/stats/consumption?from={_day(-1)}&to={_day(-1)}", headers=auth_headers ).json() assert stats["series"][0]["points"] == [[_day(-1), 7.0]] total = client.post( f"{BASE}/liquids", json={"entry_date": _day(-1), "ml": 9, "kind": "daily_total"}, headers=auth_headers, ) assert total.status_code == 201 overridden = client.get( f"{BASE}/stats/consumption?from={_day(-1)}&to={_day(-1)}", headers=auth_headers ).json() assert overridden["series"][0]["points"] == [[_day(-1), 9.0]] assert overridden["meta"]["tracked_days_ratio"] == 1.0 duplicate = client.post( f"{BASE}/liquids", json={"entry_date": _day(-1), "ml": 5, "kind": "daily_total"}, headers=auth_headers, ) assert duplicate.status_code == 409 assert "total quotidien" in duplicate.json()["error"]["message"] # the same day of another date is fine other_day = client.post( f"{BASE}/liquids", json={"entry_date": _day(-2), "ml": 5, "kind": "daily_total"}, headers=auth_headers, ) assert other_day.status_code == 201 listing = client.get( f"{BASE}/liquids?kind=daily_total", headers=auth_headers ).json() assert listing["total"] == 2 patched = client.patch( f"{BASE}/liquids/{total.json()['id']}", json={"ml": 11, "nicotine_mg_ml": 3}, headers=auth_headers, ) assert patched.status_code == 200 assert patched.json()["nicotine_mg"] == 33.0 assert ( client.delete( f"{BASE}/liquids/{total.json()['id']}", headers=auth_headers ).status_code == 204 ) assert ( client.get( f"{BASE}/liquids/{total.json()['id']}", headers=auth_headers ).status_code == 405 ) def test_liquid_entry_inherits_the_active_mix( client: TestClient, auth_headers: dict[str, str] ) -> None: _put_settings(client, auth_headers, default_nicotine_mg_ml=12) catalog = _catalog(client, auth_headers) mix = _create_mix(client, auth_headers, catalog) client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) entry = client.post( f"{BASE}/liquids", json={"entry_date": _day(0), "ml": 4}, headers=auth_headers, ).json() assert entry["mix_id"] == mix["id"] assert entry["nicotine_effective_mg_ml"] == 6.0 # mix target beats the default assert entry["nicotine_mg"] == 24.0 # --- Coils ------------------------------------------------------------------ def test_one_click_coil_change_reports_previous_lifespan( client: TestClient, auth_headers: dict[str, str] ) -> None: catalog = _catalog(client, auth_headers) first = client.post( f"{BASE}/coils", json={ "changed_at": (datetime.now(UTC) - timedelta(days=20)).isoformat(), "product_id": catalog["coil"]["id"], }, headers=auth_headers, ) assert first.status_code == 201, first.text assert first.json()["previous_lifespan_days"] is None assert first.json()["is_current"] is True quick = client.post(f"{BASE}/coils/change", headers=auth_headers) assert quick.status_code == 201, quick.text body = quick.json() assert body["previous_lifespan_days"] == pytest.approx(20.0, abs=0.01) assert body["is_current"] is True listing = client.get(f"{BASE}/coils", headers=auth_headers).json() assert listing["total"] == 2 finished = next(item for item in listing["items"] if not item["is_current"]) assert finished["lifespan_days"] == pytest.approx(20.0, abs=0.01) assert finished["product_name"] == "GT Mesh 0,6 Ω" def test_coil_stats_average_and_amortization( client: TestClient, auth_headers: dict[str, str] ) -> None: catalog = _catalog(client, auth_headers) for offset in (30, 20, 10): client.post( f"{BASE}/coils", json={ "changed_at": (datetime.now(UTC) - timedelta(days=offset)).isoformat(), "product_id": catalog["coil"]["id"], }, headers=auth_headers, ) client.post( f"{BASE}/liquids", json={"entry_date": _day(-15), "ml": 6}, headers=auth_headers, ) stats = client.get(f"{BASE}/coils/stats", headers=auth_headers).json() assert stats["unit"] == "days" assert stats["meta"]["avg_lifespan_days"] == pytest.approx(10.0, abs=0.01) assert stats["meta"]["avg_lifespan_is_default"] is False assert stats["meta"]["coil_unit_price_cents"] == 390.0 assert stats["meta"]["coil_cost_per_day_cents"] == pytest.approx(39.0, abs=0.05) assert stats["meta"]["current_coil_age_days"] == pytest.approx(10.0, abs=0.01) lifespans = stats["series"][0]["points"] assert len(lifespans) == 3 # the 6 ml logged 15 days ago belong to the coil installed 20 days ago ml_through = dict(stats["series"][1]["points"]) assert ( ml_through[(datetime.now(UTC) - timedelta(days=20)).date().isoformat()] == 6.0 ) def test_avg_ml_through_coil_uses_completed_cycles_only( client: TestClient, auth_headers: dict[str, str] ) -> None: """§7.3: the running coil (partially used) must not drag the average down, and only the last COIL_AVG_LAST_N finished cycles are averaged.""" catalog = _catalog(client, auth_headers) for offset in (20, 10): client.post( f"{BASE}/coils", json={ "changed_at": (datetime.now(UTC) - timedelta(days=offset)).isoformat(), "product_id": catalog["coil"]["id"], }, headers=auth_headers, ) # 8 ml on the finished cycle, 2 ml on the coil currently in use for day, ml in ((-15, 8), (-5, 2)): created = client.post( f"{BASE}/liquids", json={"entry_date": _day(day), "ml": ml}, headers=auth_headers, ) assert created.status_code == 201, created.text stats = client.get(f"{BASE}/coils/stats", headers=auth_headers).json() ml_points = dict(stats["series"][1]["points"]) assert ml_points[(datetime.now(UTC) - timedelta(days=20)).date().isoformat()] == 8.0 assert ml_points[(datetime.now(UTC) - timedelta(days=10)).date().isoformat()] == 2.0 assert stats["meta"]["avg_ml_through_coil"] == 8.0 # not (8 + 2) / 2 = 5.0 def test_coil_changed_at_is_stored_in_utc_and_filterable( client: TestClient, auth_headers: dict[str, str] ) -> None: local_instant = datetime.now(PARIS) - timedelta(days=2) created = client.post( f"{BASE}/coils", json={"changed_at": local_instant.isoformat()}, headers=auth_headers, ) assert created.status_code == 201, created.text returned = datetime.fromisoformat(created.json()["changed_at"]) assert returned.utcoffset() == timedelta(0) # always UTC on the wire assert returned == local_instant day = local_instant.date().isoformat() listing = client.get(f"{BASE}/coils?from={day}&to={day}", headers=auth_headers) assert listing.json()["total"] == 1 empty = client.get( f"{BASE}/coils?from={_day(-1)}&to={_day(0)}", headers=auth_headers ) assert empty.json()["total"] == 0 def test_coil_stats_without_any_change_uses_the_default_lifespan( client: TestClient, auth_headers: dict[str, str] ) -> None: stats = client.get(f"{BASE}/coils/stats", headers=auth_headers).json() assert stats["meta"]["avg_lifespan_days"] == 14.0 assert stats["meta"]["avg_lifespan_is_default"] is True assert stats["meta"]["current_coil_age_days"] is None assert stats["series"][0]["points"] == [] # --- Purchases & savings ---------------------------------------------------- def test_purchases_crud_and_totals( client: TestClient, auth_headers: dict[str, str] ) -> None: catalog = _catalog(client, auth_headers) created = client.post( f"{BASE}/purchases", json={ "purchased_on": _day(-5), "product_id": catalog["coil"]["id"], "qty": 2, }, headers=auth_headers, ) assert created.status_code == 201, created.text assert created.json()["total_cents"] == 3900.0 # 2 × 19,50 € catalog price overridden = client.patch( f"{BASE}/purchases/{created.json()['id']}", json={"unit_price_cents": 1500}, headers=auth_headers, ) assert overridden.json()["total_cents"] == 3000.0 listing = client.get( f"{BASE}/purchases?from={_day(-6)}&to={_day(-4)}", headers=auth_headers ).json() assert listing["total"] == 1 assert listing["items"][0]["product_name"] == "GT Mesh 0,6 Ω" assert ( client.delete( f"{BASE}/purchases/{created.json()['id']}", headers=auth_headers ).status_code == 204 ) def test_savings_theoretical_and_real( client: TestClient, auth_headers: dict[str, str] ) -> None: _put_settings(client, auth_headers, quit_date=_day(-4)) catalog = _catalog(client, auth_headers) mix = _create_mix(client, auth_headers, catalog) client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) for offset in (-4, -3, -2, -1, 0): client.post( f"{BASE}/liquids", json={"entry_date": _day(offset), "ml": 4, "kind": "daily_total"}, headers=auth_headers, ) client.post( f"{BASE}/purchases", json={"purchased_on": _day(-4), "product_id": catalog["base"]["id"], "qty": 1}, headers=auth_headers, ) stats = client.get(f"{BASE}/stats/savings", headers=auth_headers).json() assert stats["unit"] == "cents" assert stats["from"] == _day(-4) assert stats["to"] == _day(0) names = [series["name"] for series in stats["series"]] assert names == ["savings_theoretical", "savings_real"] assert len(stats["series"][0]["points"]) == 5 meta = stats["meta"] # 15 cig/day ÷ 20 × 12,50 € = 937,5 cents/day, frozen since the quit date assert meta["cig_cost_per_day_cents"] == 937.5 assert meta["days_since_quit"] == 4 assert meta["cigarettes_avoided"] == 60 assert meta["packs_avoided"] == 3.0 assert meta["time_regained_minutes"] == 660 assert meta["has_purchases"] is True # daily vape cost = 4 ml × 5,42 c + coil amortization (no coil -> 0) theoretical_points = stats["series"][0]["points"] assert theoretical_points[0][1] == pytest.approx(-21.68, abs=0.01) assert theoretical_points[4][1] == pytest.approx(937.5 * 4 - 5 * 21.68, abs=0.05) real_points = stats["series"][1]["points"] assert real_points[0][1] == pytest.approx(-1200.0, abs=0.01) assert real_points[4][1] == pytest.approx(937.5 * 4 - 1200.0, abs=0.05) def test_savings_requires_settings( client: TestClient, auth_headers: dict[str, str] ) -> None: for path in ("/stats/savings", "/milestones", "/dashboard"): response = client.get(f"{BASE}{path}", headers=auth_headers) assert response.status_code == 404 assert response.json()["error"]["message"] == "Paramètres vape non configurés." # --- Stats contract --------------------------------------------------------- def test_stats_endpoints_follow_the_series_contract( client: TestClient, auth_headers: dict[str, str] ) -> None: _put_settings(client, auth_headers) catalog = _catalog(client, auth_headers) mix = _create_mix(client, auth_headers, catalog) client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) client.post( f"{BASE}/liquids", json={"entry_date": _day(-1), "ml": 4, "kind": "daily_total"}, headers=auth_headers, ) for path in ( "/stats/consumption", "/stats/nicotine", "/stats/costs", "/stats/savings", "/coils/stats", ): body = client.get(f"{BASE}{path}", headers=auth_headers).json() assert set(body) == {"from", "to", "unit", "series", "meta"}, path for series in body["series"]: assert set(series) == {"name", "type", "points"} for point in series["points"]: assert len(point) == 2 assert isinstance(point[0], str) assert point[1] is None or isinstance(point[1], (int, float)) consumption = client.get( f"{BASE}/stats/consumption?from={_day(-6)}&to={_day(0)}", headers=auth_headers ).json() assert len(consumption["series"][0]["points"]) == 7 assert consumption["meta"]["ml_per_day_7"] == 4.0 assert consumption["meta"]["tracked_days_ratio"] == pytest.approx(1 / 7, abs=0.001) assert consumption["series"][1]["name"] == "ml_ma7" nicotine = client.get( f"{BASE}/stats/nicotine?from={_day(-1)}&to={_day(-1)}", headers=auth_headers ).json() assert nicotine["unit"] == "mg" assert nicotine["series"][0]["points"] == [[_day(-1), 24.0]] # 4 ml × 6 mg/ml assert nicotine["meta"]["cig_equivalent_per_day"] == 2.0 assert nicotine["meta"]["trend_status"] in {"down", "stable", "up"} costs = client.get(f"{BASE}/stats/costs", headers=auth_headers).json() assert costs["unit"] == "cents" assert costs["meta"]["cost_per_ml_cents"] == 5.42 assert costs["meta"]["cost_per_ml_source"] == "active_mix" assert costs["meta"]["coil_cost_per_day_cents"] == 0.0 assert [series["name"] for series in costs["series"]] == [ "theoretical_cost", "real_spend", ] invalid = client.get( f"{BASE}/stats/consumption?from={_day(0)}&to={_day(-5)}", headers=auth_headers ) assert invalid.status_code == 422 def test_theoretical_cost_is_not_imputed_before_the_quit_date( client: TestClient, auth_headers: dict[str, str] ) -> None: """Mean-imputation fills tracking gaps — it must not invent a vape cost for the months the user was still smoking (nor for the future).""" _put_settings(client, auth_headers, quit_date=_day(-3)) catalog = _catalog(client, auth_headers) mix = _create_mix(client, auth_headers, catalog) client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) client.post( f"{BASE}/liquids", json={"entry_date": _day(-1), "ml": 4, "kind": "daily_total"}, headers=auth_headers, ) # A window that starts well before the quit date and ends in the future costs = client.get( f"{BASE}/stats/costs?from={_day(-40)}&to={_day(30)}", headers=auth_headers ).json() theoretical = dict(costs["series"][0]["points"]) total = sum(value or 0.0 for value in theoretical.values()) # 4 days from the quit date to today: 1 tracked (4 ml) + 3 imputed at 4 ml assert total == pytest.approx(4 * 4 * 5.42, abs=0.05) def test_savings_do_not_project_into_the_future( client: TestClient, auth_headers: dict[str, str] ) -> None: _put_settings(client, auth_headers, quit_date=_day(-2)) body = client.get( f"{BASE}/stats/savings?to={_day(60)}", headers=auth_headers ).json() assert body["to"] == _day(0) assert body["series"][0]["points"][-1][0] == _day(0) # 15 cig/day ÷ 20 × 12,50 € × 2 days, nothing vaped yet assert body["meta"]["savings_theoretical_cents"] == pytest.approx(937.5 * 2) def test_cost_per_ml_falls_back_to_purchases( client: TestClient, auth_headers: dict[str, str] ) -> None: _put_settings(client, auth_headers) catalog = _catalog(client, auth_headers) client.post( f"{BASE}/purchases", json={"purchased_on": _day(-3), "product_id": catalog["base"]["id"], "qty": 1}, headers=auth_headers, ) costs = client.get(f"{BASE}/stats/costs", headers=auth_headers).json() # 12,00 € for 1 000 ml -> 1,2 cent/ml, no active mix assert costs["meta"]["cost_per_ml_cents"] == 1.2 assert costs["meta"]["cost_per_ml_source"] == "purchases" # --- Milestones & dashboard ------------------------------------------------- def test_milestones_timeline(client: TestClient, auth_headers: dict[str, str]) -> None: _put_settings(client, auth_headers, quit_date=_day(-20)) body = client.get(f"{BASE}/milestones", headers=auth_headers).json() assert len(body) == 12 assert set(body[0]) == { "code", "label_fr", "reached_at", "achieved", "progress_pct", } by_code = {item["code"]: item for item in body} assert by_code["hr_bp_normal"]["achieved"] is True assert by_code["circulation"]["achieved"] is True assert by_code["lung_function"]["achieved"] is False assert by_code["chd_risk_normal"]["label_fr"].startswith("Risque coronarien") assert 0 < by_code["lung_function"]["progress_pct"] < 100 def test_dashboard_aggregates_the_home_kpis( client: TestClient, auth_headers: dict[str, str] ) -> None: _put_settings(client, auth_headers, quit_date=_day(-10)) catalog = _catalog(client, auth_headers) mix = _create_mix(client, auth_headers, catalog) client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) client.post( f"{BASE}/liquids", json={"entry_date": _day(0), "ml": 4, "kind": "daily_total"}, headers=auth_headers, ) client.post( f"{BASE}/coils", json={ "changed_at": (datetime.now(UTC) - timedelta(days=6)).isoformat(), "product_id": catalog["coil"]["id"], }, headers=auth_headers, ) body = client.get(f"{BASE}/dashboard", headers=auth_headers).json() assert body["quit_date"] == _day(-10) assert body["days_since_quit"] == 10 assert body["ml_today"] == 4.0 assert body["nicotine_today_mg"] == 24.0 assert body["cost_per_ml_cents"] == 5.42 assert body["cigarettes_avoided"] == 150 assert body["packs_avoided"] == 7.5 assert body["cig_cost_per_day_cents"] == 937.5 assert body["coil_avg_lifespan_days"] == 14.0 # default: a single change so far assert body["coil_lifespan_is_default"] is True assert body["coil_cost_per_day_cents"] == pytest.approx(390 / 14, abs=0.01) assert body["current_coil_age_days"] == pytest.approx(6.0, abs=0.01) assert body["has_purchases"] is False assert body["savings_display_cents"] == body["savings_theoretical_cents"] assert body["savings_theoretical_cents"] > 0 assert body["next_milestone"]["code"] == "circulation" # 14 days, quit 10 days ago # --- Multi-user isolation --------------------------------------------------- def test_user_isolation( client: TestClient, db: Session, auth_headers: dict[str, str], make_auth_headers: Callable[[int], dict[str, str]], ) -> None: _put_settings(client, auth_headers) catalog = _catalog(client, auth_headers) intruder = User( email="autre@lifetrack.local", password_hash="x" * 20, display_name="Autre", ) db.add(intruder) db.commit() other_headers = make_auth_headers(intruder.id) assert client.get(f"{BASE}/settings", headers=other_headers).status_code == 404 assert client.get(f"{BASE}/products", headers=other_headers).json()["total"] == 0 assert ( client.get( f"{BASE}/products/{catalog['base']['id']}", headers=other_headers ).status_code == 405 # no item GET route: the module never exposes another user's row ) assert ( client.patch( f"{BASE}/products/{catalog['base']['id']}", json={"name": "Volé"}, headers=other_headers, ).status_code == 404 ) assert ( client.delete( f"{BASE}/products/{catalog['base']['id']}", headers=other_headers ).status_code == 404 )