"""Tests for the Phase 2 reflection endpoints. The Anthropic-backed ReflectionCoach is replaced with a FakeCoach (via monkeypatch on the router) so these run offline and deterministically. Auth uses the X-API-Key admin identity. """ import uuid from datetime import datetime, timezone import pytest API_KEY = {"X-API-Key": "test-api-key"} async def _seed_profile(locked: bool = False) -> str: from app.auth import API_KEY_ADMIN_ID, ensure_api_key_admin from app.database import AsyncSessionLocal from app.models import DiscoveryConversation, DiscoveryProfile async with AsyncSessionLocal() as db: await ensure_api_key_admin(db) conv = DiscoveryConversation( id=str(uuid.uuid4()), user_id=API_KEY_ADMIN_ID, started_at=datetime.now(timezone.utc), ) db.add(conv) await db.commit() profile = DiscoveryProfile( id=str(uuid.uuid4()), user_id=API_KEY_ADMIN_ID, conversation_id=conv.id, generated_at=datetime.now(timezone.utc), triad="gut", probable_type=8, wing=9, short_term_goals="orig short", long_term_goals="orig long", overlap_narrative="orig narrative", locked=locked, ) db.add(profile) await db.commit() return profile.id class FakeCoach: """Stand-in for ReflectionCoach. Returns a configurable response and records the (profile, history) it was called with.""" response = {"message": "Here is what I am hearing — does it fit?", "revisions": None, "revision_note": None} calls = [] def __init__(self, api_key=None, model=None): pass async def reflect(self, profile, history): FakeCoach.calls.append({"profile": profile, "history": list(history)}) return dict(FakeCoach.response) @pytest.fixture(autouse=True) async def fake_coach(monkeypatch, app_client): # Depends on app_client so it patches the *reimported* router module # (the app_client fixture reloads app.* modules before this runs). FakeCoach.calls = [] FakeCoach.response = { "message": "Here is what I am hearing — does it fit?", "revisions": None, "revision_note": None, } monkeypatch.setattr("app.routers.discovery.ReflectionCoach", FakeCoach) yield async def test_opening_reflection(app_client): await _seed_profile() r = await app_client.post( "/discovery/profile/me/reflect", headers=API_KEY, json={} ) assert r.status_code == 200 body = r.json() assert body["message"]["role"] == "coach" assert body["revised"] is False # Opener is called with no history. assert FakeCoach.calls[-1]["history"] == [] # The thread now has exactly one coach message. thread = (await app_client.get( "/discovery/profile/me/reflection", headers=API_KEY )).json() assert len(thread["messages"]) == 1 assert thread["messages"][0]["role"] == "coach" async def test_person_turn_records_both_messages(app_client): await _seed_profile() await app_client.post("/discovery/profile/me/reflect", headers=API_KEY, json={}) r = await app_client.post( "/discovery/profile/me/reflect", headers=API_KEY, json={"message": "Mostly right, thanks."}, ) assert r.status_code == 200 thread = (await app_client.get( "/discovery/profile/me/reflection", headers=API_KEY )).json() roles = [m["role"] for m in thread["messages"]] assert roles == ["coach", "person", "coach"] # The coach saw the person's turn in history. assert FakeCoach.calls[-1]["history"][-1] == { "role": "person", "content": "Mostly right, thanks." } async def test_reflection_applies_revision_and_records_history(app_client): await _seed_profile() FakeCoach.response = { "message": "Updated your near-term goal.", "revisions": {"short_term_goals": "Launch the pilot this fall."}, "revision_note": "near-term goal updated", } r = await app_client.post( "/discovery/profile/me/reflect", headers=API_KEY, json={"message": "Change my short-term goal."}, ) assert r.status_code == 200 body = r.json() assert body["revised"] is True assert body["profile"]["short_term_goals"] == "Launch the pilot this fall." assert body["revision_note"] == "near-term goal updated" # Persisted on the profile. prof = (await app_client.get( "/discovery/profile/me", headers=API_KEY )).json() assert prof["short_term_goals"] == "Launch the pilot this fall." # Recorded in the revision history with source 'reflection'. revs = (await app_client.get( "/discovery/profile/me/revisions", headers=API_KEY )).json() assert any(rev["source"] == "reflection" for rev in revs) async def test_endpoint_ignores_noneditable_revision_fields(app_client): await _seed_profile() FakeCoach.response = { "message": "ok", "revisions": {"triad": "head", "long_term_goals": "Statewide network."}, "revision_note": "x", } r = await app_client.post( "/discovery/profile/me/reflect", headers=API_KEY, json={"message": "tweak"}, ) body = r.json() assert body["profile"]["long_term_goals"] == "Statewide network." assert body["profile"]["triad"] == "gut" # unchanged async def test_reflect_on_locked_profile_returns_409(app_client): await _seed_profile(locked=True) r = await app_client.post( "/discovery/profile/me/reflect", headers=API_KEY, json={"message": "hi"} ) assert r.status_code == 409 async def test_empty_message_after_opener_returns_400(app_client): await _seed_profile() await app_client.post("/discovery/profile/me/reflect", headers=API_KEY, json={}) r = await app_client.post( "/discovery/profile/me/reflect", headers=API_KEY, json={} ) assert r.status_code == 400 async def test_reflect_requires_auth(app_client): r = await app_client.post("/discovery/profile/me/reflect", json={}) assert r.status_code == 401 async def test_reflection_without_profile_404(app_client): r = await app_client.post( "/discovery/profile/me/reflect", headers=API_KEY, json={} ) assert r.status_code == 404