Initial commit: backend API, data models, and test suite

FastAPI backend for personal health and skincare data with MCP export.
Includes SQLModel models for products, inventory, medications, lab results,
routines, and skin condition snapshots. Pytest suite with 111 tests running
on SQLite in-memory (no PostgreSQL required).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-26 15:10:24 +01:00
commit 8f7d893a63
32 changed files with 6282 additions and 0 deletions

View file

87
backend/tests/conftest.py Normal file
View file

@ -0,0 +1,87 @@
import os
# Must be set before importing db (which calls create_engine at module level)
os.environ.setdefault("DATABASE_URL", "sqlite://")
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
import db as db_module
from db import get_session
from main import app
@pytest.fixture()
def session(monkeypatch):
"""Per-test fresh SQLite in-memory database with full isolation."""
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
# Patch before TestClient triggers lifespan (which calls create_db_and_tables)
monkeypatch.setattr(db_module, "engine", engine)
import innercontext.models # noqa: F401 — populate SQLModel.metadata
SQLModel.metadata.create_all(engine)
with Session(engine) as s:
yield s
# monkeypatch auto-restores db_module.engine after test
@pytest.fixture()
def client(session):
"""TestClient using the per-test session for every request."""
def _override():
yield session
app.dependency_overrides[get_session] = _override
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
# ---- Shared data fixtures ----
@pytest.fixture()
def product_data():
return {
"name": "CeraVe Moisturising Cream",
"brand": "CeraVe",
"category": "moisturizer",
"routine_role": "seal",
"recommended_time": "both",
"leave_on": True,
}
@pytest.fixture()
def created_product(client, product_data):
r = client.post("/products/", json=product_data)
assert r.status_code == 201
return r.json()
@pytest.fixture()
def medication_data():
return {"kind": "prescription", "product_name": "Epiduo"}
@pytest.fixture()
def created_medication(client, medication_data):
r = client.post("/health/medications", json=medication_data)
assert r.status_code == 201
return r.json()
@pytest.fixture()
def created_routine(client):
r = client.post(
"/routines/", json={"routine_date": "2026-02-26", "part_of_day": "am"}
)
assert r.status_code == 201
return r.json()

View file

