feat(api): enforce ownership across health routines and profile flows
This commit is contained in:
parent
cd8e39939a
commit
ffa3b71309
14 changed files with 1225 additions and 206 deletions
|
|
@ -37,27 +37,32 @@ def session(monkeypatch):
|
|||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(session, monkeypatch):
|
||||
def current_user() -> CurrentUser:
|
||||
claims = TokenClaims(
|
||||
issuer="https://auth.test",
|
||||
subject="test-user",
|
||||
audience=("innercontext-web",),
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
groups=("innercontext-admin",),
|
||||
raw_claims={"iss": "https://auth.test", "sub": "test-user"},
|
||||
)
|
||||
return CurrentUser(
|
||||
user_id=uuid4(),
|
||||
role=Role.ADMIN,
|
||||
identity=IdentityData.from_claims(claims),
|
||||
claims=claims,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(session, monkeypatch, current_user):
|
||||
"""TestClient using the per-test session for every request."""
|
||||
|
||||
def _override():
|
||||
yield session
|
||||
|
||||
def _current_user_override():
|
||||
claims = TokenClaims(
|
||||
issuer="https://auth.test",
|
||||
subject="test-user",
|
||||
audience=("innercontext-web",),
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
groups=("innercontext-admin",),
|
||||
raw_claims={"iss": "https://auth.test", "sub": "test-user"},
|
||||
)
|
||||
return CurrentUser(
|
||||
user_id=uuid4(),
|
||||
role=Role.ADMIN,
|
||||
identity=IdentityData.from_claims(claims),
|
||||
claims=claims,
|
||||
)
|
||||
return current_user
|
||||
|
||||
app.dependency_overrides[get_session] = _override
|
||||
app.dependency_overrides[get_current_user] = _current_user_override
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ from typing import Any, cast
|
|||
from innercontext.models.ai_log import AICallLog
|
||||
|
||||
|
||||
def test_list_ai_logs_normalizes_tool_trace_string(client, session):
|
||||
def test_list_ai_logs_normalizes_tool_trace_string(client, session, current_user):
|
||||
log = AICallLog(
|
||||
id=uuid.uuid4(),
|
||||
endpoint="routines/suggest",
|
||||
model="gemini-3-flash-preview",
|
||||
success=True,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
log.tool_trace = cast(
|
||||
Any,
|
||||
|
|
@ -26,12 +27,13 @@ def test_list_ai_logs_normalizes_tool_trace_string(client, session):
|
|||
assert data[0]["tool_trace"]["events"][0]["function"] == "get_product_inci"
|
||||
|
||||
|
||||
def test_get_ai_log_normalizes_tool_trace_string(client, session):
|
||||
def test_get_ai_log_normalizes_tool_trace_string(client, session, current_user):
|
||||
log = AICallLog(
|
||||
id=uuid.uuid4(),
|
||||
endpoint="routines/suggest",
|
||||
model="gemini-3-flash-preview",
|
||||
success=True,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
log.tool_trace = cast(Any, '{"mode":"function_tools","round":1}')
|
||||
session.add(log)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from datetime import date
|
|||
from unittest.mock import patch
|
||||
|
||||
from innercontext.models import Routine, SkinConditionSnapshot
|
||||
from innercontext.models.enums import BarrierState, OverallSkinState
|
||||
from innercontext.models.enums import BarrierState, OverallSkinState, PartOfDay
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routines
|
||||
|
|
@ -223,13 +223,14 @@ def test_delete_grooming_schedule_not_found(client):
|
|||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_suggest_routine(client, session):
|
||||
def test_suggest_routine(client, session, current_user):
|
||||
with patch(
|
||||
"innercontext.api.routines.call_gemini_with_function_tools"
|
||||
) as mock_gemini:
|
||||
session.add(
|
||||
SkinConditionSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
snapshot_date=date(2026, 2, 22),
|
||||
overall_state=OverallSkinState.GOOD,
|
||||
hydration_level=4,
|
||||
|
|
@ -272,18 +273,20 @@ def test_suggest_routine(client, session):
|
|||
assert "get_product_details" in kwargs["function_handlers"]
|
||||
|
||||
|
||||
def test_suggest_batch(client, session):
|
||||
def test_suggest_batch(client, session, current_user):
|
||||
with patch("innercontext.api.routines.call_gemini") as mock_gemini:
|
||||
session.add(
|
||||
Routine(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
routine_date=date(2026, 2, 27),
|
||||
part_of_day="pm",
|
||||
part_of_day=PartOfDay.PM,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
SkinConditionSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
snapshot_date=date(2026, 2, 20),
|
||||
overall_state=OverallSkinState.GOOD,
|
||||
hydration_level=4,
|
||||
|
|
|
|||
112
backend/tests/test_routines_auth.py
Normal file
112
backend/tests/test_routines_auth.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from innercontext.api.auth_deps import get_current_user
|
||||
from innercontext.auth import CurrentUser, IdentityData, TokenClaims
|
||||
from innercontext.models import Role
|
||||
from main import app
|
||||
|
||||
|
||||
def _user(subject: str, *, role: Role = Role.MEMBER) -> CurrentUser:
|
||||
claims = TokenClaims(
|
||||
issuer="https://auth.test",
|
||||
subject=subject,
|
||||
audience=("innercontext-web",),
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
raw_claims={"iss": "https://auth.test", "sub": subject},
|
||||
)
|
||||
return CurrentUser(
|
||||
user_id=uuid4(),
|
||||
role=role,
|
||||
identity=IdentityData.from_claims(claims),
|
||||
claims=claims,
|
||||
)
|
||||
|
||||
|
||||
def _set_current_user(user: CurrentUser) -> None:
|
||||
app.dependency_overrides[get_current_user] = lambda: user
|
||||
|
||||
|
||||
def test_suggest_uses_current_user_profile_and_visible_products_only(client):
|
||||
owner = _user("owner")
|
||||
other = _user("other")
|
||||
|
||||
_set_current_user(owner)
|
||||
owner_profile = client.patch(
|
||||
"/profile", json={"birth_date": "1991-01-15", "sex_at_birth": "male"}
|
||||
)
|
||||
owner_product = client.post(
|
||||
"/products",
|
||||
json={
|
||||
"name": "Owner Serum",
|
||||
"brand": "Test",
|
||||
"category": "serum",
|
||||
"recommended_time": "both",
|
||||
"leave_on": True,
|
||||
},
|
||||
)
|
||||
assert owner_profile.status_code == 200
|
||||
assert owner_product.status_code == 201
|
||||
|
||||
_set_current_user(other)
|
||||
other_profile = client.patch(
|
||||
"/profile", json={"birth_date": "1975-06-20", "sex_at_birth": "female"}
|
||||
)
|
||||
other_product = client.post(
|
||||
"/products",
|
||||
json={
|
||||
"name": "Other Serum",
|
||||
"brand": "Test",
|
||||
"category": "serum",
|
||||
"recommended_time": "both",
|
||||
"leave_on": True,
|
||||
},
|
||||
)
|
||||
assert other_profile.status_code == 200
|
||||
assert other_product.status_code == 201
|
||||
|
||||
_set_current_user(owner)
|
||||
|
||||
with patch(
|
||||
"innercontext.api.routines.call_gemini_with_function_tools"
|
||||
) as mock_gemini:
|
||||
mock_response = type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "ok", "summary": {"primary_goal": "safe", "constraints_applied": [], "confidence": 0.7}}'
|
||||
},
|
||||
)
|
||||
mock_gemini.return_value = (mock_response, None)
|
||||
|
||||
response = client.post(
|
||||
"/routines/suggest",
|
||||
json={
|
||||
"routine_date": "2026-03-05",
|
||||
"part_of_day": "am",
|
||||
"include_minoxidil_beard": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
kwargs = mock_gemini.call_args.kwargs
|
||||
prompt = kwargs["contents"]
|
||||
assert "Birth date: 1991-01-15" in prompt
|
||||
assert "Birth date: 1975-06-20" not in prompt
|
||||
assert "Owner Serum" in prompt
|
||||
assert "Other Serum" not in prompt
|
||||
|
||||
handler = kwargs["function_handlers"]["get_product_details"]
|
||||
payload = handler(
|
||||
{
|
||||
"product_ids": [
|
||||
owner_product.json()["id"],
|
||||
other_product.json()["id"],
|
||||
]
|
||||
}
|
||||
)
|
||||
assert len(payload["products"]) == 1
|
||||
assert payload["products"][0]["name"] == "Owner Serum"
|
||||
|
|
@ -78,17 +78,22 @@ def test_ev():
|
|||
assert _ev("string") == "string"
|
||||
|
||||
|
||||
def test_build_skin_context(session: Session):
|
||||
def test_build_skin_context(session: Session, current_user):
|
||||
# Empty
|
||||
reference_date = date(2026, 3, 10)
|
||||
assert (
|
||||
_build_skin_context(session, reference_date=reference_date)
|
||||
_build_skin_context(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
== "SKIN CONDITION: no data\n"
|
||||
)
|
||||
|
||||
# With data
|
||||
snap = SkinConditionSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
snapshot_date=reference_date,
|
||||
overall_state=OverallSkinState.GOOD,
|
||||
hydration_level=4,
|
||||
|
|
@ -100,7 +105,11 @@ def test_build_skin_context(session: Session):
|
|||
session.add(snap)
|
||||
session.commit()
|
||||
|
||||
ctx = _build_skin_context(session, reference_date=reference_date)
|
||||
ctx = _build_skin_context(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
assert "SKIN CONDITION (snapshot from" in ctx
|
||||
assert "Overall state: good" in ctx
|
||||
assert "Hydration: 4/5" in ctx
|
||||
|
|
@ -112,10 +121,12 @@ def test_build_skin_context(session: Session):
|
|||
|
||||
def test_build_skin_context_falls_back_to_recent_snapshot_within_14_days(
|
||||
session: Session,
|
||||
current_user,
|
||||
):
|
||||
reference_date = date(2026, 3, 20)
|
||||
snap = SkinConditionSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
snapshot_date=reference_date - timedelta(days=10),
|
||||
overall_state=OverallSkinState.FAIR,
|
||||
hydration_level=3,
|
||||
|
|
@ -126,16 +137,23 @@ def test_build_skin_context_falls_back_to_recent_snapshot_within_14_days(
|
|||
session.add(snap)
|
||||
session.commit()
|
||||
|
||||
ctx = _build_skin_context(session, reference_date=reference_date)
|
||||
ctx = _build_skin_context(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
|
||||
assert f"snapshot from {reference_date - timedelta(days=10)}" in ctx
|
||||
assert "Barrier: compromised" in ctx
|
||||
|
||||
|
||||
def test_build_skin_context_ignores_snapshot_older_than_14_days(session: Session):
|
||||
def test_build_skin_context_ignores_snapshot_older_than_14_days(
|
||||
session: Session, current_user
|
||||
):
|
||||
reference_date = date(2026, 3, 20)
|
||||
snap = SkinConditionSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
snapshot_date=reference_date - timedelta(days=15),
|
||||
overall_state=OverallSkinState.FAIR,
|
||||
hydration_level=3,
|
||||
|
|
@ -145,15 +163,20 @@ def test_build_skin_context_ignores_snapshot_older_than_14_days(session: Session
|
|||
session.commit()
|
||||
|
||||
assert (
|
||||
_build_skin_context(session, reference_date=reference_date)
|
||||
_build_skin_context(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
== "SKIN CONDITION: no data\n"
|
||||
)
|
||||
|
||||
|
||||
def test_get_recent_skin_snapshot_prefers_window_match(session: Session):
|
||||
def test_get_recent_skin_snapshot_prefers_window_match(session: Session, current_user):
|
||||
reference_date = date(2026, 3, 20)
|
||||
older = SkinConditionSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
snapshot_date=reference_date - timedelta(days=10),
|
||||
overall_state=OverallSkinState.POOR,
|
||||
hydration_level=2,
|
||||
|
|
@ -161,6 +184,7 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session):
|
|||
)
|
||||
newer = SkinConditionSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
snapshot_date=reference_date - timedelta(days=2),
|
||||
overall_state=OverallSkinState.GOOD,
|
||||
hydration_level=4,
|
||||
|
|
@ -169,7 +193,11 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session):
|
|||
session.add_all([older, newer])
|
||||
session.commit()
|
||||
|
||||
snapshot = _get_recent_skin_snapshot(session, reference_date=reference_date)
|
||||
snapshot = _get_recent_skin_snapshot(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot.id == newer.id
|
||||
|
|
@ -177,10 +205,12 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session):
|
|||
|
||||
def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days(
|
||||
session: Session,
|
||||
current_user,
|
||||
):
|
||||
reference_date = date(2026, 3, 20)
|
||||
older = SkinConditionSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
snapshot_date=reference_date - timedelta(days=10),
|
||||
overall_state=OverallSkinState.POOR,
|
||||
hydration_level=2,
|
||||
|
|
@ -188,6 +218,7 @@ def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days(
|
|||
)
|
||||
newer = SkinConditionSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
snapshot_date=reference_date - timedelta(days=2),
|
||||
overall_state=OverallSkinState.GOOD,
|
||||
hydration_level=4,
|
||||
|
|
@ -198,6 +229,7 @@ def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days(
|
|||
|
||||
snapshot = _get_latest_skin_snapshot_within_days(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
|
||||
|
|
@ -205,39 +237,65 @@ def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days(
|
|||
assert snapshot.id == newer.id
|
||||
|
||||
|
||||
def test_build_grooming_context(session: Session):
|
||||
assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n"
|
||||
def test_build_grooming_context(session: Session, current_user):
|
||||
assert (
|
||||
_build_grooming_context(session, target_user_id=current_user.user_id)
|
||||
== "GROOMING SCHEDULE: none\n"
|
||||
)
|
||||
|
||||
sch = GroomingSchedule(
|
||||
id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning"
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
day_of_week=0,
|
||||
action="shaving_oneblade",
|
||||
notes="Morning",
|
||||
)
|
||||
session.add(sch)
|
||||
session.commit()
|
||||
|
||||
ctx = _build_grooming_context(session)
|
||||
ctx = _build_grooming_context(session, target_user_id=current_user.user_id)
|
||||
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
|
||||
ctx2 = _build_grooming_context(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
weekdays=[1],
|
||||
) # not monday
|
||||
assert "(no entries for specified days)" in ctx2
|
||||
|
||||
|
||||
def test_build_upcoming_grooming_context(session: Session):
|
||||
def test_build_upcoming_grooming_context(session: Session, current_user):
|
||||
assert (
|
||||
_build_upcoming_grooming_context(session, start_date=date(2026, 3, 2), days=7)
|
||||
_build_upcoming_grooming_context(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
start_date=date(2026, 3, 2),
|
||||
days=7,
|
||||
)
|
||||
== "UPCOMING GROOMING (next 7 days): none\n"
|
||||
)
|
||||
|
||||
monday = GroomingSchedule(
|
||||
id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning"
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
day_of_week=0,
|
||||
action="shaving_oneblade",
|
||||
notes="Morning",
|
||||
)
|
||||
wednesday = GroomingSchedule(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
day_of_week=2,
|
||||
action="dermarolling",
|
||||
)
|
||||
wednesday = GroomingSchedule(id=uuid.uuid4(), day_of_week=2, action="dermarolling")
|
||||
session.add_all([monday, wednesday])
|
||||
session.commit()
|
||||
|
||||
ctx = _build_upcoming_grooming_context(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
start_date=date(2026, 3, 2),
|
||||
days=7,
|
||||
)
|
||||
|
|
@ -246,14 +304,23 @@ def test_build_upcoming_grooming_context(session: Session):
|
|||
assert "za 2 dni (2026-03-04, środa): dermarolling" in ctx
|
||||
|
||||
|
||||
def test_build_recent_history(session: Session):
|
||||
def test_build_recent_history(session: Session, current_user):
|
||||
reference_date = date(2026, 3, 10)
|
||||
assert (
|
||||
_build_recent_history(session, reference_date=reference_date)
|
||||
_build_recent_history(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
== "RECENT ROUTINES: none\n"
|
||||
)
|
||||
|
||||
r = Routine(id=uuid.uuid4(), routine_date=reference_date, part_of_day="am")
|
||||
r = Routine(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
routine_date=reference_date,
|
||||
part_of_day="am",
|
||||
)
|
||||
session.add(r)
|
||||
p = Product(
|
||||
id=uuid.uuid4(),
|
||||
|
|
@ -268,19 +335,37 @@ def test_build_recent_history(session: Session):
|
|||
session.add(p)
|
||||
session.commit()
|
||||
|
||||
s1 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p.id)
|
||||
s1 = RoutineStep(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
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"
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
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()
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
routine_id=r.id,
|
||||
order_index=3,
|
||||
product_id=uuid.uuid4(),
|
||||
)
|
||||
|
||||
session.add_all([s1, s2, s3])
|
||||
session.commit()
|
||||
|
||||
ctx = _build_recent_history(session, reference_date=reference_date)
|
||||
ctx = _build_recent_history(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
assert "RECENT ROUTINES:" in ctx
|
||||
assert "AM:" in ctx
|
||||
assert "cleanser [" in ctx
|
||||
|
|
@ -288,31 +373,38 @@ def test_build_recent_history(session: Session):
|
|||
assert "unknown [" in ctx
|
||||
|
||||
|
||||
def test_build_recent_history_uses_reference_window(session: Session):
|
||||
def test_build_recent_history_uses_reference_window(session: Session, current_user):
|
||||
reference_date = date(2026, 3, 10)
|
||||
recent = Routine(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
routine_date=reference_date - timedelta(days=3),
|
||||
part_of_day="pm",
|
||||
)
|
||||
old = Routine(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
routine_date=reference_date - timedelta(days=6),
|
||||
part_of_day="am",
|
||||
)
|
||||
session.add_all([recent, old])
|
||||
session.commit()
|
||||
|
||||
ctx = _build_recent_history(session, reference_date=reference_date)
|
||||
ctx = _build_recent_history(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
|
||||
assert str(recent.routine_date) in ctx
|
||||
assert str(old.routine_date) not in ctx
|
||||
|
||||
|
||||
def test_build_recent_history_excludes_future_routines(session: Session):
|
||||
def test_build_recent_history_excludes_future_routines(session: Session, current_user):
|
||||
reference_date = date(2026, 3, 10)
|
||||
future = Routine(
|
||||
id=uuid.uuid4(),
|
||||
user_id=current_user.user_id,
|
||||
routine_date=reference_date + timedelta(days=1),
|
||||
part_of_day="am",
|
||||
)
|
||||
|
|
@ -320,12 +412,16 @@ def test_build_recent_history_excludes_future_routines(session: Session):
|
|||
session.commit()
|
||||
|
||||
assert (
|
||||
_build_recent_history(session, reference_date=reference_date)
|
||||
_build_recent_history(
|
||||
session,
|
||||
target_user_id=current_user.user_id,
|
||||
reference_date=reference_date,
|
||||
)
|
||||
== "RECENT ROUTINES: none\n"
|
||||
)
|
||||
|
||||
|
||||
def test_build_products_context_summary_list(session: Session):
|
||||
def test_build_products_context_summary_list(session: Session, current_user):
|
||||
p1 = Product(
|
||||
id=uuid.uuid4(),
|
||||
short_id=str(uuid.uuid4())[:8],
|
||||
|
|
@ -336,6 +432,7 @@ def test_build_products_context_summary_list(session: Session):
|
|||
recommended_time="both",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
p2 = Product(
|
||||
id=uuid.uuid4(),
|
||||
|
|
@ -350,11 +447,16 @@ def test_build_products_context_summary_list(session: Session):
|
|||
context_rules={"safe_after_shaving": False},
|
||||
min_interval_hours=12,
|
||||
max_frequency_per_week=7,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
session.add_all([p1, p2])
|
||||
session.commit()
|
||||
|
||||
products_am = _get_available_products(session, time_filter="am")
|
||||
products_am = _get_available_products(
|
||||
session,
|
||||
current_user=current_user,
|
||||
time_filter="am",
|
||||
)
|
||||
ctx = build_products_context_summary_list(products_am, {p2.id})
|
||||
|
||||
assert "Regaine Minoxidil" in ctx
|
||||
|
|
@ -375,7 +477,7 @@ def test_build_day_context():
|
|||
assert "Leaving home: no" in _build_day_context(False)
|
||||
|
||||
|
||||
def test_get_available_products_respects_filters(session: Session):
|
||||
def test_get_available_products_respects_filters(session: Session, current_user):
|
||||
regular_med = Product(
|
||||
id=uuid.uuid4(),
|
||||
name="Tretinoin",
|
||||
|
|
@ -385,6 +487,7 @@ def test_get_available_products_respects_filters(session: Session):
|
|||
recommended_time="pm",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
minoxidil_med = Product(
|
||||
id=uuid.uuid4(),
|
||||
|
|
@ -395,6 +498,7 @@ def test_get_available_products_respects_filters(session: Session):
|
|||
recommended_time="both",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
am_product = Product(
|
||||
id=uuid.uuid4(),
|
||||
|
|
@ -404,6 +508,7 @@ def test_get_available_products_respects_filters(session: Session):
|
|||
recommended_time="am",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
pm_product = Product(
|
||||
id=uuid.uuid4(),
|
||||
|
|
@ -413,11 +518,16 @@ def test_get_available_products_respects_filters(session: Session):
|
|||
recommended_time="pm",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
session.add_all([regular_med, minoxidil_med, am_product, pm_product])
|
||||
session.commit()
|
||||
|
||||
am_available = _get_available_products(session, time_filter="am")
|
||||
am_available = _get_available_products(
|
||||
session,
|
||||
current_user=current_user,
|
||||
time_filter="am",
|
||||
)
|
||||
am_names = {p.name for p in am_available}
|
||||
assert "Tretinoin" not in am_names
|
||||
assert "Minoxidil 5%" in am_names
|
||||
|
|
@ -508,7 +618,10 @@ def test_extract_active_names_uses_compact_distinct_names(session: Session):
|
|||
assert names == ["Niacinamide", "Zinc PCA"]
|
||||
|
||||
|
||||
def test_get_available_products_excludes_minoxidil_when_flag_false(session: Session):
|
||||
def test_get_available_products_excludes_minoxidil_when_flag_false(
|
||||
session: Session,
|
||||
current_user,
|
||||
):
|
||||
minoxidil = Product(
|
||||
id=uuid.uuid4(),
|
||||
name="Minoxidil 5%",
|
||||
|
|
@ -518,6 +631,7 @@ def test_get_available_products_excludes_minoxidil_when_flag_false(session: Sess
|
|||
recommended_time="both",
|
||||
leave_on=True,
|
||||
product_effect_profile={},
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
regular = Product(
|
||||
id=uuid.uuid4(),
|
||||
|
|
@ -527,18 +641,27 @@ def test_get_available_products_excludes_minoxidil_when_flag_false(session: Sess
|
|||
recommended_time="both",
|
||||
leave_on=False,
|
||||
product_effect_profile={},
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
session.add_all([minoxidil, regular])
|
||||
session.commit()
|
||||
|
||||
# With flag True (default) - minoxidil included
|
||||
products = _get_available_products(session, include_minoxidil=True)
|
||||
products = _get_available_products(
|
||||
session,
|
||||
current_user=current_user,
|
||||
include_minoxidil=True,
|
||||
)
|
||||
names = {p.name for p in products}
|
||||
assert "Minoxidil 5%" in names
|
||||
assert "Cleanser" in names
|
||||
|
||||
# With flag False - minoxidil excluded
|
||||
products = _get_available_products(session, include_minoxidil=False)
|
||||
products = _get_available_products(
|
||||
session,
|
||||
current_user=current_user,
|
||||
include_minoxidil=False,
|
||||
)
|
||||
names = {p.name for p in products}
|
||||
assert "Minoxidil 5%" not in names
|
||||
assert "Cleanser" in names
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ def test_analyze_photos_includes_user_profile_context(client, monkeypatch):
|
|||
|
||||
def _fake_call_gemini(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _FakeResponse()
|
||||
return _FakeResponse(), None
|
||||
|
||||
monkeypatch.setattr(skincare_api, "call_gemini", _fake_call_gemini)
|
||||
|
||||
|
|
|
|||
100
backend/tests/test_tenancy_domains.py
Normal file
100
backend/tests/test_tenancy_domains.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from innercontext.api.auth_deps import get_current_user
|
||||
from innercontext.auth import CurrentUser, IdentityData, TokenClaims
|
||||
from innercontext.models import Role
|
||||
from innercontext.models.ai_log import AICallLog
|
||||
from main import app
|
||||
|
||||
|
||||
def _user(subject: str, *, role: Role = Role.MEMBER) -> CurrentUser:
|
||||
claims = TokenClaims(
|
||||
issuer="https://auth.test",
|
||||
subject=subject,
|
||||
audience=("innercontext-web",),
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
raw_claims={"iss": "https://auth.test", "sub": subject},
|
||||
)
|
||||
return CurrentUser(
|
||||
user_id=uuid4(),
|
||||
role=role,
|
||||
identity=IdentityData.from_claims(claims),
|
||||
claims=claims,
|
||||
)
|
||||
|
||||
|
||||
def _set_current_user(user: CurrentUser) -> None:
|
||||
app.dependency_overrides[get_current_user] = lambda: user
|
||||
|
||||
|
||||
def test_profile_health_routines_skincare_ai_logs_are_user_scoped_by_default(
|
||||
client, session
|
||||
):
|
||||
owner = _user("owner")
|
||||
intruder = _user("intruder")
|
||||
|
||||
_set_current_user(owner)
|
||||
profile = client.patch(
|
||||
"/profile", json={"birth_date": "1991-01-15", "sex_at_birth": "male"}
|
||||
)
|
||||
medication = client.post(
|
||||
"/health/medications", json={"kind": "prescription", "product_name": "Owner Rx"}
|
||||
)
|
||||
routine = client.post(
|
||||
"/routines", json={"routine_date": "2026-03-01", "part_of_day": "am"}
|
||||
)
|
||||
snapshot = client.post("/skincare", json={"snapshot_date": "2026-03-01"})
|
||||
log = AICallLog(endpoint="routines/suggest", model="gemini-3-flash-preview")
|
||||
log.user_id = owner.user_id
|
||||
session.add(log)
|
||||
session.commit()
|
||||
session.refresh(log)
|
||||
|
||||
assert profile.status_code == 200
|
||||
assert medication.status_code == 201
|
||||
assert routine.status_code == 201
|
||||
assert snapshot.status_code == 201
|
||||
|
||||
medication_id = medication.json()["record_id"]
|
||||
routine_id = routine.json()["id"]
|
||||
snapshot_id = snapshot.json()["id"]
|
||||
|
||||
_set_current_user(intruder)
|
||||
assert client.get("/profile").json() is None
|
||||
assert client.get("/health/medications").json() == []
|
||||
assert client.get("/routines").json() == []
|
||||
assert client.get("/skincare").json() == []
|
||||
assert client.get("/ai-logs").json() == []
|
||||
|
||||
assert client.get(f"/health/medications/{medication_id}").status_code == 404
|
||||
assert client.get(f"/routines/{routine_id}").status_code == 404
|
||||
assert client.get(f"/skincare/{snapshot_id}").status_code == 404
|
||||
assert client.get(f"/ai-logs/{log.id}").status_code == 404
|
||||
|
||||
|
||||
def test_health_admin_override_requires_explicit_user_id(client):
|
||||
owner = _user("owner")
|
||||
admin = _user("admin", role=Role.ADMIN)
|
||||
|
||||
_set_current_user(owner)
|
||||
created = client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
"collected_at": "2026-03-01T00:00:00",
|
||||
"test_code": "718-7",
|
||||
"test_name_original": "Hemoglobin",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
_set_current_user(admin)
|
||||
default_scope = client.get("/health/lab-results")
|
||||
assert default_scope.status_code == 200
|
||||
assert default_scope.json()["items"] == []
|
||||
|
||||
overridden = client.get(f"/health/lab-results?user_id={owner.user_id}")
|
||||
assert overridden.status_code == 200
|
||||
assert len(overridden.json()["items"]) == 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue