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>
915 lines
32 KiB
Python
915 lines
32 KiB
Python
"""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
|
||
)
|