Files
impactflow_discovery/tests/test_extractor.py
T
Joel Salmon b8f176bb31 Initial commit: ImpactFlow Discovery + Google OAuth auth layer
Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.

Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:

- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
  dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
  allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
  tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
  /api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
  /api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
  dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
  resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
  data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
  ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log

Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:59:41 -05:00

173 lines
5.7 KiB
Python

"""Tests for DiscoveryExtractor.
The Anthropic API is mocked: a FakeClient returns canned JSON so the suite is
deterministic and runs without a network call or API key. The fixtures supply
realistic narrative input, so these tests exercise the full extract() pipeline
(message building, parsing, validation, and retry) end to end.
"""
import json
import os
from types import SimpleNamespace
import pytest
from app.services.extractor import (
DiscoveryExtractionError,
DiscoveryExtractor,
)
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
def load_fixture(name: str) -> dict:
with open(os.path.join(FIXTURE_DIR, name), encoding="utf-8") as fh:
return json.load(fh)
class FakeMessages:
"""Stand-in for client.messages that returns queued responses."""
def __init__(self, responses):
self._responses = list(responses)
self.calls = []
async def create(self, **kwargs):
self.calls.append(kwargs)
if not self._responses:
raise AssertionError("messages.create called more times than queued")
text = self._responses.pop(0)
return SimpleNamespace(content=[SimpleNamespace(text=text)])
class FakeClient:
def __init__(self, responses):
self.messages = FakeMessages(responses)
def make_profile_json(
triad: str,
probable_type: int,
wing: int,
variant: str = "sp",
stack: str = "sp/so/sx",
) -> str:
"""Build a well-formed profile JSON string for the given archetype."""
return json.dumps(
{
"triad": triad,
"probable_type": probable_type,
"wing": wing,
"instinctual_variant": variant,
"instinctual_stack": stack,
"love_summary": "You light up around hands-on, purposeful work.",
"strength_summary": "You see the whole picture and act decisively.",
"mission_summary": "People around you need protection and clarity.",
"vocation_summary": "You can be paid to lead and build under pressure.",
"overlap_narrative": (
"You come most alive where your instinct to act, your eye for "
"what's broken, and the world's need for someone steady all "
"meet. The work that fits you lets you move first and bring "
"others with you."
),
"confidence": {
"triad": "high",
"type": "medium",
"variant": "medium",
"ikigai": "high",
},
"extraction_notes": "",
}
)
def make_extractor(responses) -> DiscoveryExtractor:
extractor = DiscoveryExtractor(api_key="test-key")
extractor.client = FakeClient(responses)
return extractor
@pytest.mark.asyncio
async def test_gut_type_extraction():
responses = load_fixture("gut_type_responses.json")
extractor = make_extractor([make_profile_json("gut", 8, 9)])
result = await extractor.extract(responses)
assert result["triad"] == "gut"
assert result["probable_type"] in (8, 9, 1)
# the model was actually called and the friction story was in the prompt
sent = extractor.client.messages.calls[0]["messages"][0]["content"]
assert "The Friction Moment" in sent
assert "regional manager" in sent
@pytest.mark.asyncio
async def test_heart_type_extraction():
responses = load_fixture("heart_type_responses.json")
extractor = make_extractor([make_profile_json("heart", 2, 3)])
result = await extractor.extract(responses)
assert result["triad"] == "heart"
assert result["probable_type"] in (2, 3, 4)
@pytest.mark.asyncio
async def test_head_type_extraction():
responses = load_fixture("head_type_responses.json")
extractor = make_extractor([make_profile_json("head", 5, 6)])
result = await extractor.extract(responses)
assert result["triad"] == "head"
assert result["probable_type"] in (5, 6, 7)
@pytest.mark.asyncio
async def test_json_parse_failure_retry():
"""An invalid first response triggers exactly one retry, then succeeds."""
valid = make_profile_json("head", 5, 6)
extractor = make_extractor(["here is your profile: not-json!!!", valid])
responses = load_fixture("head_type_responses.json")
result = await extractor.extract(responses)
assert result["triad"] == "head"
assert len(extractor.client.messages.calls) == 2
# the retry message includes an explicit JSON-only reminder
retry_content = extractor.client.messages.calls[1]["messages"][0]["content"]
assert "JSON" in retry_content
@pytest.mark.asyncio
async def test_retry_exhausted_raises():
"""Two unparseable responses surface a DiscoveryExtractionError."""
extractor = make_extractor(["nope", "still not json"])
with pytest.raises(DiscoveryExtractionError):
await extractor.extract(load_fixture("gut_type_responses.json"))
assert len(extractor.client.messages.calls) == 2
@pytest.mark.asyncio
async def test_overlap_narrative_present():
cases = [
("gut_type_responses.json", "gut", 8, 9),
("heart_type_responses.json", "heart", 2, 3),
("head_type_responses.json", "head", 5, 6),
]
for fixture, triad, ptype, wing in cases:
extractor = make_extractor([make_profile_json(triad, ptype, wing)])
result = await extractor.extract(load_fixture(fixture))
assert isinstance(result["overlap_narrative"], str)
assert result["overlap_narrative"].strip() != ""
@pytest.mark.asyncio
async def test_confidence_flags_present():
extractor = make_extractor([make_profile_json("heart", 3, 2)])
result = await extractor.extract(load_fixture("heart_type_responses.json"))
confidence = result["confidence"]
for key in ("triad", "type", "variant", "ikigai"):
assert key in confidence