@ -0,0 +1,285 @@
import uuid
# ---------------------------------------------------------------------------
# Medications
# ---------------------------------------------------------------------------
def test_create_medication_minimal(client, medication_data):
r = client.post("/health/medications", json=medication_data)
assert r.status_code == 201
data = r.json()
assert "record_id" in data
assert data["kind"] == "prescription"
assert data["product_name"] == "Epiduo"
def test_create_medication_invalid_kind(client):
r = client.post(
"/health/medications", json={"kind": "invalid_kind", "product_name": "X"}
)
assert r.status_code == 422
def test_list_medications_empty(client):
r = client.get("/health/medications")
assert r.status_code == 200
assert r.json() == []
def test_list_filter_kind(client):
client.post(
"/health/medications", json={"kind": "prescription", "product_name": "A"}
)
client.post(
"/health/medications", json={"kind": "supplement", "product_name": "B"}
)
r = client.get("/health/medications?kind=supplement")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["kind"] == "supplement"
def test_list_filter_product_name(client):
client.post(
"/health/medications", json={"kind": "otc", "product_name": "Epiduo Forte"}
)
client.post(
"/health/medications", json={"kind": "otc", "product_name": "Panoxyl"}
)
r = client.get("/health/medications?product_name=epid")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert "Epiduo" in data[0]["product_name"]
def test_get_medication(client, created_medication):
mid = created_medication["record_id"]
r = client.get(f"/health/medications/{mid}")
assert r.status_code == 200
assert r.json()["record_id"] == mid
def test_get_medication_not_found(client):
r = client.get(f"/health/medications/{uuid.uuid4()}")
assert r.status_code == 404
def test_update_medication(client, created_medication):
mid = created_medication["record_id"]
r = client.patch(
f"/health/medications/{mid}", json={"notes": "Take in the evening"}
)
assert r.status_code == 200
assert r.json()["notes"] == "Take in the evening"
def test_update_medication_not_found(client):
r = client.patch(f"/health/medications/{uuid.uuid4()}", json={"notes": "x"})
assert r.status_code == 404
def test_delete_medication_no_usages(client, created_medication):
mid = created_medication["record_id"]
r = client.delete(f"/health/medications/{mid}")
assert r.status_code == 204
r2 = client.get(f"/health/medications/{mid}")
assert r2.status_code == 404
def test_delete_medication_with_usages(client, created_medication):
mid = created_medication["record_id"]
# Create a usage
client.post(
f"/health/medications/{mid}/usages",
json={"valid_from": "2026-01-01T00:00:00"},
)
# Delete medication (should also delete usages)
r = client.delete(f"/health/medications/{mid}")
assert r.status_code == 204
r2 = client.get(f"/health/medications/{mid}")
assert r2.status_code == 404
# ---------------------------------------------------------------------------
# Usages
# ---------------------------------------------------------------------------
def test_create_usage(client, created_medication):
mid = created_medication["record_id"]
r = client.post(
f"/health/medications/{mid}/usages",
json={"valid_from": "2026-01-01T08:00:00", "dose_value": 1.0, "dose_unit": "pea"},
)
assert r.status_code == 201
data = r.json()
assert "record_id" in data
assert data["medication_record_id"] == mid
assert data["dose_unit"] == "pea"
def test_create_usage_medication_not_found(client):
r = client.post(
f"/health/medications/{uuid.uuid4()}/usages",
json={"valid_from": "2026-01-01T00:00:00"},
)
assert r.status_code == 404
def test_list_usages_empty(client, created_medication):
mid = created_medication["record_id"]
r = client.get(f"/health/medications/{mid}/usages")
assert r.status_code == 200
assert r.json() == []
def test_list_usages_returns_entries(client, created_medication):
mid = created_medication["record_id"]
client.post(
f"/health/medications/{mid}/usages",
json={"valid_from": "2026-01-01T00:00:00"},
)
r = client.get(f"/health/medications/{mid}/usages")
assert r.status_code == 200
assert len(r.json()) == 1
def test_update_usage(client, created_medication):
mid = created_medication["record_id"]
r = client.post(
f"/health/medications/{mid}/usages",
json={"valid_from": "2026-01-01T00:00:00"},
)
uid = r.json()["record_id"]
r2 = client.patch(f"/health/usages/{uid}", json={"dose_value": 2.5, "dose_unit": "mg"})
assert r2.status_code == 200
assert r2.json()["dose_value"] == 2.5
assert r2.json()["dose_unit"] == "mg"
def test_update_usage_not_found(client):
r = client.patch(f"/health/usages/{uuid.uuid4()}", json={"dose_value": 1.0})
assert r.status_code == 404
def test_delete_usage(client, created_medication):
mid = created_medication["record_id"]
r = client.post(
f"/health/medications/{mid}/usages",
json={"valid_from": "2026-01-01T00:00:00"},
)
uid = r.json()["record_id"]
r2 = client.delete(f"/health/usages/{uid}")
assert r2.status_code == 204
def test_delete_usage_not_found(client):
r = client.delete(f"/health/usages/{uuid.uuid4()}")
assert r.status_code == 404
# ---------------------------------------------------------------------------
# Lab results
# ---------------------------------------------------------------------------
LAB_RESULT_DATA = {
"collected_at": "2026-01-15T09:00:00",
"test_code": "718-7",
"test_name_original": "Hemoglobin",
"value_num": 14.5,
"unit_original": "g/dL",
"flag": "N",
}
def test_create_lab_result(client):
r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
assert r.status_code == 201
data = r.json()
assert "record_id" in data
assert data["test_code"] == "718-7"
assert data["flag"] == "N"
def test_create_lab_result_invalid_code(client):
bad = {**LAB_RESULT_DATA, "test_code": "not-a-loinc-code"}
r = client.post("/health/lab-results", json=bad)
assert r.status_code == 422
def test_create_lab_result_invalid_flag(client):
bad = {**LAB_RESULT_DATA, "flag": "INVALID"}
r = client.post("/health/lab-results", json=bad)
assert r.status_code == 422
def test_list_lab_results_empty(client):
r = client.get("/health/lab-results")
assert r.status_code == 200
assert r.json() == []
def test_list_filter_test_code(client):
client.post("/health/lab-results", json=LAB_RESULT_DATA)
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "test_code": "2951-2"})
r = client.get("/health/lab-results?test_code=718-7")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["test_code"] == "718-7"
def test_list_filter_flag(client):
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "flag": "N"})
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "flag": "H"})
r = client.get("/health/lab-results?flag=H")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["flag"] == "H"
def test_list_filter_date_range(client):
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "collected_at": "2026-01-01T00:00:00"})
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "collected_at": "2026-06-01T00:00:00"})
r = client.get("/health/lab-results?from_date=2026-05-01T00:00:00")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
def test_get_lab_result(client):
r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
rid = r.json()["record_id"]
r2 = client.get(f"/health/lab-results/{rid}")
assert r2.status_code == 200
assert r2.json()["record_id"] == rid
def test_get_lab_result_not_found(client):
r = client.get(f"/health/lab-results/{uuid.uuid4()}")
assert r.status_code == 404
def test_update_lab_result(client):
r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
rid = r.json()["record_id"]
r2 = client.patch(f"/health/lab-results/{rid}", json={"notes": "Recheck in 3 months"})
assert r2.status_code == 200
assert r2.json()["notes"] == "Recheck in 3 months"
def test_delete_lab_result(client):
r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
rid = r.json()["record_id"]
r2 = client.delete(f"/health/lab-results/{rid}")
assert r2.status_code == 204
r3 = client.get(f"/health/lab-results/{rid}")
assert r3.status_code == 404

