kiwi/tests/api/test_settings.py
pyr0ball c3e7dc1ea4 feat: time-first recipe entry (kiwi#52)
- Add max_total_min to RecipeRequest schema and TypeScript interface
- Add _within_time() helper to recipe_engine using parse_time_effort()
  with graceful degradation (empty directions or no signals -> pass)
- Wire max_total_min filter into suggest() loop after max_time_min
- Add time_first_layout to allowed settings keys
- Add timeFirstLayout ref to settings store (preserves sensoryPreferences)
- Add maxTotalMin ref to recipes store, wired into _buildRequest()
- Add time bucket selector UI (15/30/45/60/90 min) in RecipesView
  Find tab, gated by timeFirstLayout != 'normal'
- Add time-first layout selector section in SettingsView
- Add 5 _within_time unit tests and 2 settings key tests
2026-04-24 10:15:58 -07:00

169 lines
5.5 KiB
Python

"""Tests for user settings endpoints."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.cloud_session import get_session
from app.db.session import get_store
from app.main import app
from app.models.schemas.recipe import RecipeRequest
from app.services.recipe.recipe_engine import RecipeEngine
client = TestClient(app)
def _make_session(tier: str = "free", has_byok: bool = False) -> MagicMock:
mock = MagicMock()
mock.tier = tier
mock.has_byok = has_byok
return mock
def _make_store() -> MagicMock:
mock = MagicMock()
mock.get_setting.return_value = None
mock.set_setting.return_value = None
mock.search_recipes_by_ingredients.return_value = []
mock.check_and_increment_rate_limit.return_value = (True, 1)
return mock
@pytest.fixture()
def tmp_store() -> MagicMock:
session_mock = _make_session()
store_mock = _make_store()
app.dependency_overrides[get_session] = lambda: session_mock
app.dependency_overrides[get_store] = lambda: store_mock
yield store_mock
app.dependency_overrides.clear()
def test_set_and_get_cooking_equipment(tmp_store: MagicMock) -> None:
"""PUT then GET round-trips the cooking_equipment value."""
equipment_json = '["oven", "stovetop"]'
# PUT stores the value
put_resp = client.put(
"/api/v1/settings/cooking_equipment",
json={"value": equipment_json},
)
assert put_resp.status_code == 200
assert put_resp.json()["key"] == "cooking_equipment"
assert put_resp.json()["value"] == equipment_json
tmp_store.set_setting.assert_called_once_with("cooking_equipment", equipment_json)
# GET returns the stored value
tmp_store.get_setting.return_value = equipment_json
get_resp = client.get("/api/v1/settings/cooking_equipment")
assert get_resp.status_code == 200
assert get_resp.json()["value"] == equipment_json
def test_get_missing_setting_returns_404(tmp_store: MagicMock) -> None:
"""GET an allowed key that was never set returns 404."""
tmp_store.get_setting.return_value = None
resp = client.get("/api/v1/settings/cooking_equipment")
assert resp.status_code == 404
def test_hard_day_mode_uses_equipment_setting(tmp_store: MagicMock) -> None:
"""RecipeEngine.suggest() respects cooking_equipment from store when hard_day_mode=True."""
equipment_json = '["microwave"]'
tmp_store.get_setting.return_value = equipment_json
engine = RecipeEngine(store=tmp_store)
req = RecipeRequest(
pantry_items=["rice", "water"],
level=1,
constraints=[],
hard_day_mode=True,
)
result = engine.suggest(req)
# Engine should have read the equipment setting
tmp_store.get_setting.assert_any_call("cooking_equipment")
# Result is a valid RecipeResult (no crash)
assert result is not None
assert hasattr(result, "suggestions")
def test_put_unknown_key_returns_422(tmp_store: MagicMock) -> None:
"""PUT to an unknown settings key returns 422."""
resp = client.put(
"/api/v1/settings/nonexistent_key",
json={"value": "something"},
)
assert resp.status_code == 422
def test_put_null_value_returns_422(tmp_store: MagicMock) -> None:
"""PUT with a null value returns 422 (Pydantic validation)."""
resp = client.put(
"/api/v1/settings/cooking_equipment",
json={"value": None},
)
assert resp.status_code == 422
def test_set_and_get_sensory_preferences(tmp_store: MagicMock) -> None:
"""PUT then GET round-trips the sensory_preferences value."""
prefs = json.dumps({
"avoid_textures": ["mushy", "slimy"],
"max_smell": "pungent",
"max_noise": "loud",
})
put_resp = client.put(
"/api/v1/settings/sensory_preferences",
json={"value": prefs},
)
assert put_resp.status_code == 200
assert put_resp.json()["key"] == "sensory_preferences"
tmp_store.set_setting.assert_called_with("sensory_preferences", prefs)
tmp_store.get_setting.return_value = prefs
get_resp = client.get("/api/v1/settings/sensory_preferences")
assert get_resp.status_code == 200
assert get_resp.json()["value"] == prefs
def test_sensory_preferences_unknown_key_still_422(tmp_store: MagicMock) -> None:
"""Confirm unknown keys still 422 after adding sensory_preferences."""
resp = client.put(
"/api/v1/settings/sensory_taste_buds",
json={"value": "{}"},
)
assert resp.status_code == 422
def test_set_and_get_time_first_layout(tmp_store: MagicMock) -> None:
"""PUT then GET round-trips the time_first_layout value."""
layout_value = "time_first"
put_resp = client.put(
"/api/v1/settings/time_first_layout",
json={"value": layout_value},
)
assert put_resp.status_code == 200
assert put_resp.json()["key"] == "time_first_layout"
assert put_resp.json()["value"] == layout_value
tmp_store.set_setting.assert_called_with("time_first_layout", layout_value)
tmp_store.get_setting.return_value = layout_value
get_resp = client.get("/api/v1/settings/time_first_layout")
assert get_resp.status_code == 200
assert get_resp.json()["value"] == layout_value
def test_time_first_layout_unknown_key_still_422(tmp_store: MagicMock) -> None:
"""Confirm unknown keys still 422 after adding time_first_layout."""
resp = client.put(
"/api/v1/settings/time_first_mode",
json={"value": "time_first"},
)
assert resp.status_code == 422