feat(api): enforce ownership across health routines and profile flows

This commit is contained in:
Piotr Oleszczyk 2026-03-12 15:48:13 +01:00
parent cd8e39939a
commit ffa3b71309
14 changed files with 1225 additions and 206 deletions

View file

@ -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