View file

@ -0,0 +1,54 @@
import uuid
def test_get_inventory_by_id(client, created_product):
pid = created_product["id"]
r = client.post(f"/products/{pid}/inventory", json={"is_opened": False})
assert r.status_code == 201
inv_id = r.json()["id"]
r2 = client.get(f"/inventory/{inv_id}")
assert r2.status_code == 200
assert r2.json()["id"] == inv_id
def test_get_inventory_not_found(client):
r = client.get(f"/inventory/{uuid.uuid4()}")
assert r.status_code == 404
def test_update_inventory_opened(client, created_product):
pid = created_product["id"]
r = client.post(f"/products/{pid}/inventory", json={"is_opened": False})
inv_id = r.json()["id"]
r2 = client.patch(
f"/inventory/{inv_id}",
json={"is_opened": True, "opened_at": "2026-01-15"},
)
assert r2.status_code == 200
data = r2.json()
assert data["is_opened"] is True
assert data["opened_at"] == "2026-01-15"
def test_update_inventory_not_found(client):
r = client.patch(f"/inventory/{uuid.uuid4()}", json={"is_opened": True})
assert r.status_code == 404
def test_delete_inventory(client, created_product):
pid = created_product["id"]
r = client.post(f"/products/{pid}/inventory", json={})
inv_id = r.json()["id"]
r2 = client.delete(f"/inventory/{inv_id}")
assert r2.status_code == 204
r3 = client.get(f"/inventory/{inv_id}")
assert r3.status_code == 404
def test_delete_inventory_not_found(client):
r = client.delete(f"/inventory/{uuid.uuid4()}")
assert r.status_code == 404

View file

