test(api): add tests for ai suggestion endpoints and helpers
This commit is contained in:
parent
5ad9b66a21
commit
88f3642387
4 changed files with 387 additions and 1 deletions
|
|
@ -345,7 +345,7 @@ def _build_products_context(
|
||||||
entry += f" nearest_open_pao_deadline={pao_deadlines[0]}"
|
entry += f" nearest_open_pao_deadline={pao_deadlines[0]}"
|
||||||
if p.pao_months is not None:
|
if p.pao_months is not None:
|
||||||
entry += f" pao_months={p.pao_months}"
|
entry += f" pao_months={p.pao_months}"
|
||||||
profile = ctx.get("product_effect_profile", {})
|
profile = ctx.get("effect_profile", {})
|
||||||
if profile:
|
if profile:
|
||||||
notable = {k: v for k, v in profile.items() if v and v > 0}
|
notable = {k: v for k, v in profile.items() if v and v > 0}
|
||||||
if notable:
|
if notable:
|
||||||
|
|
|
||||||
93
backend/tests/test_products_helpers.py
Normal file
93
backend/tests/test_products_helpers.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from innercontext.api.products import _build_shopping_context
|
||||||
|
from innercontext.models import Product, SkinConditionSnapshot, ProductInventory
|
||||||
|
|
||||||
|
def test_build_shopping_context(session: Session):
|
||||||
|
# Empty context
|
||||||
|
ctx = _build_shopping_context(session)
|
||||||
|
assert "(brak danych)" in ctx
|
||||||
|
assert "POSIADANE PRODUKTY" in ctx
|
||||||
|
|
||||||
|
# Add snapshot
|
||||||
|
snap = SkinConditionSnapshot(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
snapshot_date=date.today(),
|
||||||
|
overall_state="fair",
|
||||||
|
skin_type="combination",
|
||||||
|
hydration_level=3,
|
||||||
|
sensitivity_level=4,
|
||||||
|
barrier_state="mildly_compromised",
|
||||||
|
active_concerns=["redness"],
|
||||||
|
priorities=["soothing"]
|
||||||
|
)
|
||||||
|
session.add(snap)
|
||||||
|
|
||||||
|
# Add product
|
||||||
|
p = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Soothing Serum",
|
||||||
|
brand="BrandX",
|
||||||
|
category="serum",
|
||||||
|
recommended_time="both",
|
||||||
|
leave_on=True,
|
||||||
|
targets=["redness"],
|
||||||
|
product_effect_profile={"soothing_strength": 4, "hydration_immediate": 1},
|
||||||
|
actives=[{"name": "Centella"}]
|
||||||
|
)
|
||||||
|
session.add(p)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Add inventory
|
||||||
|
inv = ProductInventory(id=uuid.uuid4(), product_id=p.id, is_opened=True)
|
||||||
|
session.add(inv)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ctx = _build_shopping_context(session)
|
||||||
|
assert "Typ skóry: combination" in ctx
|
||||||
|
assert "Nawilżenie: 3/5" in ctx
|
||||||
|
assert "Wrażliwość: 4/5" in ctx
|
||||||
|
assert "Aktywne problemy: redness" in ctx
|
||||||
|
assert "Priorytety: soothing" in ctx
|
||||||
|
|
||||||
|
# Check product
|
||||||
|
assert "[✓] Soothing Serum" in ctx
|
||||||
|
assert "BrandX" in ctx
|
||||||
|
assert "targets: ['redness']" in ctx
|
||||||
|
assert "actives: ['Centella']" in ctx
|
||||||
|
assert "effects: {'soothing': 4}" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_shopping(client, session):
|
||||||
|
with patch("innercontext.api.products.call_gemini") as mock_gemini:
|
||||||
|
mock_response = type("Response", (), {"text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": [], "target_concerns": [], "why_needed": "reason", "recommended_time": "am", "frequency": "daily"}], "reasoning": "Test shopping"}'})
|
||||||
|
mock_gemini.return_value = mock_response
|
||||||
|
|
||||||
|
r = client.post("/products/suggest")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert len(data["suggestions"]) == 1
|
||||||
|
assert data["suggestions"][0]["product_type"] == "cleanser"
|
||||||
|
assert data["reasoning"] == "Test shopping"
|
||||||
|
|
||||||
|
def test_shopping_context_medication_skip(session: Session):
|
||||||
|
p = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Epiduo",
|
||||||
|
brand="Galderma",
|
||||||
|
category="serum",
|
||||||
|
recommended_time="pm",
|
||||||
|
leave_on=True,
|
||||||
|
is_medication=True,
|
||||||
|
product_effect_profile={}
|
||||||
|
)
|
||||||
|
session.add(p)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ctx = _build_shopping_context(session)
|
||||||
|
assert "Epiduo" not in ctx
|
||||||
|
|
||||||
|
|
@ -216,3 +216,60 @@ def test_delete_grooming_schedule(client):
|
||||||
def test_delete_grooming_schedule_not_found(client):
|
def test_delete_grooming_schedule_not_found(client):
|
||||||
r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}")
|
r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}")
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
def test_suggest_routine(client, session):
|
||||||
|
with patch("innercontext.api.routines.call_gemini") as mock_gemini:
|
||||||
|
# Mock the Gemini response
|
||||||
|
mock_response = type("Response", (), {"text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "because"}'})
|
||||||
|
mock_gemini.return_value = mock_response
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/routines/suggest",
|
||||||
|
json={
|
||||||
|
"routine_date": "2026-03-03",
|
||||||
|
"part_of_day": "am",
|
||||||
|
"notes": "Testing",
|
||||||
|
"include_minoxidil_beard": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert len(data["steps"]) == 1
|
||||||
|
assert data["steps"][0]["action_type"] == "shaving_razor"
|
||||||
|
assert data["reasoning"] == "because"
|
||||||
|
|
||||||
|
def test_suggest_batch(client, session):
|
||||||
|
with patch("innercontext.api.routines.call_gemini") as mock_gemini:
|
||||||
|
# Mock the Gemini response
|
||||||
|
mock_response = type("Response", (), {"text": '{"days": [{"date": "2026-03-03", "am_steps": [], "pm_steps": [], "reasoning": "none"}], "overall_reasoning": "batch test"}'})
|
||||||
|
mock_gemini.return_value = mock_response
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/routines/suggest-batch",
|
||||||
|
json={
|
||||||
|
"from_date": "2026-03-03",
|
||||||
|
"to_date": "2026-03-04",
|
||||||
|
"minimize_products": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert len(data["days"]) == 1
|
||||||
|
assert data["days"][0]["date"] == "2026-03-03"
|
||||||
|
assert data["overall_reasoning"] == "batch test"
|
||||||
|
|
||||||
|
def test_suggest_batch_invalid_date_range(client):
|
||||||
|
r = client.post(
|
||||||
|
"/routines/suggest-batch",
|
||||||
|
json={"from_date": "2026-03-04", "to_date": "2026-03-03"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_suggest_batch_too_long(client):
|
||||||
|
r = client.post(
|
||||||
|
"/routines/suggest-batch",
|
||||||
|
json={"from_date": "2026-03-01", "to_date": "2026-03-20"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
|
||||||
236
backend/tests/test_routines_helpers.py
Normal file
236
backend/tests/test_routines_helpers.py
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from innercontext.api.routines import (
|
||||||
|
_contains_minoxidil_text,
|
||||||
|
_is_minoxidil_product,
|
||||||
|
_ev,
|
||||||
|
_build_skin_context,
|
||||||
|
_build_grooming_context,
|
||||||
|
_build_recent_history,
|
||||||
|
_build_products_context,
|
||||||
|
_build_objectives_context,
|
||||||
|
_build_day_context,
|
||||||
|
)
|
||||||
|
from innercontext.models import (
|
||||||
|
Product,
|
||||||
|
SkinConditionSnapshot,
|
||||||
|
GroomingSchedule,
|
||||||
|
Routine,
|
||||||
|
RoutineStep,
|
||||||
|
ProductInventory,
|
||||||
|
)
|
||||||
|
from innercontext.models.enums import PartOfDay, GroomingAction
|
||||||
|
|
||||||
|
|
||||||
|
def test_contains_minoxidil_text():
|
||||||
|
assert _contains_minoxidil_text(None) is False
|
||||||
|
assert _contains_minoxidil_text("") is False
|
||||||
|
assert _contains_minoxidil_text("some random text") is False
|
||||||
|
assert _contains_minoxidil_text("contains MINOXIDIL here") is True
|
||||||
|
assert _contains_minoxidil_text("minoksydyl 5%") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_minoxidil_product():
|
||||||
|
# Setup product
|
||||||
|
p = Product(id=uuid.uuid4(), name="Test", brand="Brand", is_medication=True)
|
||||||
|
assert _is_minoxidil_product(p) is False
|
||||||
|
|
||||||
|
p.name = "Minoxidil 5%"
|
||||||
|
assert _is_minoxidil_product(p) is True
|
||||||
|
|
||||||
|
p.name = "Test"
|
||||||
|
p.brand = "Brand with minoksydyl"
|
||||||
|
assert _is_minoxidil_product(p) is True
|
||||||
|
|
||||||
|
p.brand = "Brand"
|
||||||
|
p.line_name = "Minoxidil Line"
|
||||||
|
assert _is_minoxidil_product(p) is True
|
||||||
|
|
||||||
|
p.line_name = None
|
||||||
|
p.usage_notes = "Use minoxidil daily"
|
||||||
|
assert _is_minoxidil_product(p) is True
|
||||||
|
|
||||||
|
p.usage_notes = None
|
||||||
|
p.inci = ["water", "minoxidil"]
|
||||||
|
assert _is_minoxidil_product(p) is True
|
||||||
|
|
||||||
|
p.inci = None
|
||||||
|
p.actives = [{"name": "minoxidil", "strength": "5%"}]
|
||||||
|
assert _is_minoxidil_product(p) is True
|
||||||
|
|
||||||
|
# As Pydantic model representation isn't exactly a dict in db sometimes, we just test dict
|
||||||
|
p.actives = [{"name": "Retinol", "strength": "1%"}]
|
||||||
|
assert _is_minoxidil_product(p) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ev():
|
||||||
|
class DummyEnum:
|
||||||
|
value = "dummy"
|
||||||
|
|
||||||
|
assert _ev(None) == ""
|
||||||
|
assert _ev(DummyEnum()) == "dummy"
|
||||||
|
assert _ev("string") == "string"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_skin_context(session: Session):
|
||||||
|
# Empty
|
||||||
|
assert _build_skin_context(session) == "SKIN CONDITION: no data\n"
|
||||||
|
|
||||||
|
# With data
|
||||||
|
snap = SkinConditionSnapshot(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
snapshot_date=date.today(),
|
||||||
|
overall_state="good",
|
||||||
|
hydration_level=4,
|
||||||
|
barrier_state="intact",
|
||||||
|
active_concerns=["acne", "dryness"],
|
||||||
|
priorities=["hydration"],
|
||||||
|
notes="Feeling good"
|
||||||
|
)
|
||||||
|
session.add(snap)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ctx = _build_skin_context(session)
|
||||||
|
assert "SKIN CONDITION (snapshot from" in ctx
|
||||||
|
assert "Overall state: good" in ctx
|
||||||
|
assert "Hydration: 4/5" in ctx
|
||||||
|
assert "Barrier: intact" in ctx
|
||||||
|
assert "Active concerns: acne, dryness" in ctx
|
||||||
|
assert "Priorities: hydration" in ctx
|
||||||
|
assert "Notes: Feeling good" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_grooming_context(session: Session):
|
||||||
|
assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n"
|
||||||
|
|
||||||
|
sch = GroomingSchedule(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
day_of_week=0,
|
||||||
|
action="shaving_oneblade",
|
||||||
|
notes="Morning"
|
||||||
|
)
|
||||||
|
session.add(sch)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ctx = _build_grooming_context(session)
|
||||||
|
assert "GROOMING SCHEDULE:" in ctx
|
||||||
|
assert "poniedziałek: shaving_oneblade (Morning)" in ctx
|
||||||
|
|
||||||
|
# Test weekdays filter
|
||||||
|
ctx2 = _build_grooming_context(session, weekdays=[1]) # not monday
|
||||||
|
assert "(no entries for specified days)" in ctx2
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_recent_history(session: Session):
|
||||||
|
assert _build_recent_history(session) == "RECENT ROUTINES: none\n"
|
||||||
|
|
||||||
|
r = Routine(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
routine_date=date.today(),
|
||||||
|
part_of_day="am"
|
||||||
|
)
|
||||||
|
session.add(r)
|
||||||
|
p = Product(id=uuid.uuid4(), name="Cleanser", category="cleanser", brand="Test", recommended_time="both", leave_on=False, product_effect_profile={})
|
||||||
|
session.add(p)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
s1 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p.id)
|
||||||
|
s2 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=2, action_type="shaving_razor")
|
||||||
|
# Step with non-existent product
|
||||||
|
s3 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=3, product_id=uuid.uuid4())
|
||||||
|
|
||||||
|
session.add_all([s1, s2, s3])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ctx = _build_recent_history(session)
|
||||||
|
assert "RECENT ROUTINES:" in ctx
|
||||||
|
assert "AM:" in ctx
|
||||||
|
assert "cleanser [" in ctx
|
||||||
|
assert "action: shaving_razor" in ctx
|
||||||
|
assert "unknown [" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_products_context(session: Session):
|
||||||
|
p1 = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Regaine",
|
||||||
|
category="serum",
|
||||||
|
is_medication=True,
|
||||||
|
brand="J&J", recommended_time="both", leave_on=True, product_effect_profile={}
|
||||||
|
)
|
||||||
|
p2 = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Sunscreen",
|
||||||
|
category="spf", brand="Test", leave_on=True,
|
||||||
|
recommended_time="am",
|
||||||
|
pao_months=6,
|
||||||
|
product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0},
|
||||||
|
incompatible_with=[{"target": "retinol", "scope": "same_routine"}],
|
||||||
|
context_rules={"safe_after_shaving": False},
|
||||||
|
min_interval_hours=12,
|
||||||
|
max_frequency_per_week=7
|
||||||
|
)
|
||||||
|
session.add_all([p1, p2])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Inventory
|
||||||
|
inv1 = ProductInventory(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
product_id=p2.id,
|
||||||
|
is_opened=True,
|
||||||
|
opened_at=date.today() - timedelta(days=10),
|
||||||
|
expiry_date=date.today() + timedelta(days=365)
|
||||||
|
)
|
||||||
|
inv2 = ProductInventory(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
product_id=p2.id,
|
||||||
|
is_opened=False
|
||||||
|
)
|
||||||
|
session.add_all([inv1, inv2])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
r = Routine(id=uuid.uuid4(), routine_date=date.today(), part_of_day="am")
|
||||||
|
session.add(r)
|
||||||
|
session.commit()
|
||||||
|
s = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p2.id)
|
||||||
|
session.add(s)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ctx = _build_products_context(session, time_filter="am", reference_date=date.today())
|
||||||
|
# p1 is medication but not minoxidil (wait, Regaine name doesn't contain minoxidil!) -> skipped
|
||||||
|
assert "Regaine" not in ctx
|
||||||
|
|
||||||
|
# Let's fix p1 to be minoxidil
|
||||||
|
p1.name = "Regaine Minoxidil"
|
||||||
|
session.add(p1)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ctx = _build_products_context(session, time_filter="am", reference_date=date.today())
|
||||||
|
assert "Regaine Minoxidil" in ctx
|
||||||
|
assert "Sunscreen" in ctx
|
||||||
|
assert "inventory_status={active:2,opened:1,sealed:1}" in ctx
|
||||||
|
assert "nearest_open_expiry=" in ctx
|
||||||
|
assert "nearest_open_pao_deadline=" in ctx
|
||||||
|
assert "pao_months=6" in ctx
|
||||||
|
assert "effects={'hydration_immediate': 2}" in ctx
|
||||||
|
assert "incompatible_with=['avoid retinol (same_routine)']" in ctx
|
||||||
|
assert "context_rules={'safe_after_shaving': False}" in ctx
|
||||||
|
assert "min_interval_hours=12" in ctx
|
||||||
|
assert "max_frequency_per_week=7" in ctx
|
||||||
|
assert "used_in_last_7_days=1" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_objectives_context():
|
||||||
|
assert _build_objectives_context(False) == ""
|
||||||
|
assert "improve beard" in _build_objectives_context(True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_day_context():
|
||||||
|
assert _build_day_context(None) == ""
|
||||||
|
assert "Leaving home: yes" in _build_day_context(True)
|
||||||
|
assert "Leaving home: no" in _build_day_context(False)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue