Implement Phase 1: Safety & Validation for all LLM-based suggestion engines. - Add input sanitization module to prevent prompt injection attacks - Implement 5 comprehensive validators (routine, batch, shopping, product parse, photo) - Add 10+ critical safety checks (retinoid+acid conflicts, barrier compatibility, etc.) - Integrate validation into all 5 API endpoints (routines, products, skincare) - Add validation fields to ai_call_logs table (validation_errors, validation_warnings, auto_fixed) - Create database migration for validation fields - Add comprehensive test suite (9/9 tests passing, 88% coverage on validators) Safety improvements: - Blocks retinoid + acid conflicts in same routine/day - Rejects unknown product IDs - Enforces min_interval_hours rules - Protects compromised skin barriers - Prevents prohibited fields (dose, amount) in responses - Validates all enum values and score ranges All validation failures are logged and responses are rejected with HTTP 502.
378 lines
10 KiB
Python
378 lines
10 KiB
Python
"""Tests for RoutineSuggestionValidator."""
|
|
|
|
from datetime import date, timedelta
|
|
from uuid import uuid4
|
|
|
|
from innercontext.validators.routine_validator import (
|
|
RoutineSuggestionValidator,
|
|
RoutineValidationContext,
|
|
)
|
|
|
|
|
|
# Helper to create mock product
|
|
class MockProduct:
|
|
def __init__(
|
|
self,
|
|
product_id,
|
|
name,
|
|
actives=None,
|
|
effect_profile=None,
|
|
context_rules=None,
|
|
min_interval_hours=None,
|
|
category="serum",
|
|
):
|
|
self.id = product_id
|
|
self.name = name
|
|
self.actives = actives or []
|
|
self.effect_profile = effect_profile
|
|
self.context_rules = context_rules
|
|
self.min_interval_hours = min_interval_hours
|
|
self.category = category
|
|
|
|
|
|
# Helper to create mock active ingredient
|
|
class MockActive:
|
|
def __init__(self, functions):
|
|
self.functions = functions
|
|
|
|
|
|
# Helper to create mock effect profile
|
|
class MockEffectProfile:
|
|
def __init__(
|
|
self,
|
|
retinoid_strength=0,
|
|
exfoliation_strength=0,
|
|
barrier_disruption_risk=0,
|
|
irritation_risk=0,
|
|
):
|
|
self.retinoid_strength = retinoid_strength
|
|
self.exfoliation_strength = exfoliation_strength
|
|
self.barrier_disruption_risk = barrier_disruption_risk
|
|
self.irritation_risk = irritation_risk
|
|
|
|
|
|
# Helper to create mock context rules
|
|
class MockContextRules:
|
|
def __init__(self, safe_after_shaving=True, safe_with_compromised_barrier=True):
|
|
self.safe_after_shaving = safe_after_shaving
|
|
self.safe_with_compromised_barrier = safe_with_compromised_barrier
|
|
|
|
|
|
# Helper to create mock routine step
|
|
class MockStep:
|
|
def __init__(self, product_id=None, action_type=None, **kwargs):
|
|
self.product_id = product_id
|
|
self.action_type = action_type
|
|
for key, value in kwargs.items():
|
|
setattr(self, key, value)
|
|
|
|
|
|
# Helper to create mock routine response
|
|
class MockRoutine:
|
|
def __init__(self, steps):
|
|
self.steps = steps
|
|
|
|
|
|
def test_detects_retinoid_acid_conflict():
|
|
"""Validator catches retinoid + AHA/BHA in same routine."""
|
|
# Setup
|
|
retinoid_id = uuid4()
|
|
acid_id = uuid4()
|
|
|
|
retinoid = MockProduct(
|
|
retinoid_id,
|
|
"Retinoid Serum",
|
|
actives=[MockActive(functions=["retinoid"])],
|
|
effect_profile=MockEffectProfile(retinoid_strength=3),
|
|
)
|
|
|
|
acid = MockProduct(
|
|
acid_id,
|
|
"AHA Toner",
|
|
actives=[MockActive(functions=["exfoliant_aha"])],
|
|
effect_profile=MockEffectProfile(exfoliation_strength=4),
|
|
)
|
|
|
|
context = RoutineValidationContext(
|
|
valid_product_ids={retinoid_id, acid_id},
|
|
routine_date=date.today(),
|
|
part_of_day="pm",
|
|
leaving_home=None,
|
|
barrier_state="intact",
|
|
products_by_id={retinoid_id: retinoid, acid_id: acid},
|
|
last_used_dates={},
|
|
)
|
|
|
|
routine = MockRoutine(
|
|
steps=[
|
|
MockStep(product_id=retinoid_id),
|
|
MockStep(product_id=acid_id),
|
|
]
|
|
)
|
|
|
|
# Execute
|
|
validator = RoutineSuggestionValidator()
|
|
result = validator.validate(routine, context)
|
|
|
|
# Assert
|
|
assert not result.is_valid
|
|
assert any(
|
|
"retinoid" in err.lower() and "acid" in err.lower() for err in result.errors
|
|
)
|
|
|
|
|
|
def test_rejects_unknown_product_ids():
|
|
"""Validator catches UUIDs not in database."""
|
|
known_id = uuid4()
|
|
unknown_id = uuid4()
|
|
|
|
product = MockProduct(known_id, "Known Product")
|
|
|
|
context = RoutineValidationContext(
|
|
valid_product_ids={known_id}, # Only known_id is valid
|
|
routine_date=date.today(),
|
|
part_of_day="am",
|
|
leaving_home=None,
|
|
barrier_state="intact",
|
|
products_by_id={known_id: product},
|
|
last_used_dates={},
|
|
)
|
|
|
|
routine = MockRoutine(
|
|
steps=[
|
|
MockStep(product_id=unknown_id), # This ID doesn't exist
|
|
]
|
|
)
|
|
|
|
validator = RoutineSuggestionValidator()
|
|
result = validator.validate(routine, context)
|
|
|
|
assert not result.is_valid
|
|
assert any("unknown" in err.lower() for err in result.errors)
|
|
|
|
|
|
def test_enforces_min_interval_hours():
|
|
"""Validator catches product used within min_interval."""
|
|
product_id = uuid4()
|
|
product = MockProduct(
|
|
product_id,
|
|
"High Frequency Product",
|
|
min_interval_hours=48, # Must wait 48 hours
|
|
)
|
|
|
|
today = date.today()
|
|
yesterday = today - timedelta(days=1) # Only 24 hours ago
|
|
|
|
context = RoutineValidationContext(
|
|
valid_product_ids={product_id},
|
|
routine_date=today,
|
|
part_of_day="am",
|
|
leaving_home=None,
|
|
barrier_state="intact",
|
|
products_by_id={product_id: product},
|
|
last_used_dates={product_id: yesterday}, # Used yesterday
|
|
)
|
|
|
|
routine = MockRoutine(
|
|
steps=[
|
|
MockStep(product_id=product_id),
|
|
]
|
|
)
|
|
|
|
validator = RoutineSuggestionValidator()
|
|
result = validator.validate(routine, context)
|
|
|
|
assert not result.is_valid
|
|
assert any(
|
|
"interval" in err.lower() or "recently" in err.lower() for err in result.errors
|
|
)
|
|
|
|
|
|
def test_blocks_dose_field():
|
|
"""Validator rejects responses with prohibited 'dose' field."""
|
|
product_id = uuid4()
|
|
product = MockProduct(product_id, "Product")
|
|
|
|
context = RoutineValidationContext(
|
|
valid_product_ids={product_id},
|
|
routine_date=date.today(),
|
|
part_of_day="am",
|
|
leaving_home=None,
|
|
barrier_state="intact",
|
|
products_by_id={product_id: product},
|
|
last_used_dates={},
|
|
)
|
|
|
|
# Step with prohibited 'dose' field
|
|
step_with_dose = MockStep(product_id=product_id, dose="2 drops")
|
|
routine = MockRoutine(steps=[step_with_dose])
|
|
|
|
validator = RoutineSuggestionValidator()
|
|
result = validator.validate(routine, context)
|
|
|
|
assert not result.is_valid
|
|
assert any(
|
|
"dose" in err.lower() and "prohibited" in err.lower() for err in result.errors
|
|
)
|
|
|
|
|
|
def test_missing_spf_in_am_leaving_home():
|
|
"""Validator warns when no SPF despite leaving home."""
|
|
product_id = uuid4()
|
|
product = MockProduct(product_id, "Moisturizer", category="moisturizer")
|
|
|
|
context = RoutineValidationContext(
|
|
valid_product_ids={product_id},
|
|
routine_date=date.today(),
|
|
part_of_day="am",
|
|
leaving_home=True, # User is leaving home
|
|
barrier_state="intact",
|
|
products_by_id={product_id: product},
|
|
last_used_dates={},
|
|
)
|
|
|
|
routine = MockRoutine(
|
|
steps=[
|
|
MockStep(product_id=product_id), # No SPF product
|
|
]
|
|
)
|
|
|
|
validator = RoutineSuggestionValidator()
|
|
result = validator.validate(routine, context)
|
|
|
|
# Should pass validation but have warnings
|
|
assert result.is_valid
|
|
assert len(result.warnings) > 0
|
|
assert any("spf" in warn.lower() for warn in result.warnings)
|
|
|
|
|
|
def test_compromised_barrier_restrictions():
|
|
"""Validator blocks high-risk actives with compromised barrier."""
|
|
product_id = uuid4()
|
|
harsh_product = MockProduct(
|
|
product_id,
|
|
"Harsh Acid",
|
|
effect_profile=MockEffectProfile(
|
|
barrier_disruption_risk=5, # Very high risk
|
|
irritation_risk=4,
|
|
),
|
|
context_rules=MockContextRules(safe_with_compromised_barrier=False),
|
|
)
|
|
|
|
context = RoutineValidationContext(
|
|
valid_product_ids={product_id},
|
|
routine_date=date.today(),
|
|
part_of_day="pm",
|
|
leaving_home=None,
|
|
barrier_state="compromised", # Barrier is compromised
|
|
products_by_id={product_id: harsh_product},
|
|
last_used_dates={},
|
|
)
|
|
|
|
routine = MockRoutine(
|
|
steps=[
|
|
MockStep(product_id=product_id),
|
|
]
|
|
)
|
|
|
|
validator = RoutineSuggestionValidator()
|
|
result = validator.validate(routine, context)
|
|
|
|
assert not result.is_valid
|
|
assert any(
|
|
"barrier" in err.lower() and "safety" in err.lower() for err in result.errors
|
|
)
|
|
|
|
|
|
def test_step_must_have_product_or_action():
|
|
"""Validator rejects steps with neither product_id nor action_type."""
|
|
context = RoutineValidationContext(
|
|
valid_product_ids=set(),
|
|
routine_date=date.today(),
|
|
part_of_day="am",
|
|
leaving_home=None,
|
|
barrier_state="intact",
|
|
products_by_id={},
|
|
last_used_dates={},
|
|
)
|
|
|
|
# Empty step (neither product nor action)
|
|
routine = MockRoutine(
|
|
steps=[
|
|
MockStep(product_id=None, action_type=None),
|
|
]
|
|
)
|
|
|
|
validator = RoutineSuggestionValidator()
|
|
result = validator.validate(routine, context)
|
|
|
|
assert not result.is_valid
|
|
assert any("product_id" in err and "action_type" in err for err in result.errors)
|
|
|
|
|
|
def test_step_cannot_have_both_product_and_action():
|
|
"""Validator rejects steps with both product_id and action_type."""
|
|
product_id = uuid4()
|
|
product = MockProduct(product_id, "Product")
|
|
|
|
context = RoutineValidationContext(
|
|
valid_product_ids={product_id},
|
|
routine_date=date.today(),
|
|
part_of_day="am",
|
|
leaving_home=None,
|
|
barrier_state="intact",
|
|
products_by_id={product_id: product},
|
|
last_used_dates={},
|
|
)
|
|
|
|
# Step with both product_id AND action_type (invalid)
|
|
routine = MockRoutine(
|
|
steps=[
|
|
MockStep(product_id=product_id, action_type="shaving"),
|
|
]
|
|
)
|
|
|
|
validator = RoutineSuggestionValidator()
|
|
result = validator.validate(routine, context)
|
|
|
|
assert not result.is_valid
|
|
assert any("cannot have both" in err.lower() for err in result.errors)
|
|
|
|
|
|
def test_accepts_valid_routine():
|
|
"""Validator accepts a properly formed safe routine."""
|
|
cleanser_id = uuid4()
|
|
moisturizer_id = uuid4()
|
|
spf_id = uuid4()
|
|
|
|
cleanser = MockProduct(cleanser_id, "Cleanser", category="cleanser")
|
|
moisturizer = MockProduct(moisturizer_id, "Moisturizer", category="moisturizer")
|
|
spf = MockProduct(spf_id, "SPF", category="spf")
|
|
|
|
context = RoutineValidationContext(
|
|
valid_product_ids={cleanser_id, moisturizer_id, spf_id},
|
|
routine_date=date.today(),
|
|
part_of_day="am",
|
|
leaving_home=True,
|
|
barrier_state="intact",
|
|
products_by_id={
|
|
cleanser_id: cleanser,
|
|
moisturizer_id: moisturizer,
|
|
spf_id: spf,
|
|
},
|
|
last_used_dates={},
|
|
)
|
|
|
|
routine = MockRoutine(
|
|
steps=[
|
|
MockStep(product_id=cleanser_id),
|
|
MockStep(product_id=moisturizer_id),
|
|
MockStep(product_id=spf_id),
|
|
]
|
|
)
|
|
|
|
validator = RoutineSuggestionValidator()
|
|
result = validator.validate(routine, context)
|
|
|
|
assert result.is_valid
|
|
assert len(result.errors) == 0
|