@ -0,0 +1,234 @@
"""Unit tests for Product.to_llm_context() — no database required."""
from uuid import uuid4
import pytest
from innercontext.models import Product
from innercontext.models.enums import (
DayTime,
IngredientFunction,
InteractionScope,
ProductCategory,
RoutineRole,
)
from innercontext.models.product import (
ActiveIngredient,
ProductContext,
ProductEffectProfile,
ProductInteraction,
)
def _make(**kwargs):
defaults = dict(
id=uuid4(),
name="Test",
brand="B",
category=ProductCategory.MOISTURIZER,
routine_role=RoutineRole.SEAL,
recommended_time=DayTime.BOTH,
leave_on=True,
)
defaults.update(kwargs)
return Product(**defaults)
# ---------------------------------------------------------------------------
# Always-present keys
# ---------------------------------------------------------------------------
def test_always_present_keys():
p = _make()
ctx = p.to_llm_context()
for key in ("id", "name", "brand", "category", "routine_role", "recommended_time", "leave_on"):
assert key in ctx, f"Expected '{key}' in to_llm_context() output"
# ---------------------------------------------------------------------------
# Optional string fields
# ---------------------------------------------------------------------------
def test_optional_string_fields_absent_when_none():
p = _make()
ctx = p.to_llm_context()
for key in ("line_name", "sku", "url", "barcode"):
assert key not in ctx, f"'{key}' should not appear when None"
def test_optional_string_fields_present_when_set():
p = _make(line_name="Hydrating", sku="CV-001", url="https://example.com", barcode="123456")
ctx = p.to_llm_context()
assert ctx["line_name"] == "Hydrating"
assert ctx["sku"] == "CV-001"
assert ctx["url"] == "https://example.com"
assert ctx["barcode"] == "123456"
# ---------------------------------------------------------------------------
# pH handling
# ---------------------------------------------------------------------------
def test_ph_exact_collapses():
p = _make(ph_min=5.5, ph_max=5.5)
ctx = p.to_llm_context()
assert "ph" in ctx
assert ctx["ph"] == 5.5
assert "ph_range" not in ctx
assert "ph_min" not in ctx
assert "ph_max" not in ctx
def test_ph_range():
p = _make(ph_min=4.0, ph_max=6.0)
ctx = p.to_llm_context()
assert "ph_range" in ctx
assert "4.0" in ctx["ph_range"]
assert "6.0" in ctx["ph_range"]
assert "ph" not in ctx
def test_ph_only_min():
p = _make(ph_min=3.5, ph_max=None)
ctx = p.to_llm_context()
assert "ph_min" in ctx
assert ctx["ph_min"] == 3.5
assert "ph_range" not in ctx
assert "ph" not in ctx
def test_ph_only_max():
p = _make(ph_min=None, ph_max=7.0)
ctx = p.to_llm_context()
assert "ph_max" in ctx
assert ctx["ph_max"] == 7.0
assert "ph_range" not in ctx
assert "ph" not in ctx
# ---------------------------------------------------------------------------
# Actives
# ---------------------------------------------------------------------------
def test_actives_pydantic_objects():
ai = ActiveIngredient(
name="Niacinamide",
percent=10.0,
functions=[IngredientFunction.NIACINAMIDE],
)
p = _make(actives=[ai])
ctx = p.to_llm_context()
assert "actives" in ctx
a = ctx["actives"][0]
assert a["name"] == "Niacinamide"
assert a["percent"] == 10.0
assert "niacinamide" in a["functions"]
def test_actives_raw_dicts():
raw = {"name": "Retinol", "percent": 0.1, "functions": ["retinoid"]}
p = _make(actives=[raw])
ctx = p.to_llm_context()
assert "actives" in ctx
assert ctx["actives"][0] == raw
# ---------------------------------------------------------------------------
# Effect profile
# ---------------------------------------------------------------------------
def test_effect_profile_all_zeros_omitted():
p = _make(product_effect_profile=ProductEffectProfile())
ctx = p.to_llm_context()
assert "effect_profile" not in ctx
def test_effect_profile_nonzero_included():
ep = ProductEffectProfile(hydration_immediate=4, barrier_repair_strength=3)
p = _make(product_effect_profile=ep)
ctx = p.to_llm_context()
assert "effect_profile" in ctx
assert ctx["effect_profile"]["hydration_immediate"] == 4
assert ctx["effect_profile"]["barrier_repair_strength"] == 3
# Zero fields should not be present
assert "retinoid_strength" not in ctx["effect_profile"]
# ---------------------------------------------------------------------------
# Incompatible_with
# ---------------------------------------------------------------------------
def test_incompatible_with_pydantic_objects():
inc = ProductInteraction(
target="AHA", scope=InteractionScope.SAME_DAY, reason="increases irritation"
)
p = _make(incompatible_with=[inc])
ctx = p.to_llm_context()
assert "incompatible_with" in ctx
assert ctx["incompatible_with"][0] == "avoid AHA (same_day): increases irritation"
def test_incompatible_with_raw_dicts():
raw = {"target": "Vitamin C", "scope": "same_step", "reason": None}
p = _make(incompatible_with=[raw])
ctx = p.to_llm_context()
assert "incompatible_with" in ctx
assert ctx["incompatible_with"][0] == "avoid Vitamin C (same_step)"
# ---------------------------------------------------------------------------
# Context rules
# ---------------------------------------------------------------------------
def test_context_rules_all_none_omitted():
p = _make(context_rules=ProductContext())
ctx = p.to_llm_context()
assert "context_rules" not in ctx
def test_context_rules_with_value():
p = _make(context_rules=ProductContext(safe_after_shaving=True, low_uv_only=True))
ctx = p.to_llm_context()
assert "context_rules" in ctx
assert ctx["context_rules"]["safe_after_shaving"] is True
assert ctx["context_rules"]["low_uv_only"] is True
assert "safe_after_acids" not in ctx["context_rules"]
# ---------------------------------------------------------------------------
# Safety dict
# ---------------------------------------------------------------------------
def test_safety_dict_present_when_set():
p = _make(fragrance_free=True, alcohol_denat_free=True)
ctx = p.to_llm_context()
assert "safety" in ctx
assert ctx["safety"]["fragrance_free"] is True
assert ctx["safety"]["alcohol_denat_free"] is True
# ---------------------------------------------------------------------------
# Empty vs non-empty lists
# ---------------------------------------------------------------------------
def test_empty_lists_omitted():
p = _make(inci=[], targets=[])
ctx = p.to_llm_context()
assert "inci" not in ctx
assert "targets" not in ctx
def test_nonempty_lists_included():
p = _make(inci=["Water", "Glycerin"], targets=["acne", "redness"])
ctx = p.to_llm_context()
assert "inci" in ctx
assert ctx["inci"] == ["Water", "Glycerin"]
assert "targets" in ctx

