diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 13e47bc..4cac357 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -345,7 +345,7 @@ def _build_products_context( entry += f" nearest_open_pao_deadline={pao_deadlines[0]}" if p.pao_months is not None: entry += f" pao_months={p.pao_months}" - profile = ctx.get("product_effect_profile", {}) + profile = ctx.get("effect_profile", {}) if profile: notable = {k: v for k, v in profile.items() if v and v > 0} if notable: diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py new file mode 100644 index 0000000..e4a2666 --- /dev/null +++ b/backend/tests/test_products_helpers.py @@ -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 + diff --git a/backend/tests/test_routines.py b/backend/tests/test_routines.py index b240079..78b1af6 100644 --- a/backend/tests/test_routines.py +++ b/backend/tests/test_routines.py @@ -216,3 +216,60 @@ def test_delete_grooming_schedule(client): def test_delete_grooming_schedule_not_found(client): r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}") 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 diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py new file mode 100644 index 0000000..5ad6ddd --- /dev/null +++ b/backend/tests/test_routines_helpers.py @@ -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)