Files
lifetrack/apps/api/app/tests/modules/test_vape_api.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

915 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
)