View file

@ -0,0 +1,198 @@
import uuid
def test_create_minimal(client, product_data):
r = client.post("/products/", json=product_data)
assert r.status_code == 201
data = r.json()
assert "id" in data
assert data["name"] == product_data["name"]
assert data["brand"] == product_data["brand"]
assert data["category"] == "moisturizer"
def test_create_with_actives(client, product_data):
product_data["actives"] = [
{"name": "Niacinamide", "percent": 10.0, "functions": ["niacinamide"]}
]
r = client.post("/products/", json=product_data)
assert r.status_code == 201
data = r.json()
assert len(data["actives"]) == 1
assert data["actives"][0]["name"] == "Niacinamide"
assert data["actives"][0]["percent"] == 10.0
def test_create_invalid_enum(client, product_data):
product_data["category"] = "not_a_category"
r = client.post("/products/", json=product_data)
assert r.status_code == 422
def test_create_missing_required(client, product_data):
del product_data["name"]
r = client.post("/products/", json=product_data)
assert r.status_code == 422
def test_list_empty(client):
r = client.get("/products/")
assert r.status_code == 200
assert r.json() == []
def test_list_returns_created(client, created_product):
r = client.get("/products/")
assert r.status_code == 200
ids = [p["id"] for p in r.json()]
assert created_product["id"] in ids
def test_list_filter_category(client, client_and_data=None):
# Create a moisturizer and a serum
base = {
"brand": "B",
"routine_role": "seal",
"recommended_time": "both",
"leave_on": True,
}
r1 = client.post("/products/", json={**base, "name": "Moist", "category": "moisturizer"})
r2 = client.post("/products/", json={**base, "name": "Ser", "category": "serum"})
assert r1.status_code == 201
assert r2.status_code == 201
r = client.get("/products/?category=moisturizer")
assert r.status_code == 200
data = r.json()
assert all(p["category"] == "moisturizer" for p in data)
names = [p["name"] for p in data]
assert "Moist" in names
assert "Ser" not in names
def test_list_filter_brand(client):
base = {
"routine_role": "seal",
"recommended_time": "both",
"leave_on": True,
"category": "serum",
}
client.post("/products/", json={**base, "name": "A1", "brand": "BrandA"})
client.post("/products/", json={**base, "name": "B1", "brand": "BrandB"})
r = client.get("/products/?brand=BrandA")
assert r.status_code == 200
data = r.json()
assert all(p["brand"] == "BrandA" for p in data)
assert len(data) == 1
def test_list_filter_is_medication(client):
base = {
"brand": "B",
"routine_role": "seal",
"recommended_time": "both",
"leave_on": True,
"category": "serum",
}
client.post("/products/", json={**base, "name": "Normal", "is_medication": False})
# is_medication=True requires usage_notes (model validator)
client.post(
"/products/",
json={**base, "name": "Med", "is_medication": True, "usage_notes": "Apply pea-sized amount"},
)
r = client.get("/products/?is_medication=true")
assert r.status_code == 200
data = r.json()
assert all(p["is_medication"] is True for p in data)
assert len(data) == 1
assert data[0]["name"] == "Med"
def test_list_filter_targets(client):
base = {
"brand": "B",
"routine_role": "seal",
"recommended_time": "both",
"leave_on": True,
"category": "serum",
}
client.post("/products/", json={**base, "name": "Acne", "targets": ["acne"]})
client.post("/products/", json={**base, "name": "Aging", "targets": ["aging"]})
r = client.get("/products/?targets=acne")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["name"] == "Acne"
def test_get_by_id(client, created_product):
pid = created_product["id"]
r = client.get(f"/products/{pid}")
assert r.status_code == 200
assert r.json()["id"] == pid
def test_get_not_found(client):
r = client.get(f"/products/{uuid.uuid4()}")
assert r.status_code == 404
def test_update_name(client, created_product):
pid = created_product["id"]
r = client.patch(f"/products/{pid}", json={"name": "New Name"})
assert r.status_code == 200
assert r.json()["name"] == "New Name"
def test_update_json_field(client, created_product):
pid = created_product["id"]
r = client.patch(f"/products/{pid}", json={"inci": ["Water", "Glycerin"]})
assert r.status_code == 200
assert r.json()["inci"] == ["Water", "Glycerin"]
def test_update_not_found(client):
r = client.patch(f"/products/{uuid.uuid4()}", json={"name": "x"})
assert r.status_code == 404
def test_delete(client, created_product):
pid = created_product["id"]
r = client.delete(f"/products/{pid}")
assert r.status_code == 204
r2 = client.get(f"/products/{pid}")
assert r2.status_code == 404
def test_delete_not_found(client):
r = client.delete(f"/products/{uuid.uuid4()}")
assert r.status_code == 404
def test_list_inventory_empty(client, created_product):
pid = created_product["id"]
r = client.get(f"/products/{pid}/inventory")
assert r.status_code == 200
assert r.json() == []
def test_list_inventory_product_not_found(client):
r = client.get(f"/products/{uuid.uuid4()}/inventory")
assert r.status_code == 404
def test_create_inventory(client, created_product):
pid = created_product["id"]
r = client.post(f"/products/{pid}/inventory", json={"is_opened": False})
assert r.status_code == 201
data = r.json()
assert data["product_id"] == pid
assert data["is_opened"] is False
def test_create_inventory_product_not_found(client):
r = client.post(f"/products/{uuid.uuid4()}/inventory", json={})
assert r.status_code == 404

View file

@ -0,0 +1,219 @@
import uuid
# ---------------------------------------------------------------------------
# Routines
# ---------------------------------------------------------------------------
def test_create_routine_minimal(client):
r = client.post(
"/routines/", json={"routine_date": "2026-02-26", "part_of_day": "am"}
)
assert r.status_code == 201
data = r.json()
assert "id" in data
assert data["routine_date"] == "2026-02-26"
assert data["part_of_day"] == "am"
def test_create_routine_invalid_part_of_day(client):
r = client.post(
"/routines/", json={"routine_date": "2026-02-26", "part_of_day": "noon"}
)
assert r.status_code == 422
def test_list_routines_empty(client):
r = client.get("/routines/")
assert r.status_code == 200
assert r.json() == []
def test_list_filter_date_range(client):
client.post("/routines/", json={"routine_date": "2026-01-01", "part_of_day": "am"})
client.post("/routines/", json={"routine_date": "2026-06-01", "part_of_day": "am"})
r = client.get("/routines/?from_date=2026-05-01&to_date=2026-12-31")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["routine_date"] == "2026-06-01"
def test_list_filter_part_of_day(client):
client.post("/routines/", json={"routine_date": "2026-02-01", "part_of_day": "am"})
client.post("/routines/", json={"routine_date": "2026-02-02", "part_of_day": "pm"})
r = client.get("/routines/?part_of_day=pm")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["part_of_day"] == "pm"
def test_get_routine(client, created_routine):
rid = created_routine["id"]
r = client.get(f"/routines/{rid}")
assert r.status_code == 200
assert r.json()["id"] == rid
def test_get_routine_not_found(client):
r = client.get(f"/routines/{uuid.uuid4()}")
assert r.status_code == 404
def test_update_routine_notes(client, created_routine):
rid = created_routine["id"]
r = client.patch(f"/routines/{rid}", json={"notes": "Felt great today"})
assert r.status_code == 200
assert r.json()["notes"] == "Felt great today"
def test_update_routine_not_found(client):
r = client.patch(f"/routines/{uuid.uuid4()}", json={"notes": "x"})
assert r.status_code == 404
def test_delete_routine(client, created_routine):
rid = created_routine["id"]
r = client.delete(f"/routines/{rid}")
assert r.status_code == 204
r2 = client.get(f"/routines/{rid}")
assert r2.status_code == 404
# ---------------------------------------------------------------------------
# RoutineStep
# ---------------------------------------------------------------------------
def test_add_step_action_only(client, created_routine):
rid = created_routine["id"]
r = client.post(
f"/routines/{rid}/steps",
json={"order_index": 0, "action_notes": "Cleanse face gently"},
)
assert r.status_code == 201
data = r.json()
assert data["order_index"] == 0
assert data["action_notes"] == "Cleanse face gently"
assert data["product_id"] is None
def test_add_step_with_product(client, created_routine, created_product):
rid = created_routine["id"]
pid = created_product["id"]
r = client.post(
f"/routines/{rid}/steps",
json={"order_index": 1, "product_id": pid},
)
assert r.status_code == 201
data = r.json()
assert data["product_id"] == pid
assert data["order_index"] == 1
def test_add_step_routine_not_found(client):
r = client.post(
f"/routines/{uuid.uuid4()}/steps",
json={"order_index": 0},
)
assert r.status_code == 404
def test_update_step(client, created_routine):
rid = created_routine["id"]
r = client.post(
f"/routines/{rid}/steps",
json={"order_index": 0, "action_notes": "Initial"},
)
step_id = r.json()["id"]
r2 = client.patch(f"/routines/steps/{step_id}", json={"action_notes": "Updated"})
assert r2.status_code == 200
assert r2.json()["action_notes"] == "Updated"
def test_update_step_not_found(client):
r = client.patch(f"/routines/steps/{uuid.uuid4()}", json={"action_notes": "x"})
assert r.status_code == 404
def test_delete_step(client, created_routine):
rid = created_routine["id"]
r = client.post(
f"/routines/{rid}/steps",
json={"order_index": 0},
)
step_id = r.json()["id"]
r2 = client.delete(f"/routines/steps/{step_id}")
assert r2.status_code == 204
def test_delete_step_not_found(client):
r = client.delete(f"/routines/steps/{uuid.uuid4()}")
assert r.status_code == 404
# ---------------------------------------------------------------------------
# GroomingSchedule
# ---------------------------------------------------------------------------
def test_list_grooming_schedule_empty(client):
r = client.get("/routines/grooming-schedule")
assert r.status_code == 200
assert r.json() == []
def test_create_grooming_schedule(client):
r = client.post(
"/routines/grooming-schedule",
json={"day_of_week": 1, "action": "shaving_razor"},
)
assert r.status_code == 201
data = r.json()
assert data["day_of_week"] == 1
assert data["action"] == "shaving_razor"
def test_list_grooming_schedule_returns_entry(client):
client.post(
"/routines/grooming-schedule",
json={"day_of_week": 3, "action": "dermarolling"},
)
r = client.get("/routines/grooming-schedule")
assert r.status_code == 200
assert len(r.json()) >= 1
def test_update_grooming_schedule(client):
r = client.post(
"/routines/grooming-schedule",
json={"day_of_week": 0, "action": "shaving_oneblade"},
)
entry_id = r.json()["id"]
r2 = client.patch(
f"/routines/grooming-schedule/{entry_id}",
json={"notes": "Use cold water"},
)
assert r2.status_code == 200
assert r2.json()["notes"] == "Use cold water"
def test_delete_grooming_schedule(client):
r = client.post(
"/routines/grooming-schedule",
json={"day_of_week": 5, "action": "shaving_razor"},
)
entry_id = r.json()["id"]
r2 = client.delete(f"/routines/grooming-schedule/{entry_id}")
assert r2.status_code == 204
def test_delete_grooming_schedule_not_found(client):
r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}")
assert r.status_code == 404

View file

@ -0,0 +1,132 @@
import uuid
def test_create_snapshot_minimal(client):
r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"})
assert r.status_code == 201
data = r.json()
assert "id" in data
assert data["snapshot_date"] == "2026-02-26"
assert data["overall_state"] is None
def test_create_snapshot_full(client):
r = client.post(
"/skin-snapshots/",
json={
"snapshot_date": "2026-02-20",
"overall_state": "good",
"trend": "improving",
"skin_type": "combination",
"hydration_level": 3,
"sebum_tzone": 4,
"sebum_cheeks": 2,
"sensitivity_level": 2,
"barrier_state": "intact",
"active_concerns": ["acne", "redness"],
"risks": ["Over-exfoliation"],
"priorities": ["Maintain barrier"],
"notes": "Looking better this week",
},
)
assert r.status_code == 201
data = r.json()
assert data["overall_state"] == "good"
assert data["trend"] == "improving"
assert "acne" in data["active_concerns"]
assert data["hydration_level"] == 3
def test_create_snapshot_invalid_state(client):
r = client.post(
"/skin-snapshots/",
json={"snapshot_date": "2026-02-26", "overall_state": "stellar"},
)
assert r.status_code == 422
def test_list_snapshots_empty(client):
r = client.get("/skin-snapshots/")
assert r.status_code == 200
assert r.json() == []
def test_list_filter_date_range(client):
client.post("/skin-snapshots/", json={"snapshot_date": "2026-01-01"})
client.post("/skin-snapshots/", json={"snapshot_date": "2026-06-01"})
r = client.get("/skin-snapshots/?from_date=2026-05-01&to_date=2026-12-31")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["snapshot_date"] == "2026-06-01"
def test_list_filter_overall_state(client):
client.post(
"/skin-snapshots/",
json={"snapshot_date": "2026-02-10", "overall_state": "good"},
)
client.post(
"/skin-snapshots/",
json={"snapshot_date": "2026-02-11", "overall_state": "poor"},
)
r = client.get("/skin-snapshots/?overall_state=poor")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["overall_state"] == "poor"
def test_get_snapshot(client):
r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"})
sid = r.json()["id"]
r2 = client.get(f"/skin-snapshots/{sid}")
assert r2.status_code == 200
assert r2.json()["id"] == sid
def test_get_snapshot_not_found(client):
r = client.get(f"/skin-snapshots/{uuid.uuid4()}")
assert r.status_code == 404
def test_update_snapshot_state(client):
r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"})
sid = r.json()["id"]
r2 = client.patch(f"/skin-snapshots/{sid}", json={"overall_state": "excellent"})
assert r2.status_code == 200
assert r2.json()["overall_state"] == "excellent"
def test_update_snapshot_concerns(client):
r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"})
sid = r.json()["id"]
r2 = client.patch(
f"/skin-snapshots/{sid}",
json={"active_concerns": ["dehydration", "redness"]},
)
assert r2.status_code == 200
data = r2.json()
assert "dehydration" in data["active_concerns"]
assert "redness" in data["active_concerns"]
def test_update_snapshot_not_found(client):
r = client.patch(
f"/skin-snapshots/{uuid.uuid4()}", json={"overall_state": "good"}
)
assert r.status_code == 404
def test_delete_snapshot(client):
r = client.post("/skin-snapshots/", json={"snapshot_date": "2026-02-26"})
sid = r.json()["id"]
r2 = client.delete(f"/skin-snapshots/{sid}")
assert r2.status_code == 204
r3 = client.get(f"/skin-snapshots/{sid}")
assert r3.status_code == 404
def test_delete_snapshot_not_found(client):
r = client.delete(f"/skin-snapshots/{uuid.uuid4()}")
assert r.status_code == 404