"""Tests for database.py — uses in-memory SQLite.""" import pytest from reachy_mini_conversation_app.database import MiniMinderDB @pytest.fixture def db() -> MiniMinderDB: """Create an in-memory database for testing.""" return MiniMinderDB(":memory:") def test_schema_creation(db: MiniMinderDB) -> None: """Tables should exist after init.""" conn = db._get_conn() tables = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" ).fetchall() table_names = [t["name"] for t in tables] assert "headache_entries" in table_names assert "medication_entries" in table_names def test_insert_headache(db: MiniMinderDB) -> None: """Insert and retrieve a headache entry.""" row_id = db.insert_headache( { "intensity": 7, "location": "left temple", "pain_type": "throbbing", "onset_time": "2 hours ago", } ) assert row_id == 1 entries = db.get_recent_headaches(days=1) assert len(entries) == 1 assert entries[0]["intensity"] == 7 assert entries[0]["location"] == "left temple" def test_insert_medication(db: MiniMinderDB) -> None: """Insert and retrieve a medication entry.""" row_id = db.insert_medication( { "medication_name": "Ibuprofen", "dose": "400mg", "taken": 1, } ) assert row_id == 1 entries = db.get_recent_medications(days=1) assert len(entries) == 1 assert entries[0]["medication_name"] == "Ibuprofen" assert entries[0]["dose"] == "400mg" def test_list_to_string_conversion(db: MiniMinderDB) -> None: """List fields should be stored as comma-separated strings.""" db.insert_headache( { "intensity": 5, "associated_symptoms": ["nausea", "light sensitivity"], "triggers": ["stress", "lack of sleep"], } ) entries = db.get_recent_headaches(days=1) assert "nausea" in entries[0]["associated_symptoms"] assert "stress" in entries[0]["triggers"] def test_get_recent_respects_days(db: MiniMinderDB) -> None: """Entries older than N days should not be returned (mocked via small cutoff).""" db.insert_headache({"intensity": 3}) # All entries created 'now' should appear within 1 day assert len(db.get_recent_headaches(days=1)) == 1 # But also within 30 days assert len(db.get_recent_headaches(days=30)) == 1 def test_export_csv_empty(db: MiniMinderDB) -> None: """Export CSV should return empty string when no entries.""" assert db.export_csv("headache", 30) == "" def test_export_csv_with_data(db: MiniMinderDB) -> None: """Export CSV should contain header and data rows.""" db.insert_headache({"intensity": 5, "location": "forehead"}) csv_output = db.export_csv("headache", 30) assert "intensity" in csv_output assert "forehead" in csv_output def test_export_html_empty(db: MiniMinderDB) -> None: """Export HTML should show 'No entries found' when empty.""" html = db.export_html("headache", 30) assert "No entries found" in html def test_export_html_with_data(db: MiniMinderDB) -> None: """Export HTML should contain a table when data exists.""" db.insert_medication({"medication_name": "Aspirin", "dose": "325mg"}) html = db.export_html("medication", 30) assert "" in html assert "Aspirin" in html def test_multiple_entries(db: MiniMinderDB) -> None: """Insert multiple entries and verify count.""" for i in range(5): db.insert_headache({"intensity": i + 1}) assert len(db.get_recent_headaches(days=1)) == 5 def test_close(db: MiniMinderDB) -> None: """close() should not raise and should clean up connection.""" db.insert_headache({"intensity": 1}) assert len(db.get_recent_headaches(days=1)) == 1 db.close() assert db._conn is None # ---- Token Usage / Caching Tests ---- def test_log_token_usage_with_cached(db: MiniMinderDB) -> None: """Token usage should persist with cached token counts.""" row_id = db.log_token_usage( session_id="test-session", input_tokens_text=1000, input_tokens_audio=500, output_tokens_text=200, output_tokens_audio=100, cached_tokens_text=600, cached_tokens_audio=300, estimated_cost_usd=0.042, ) assert row_id == 1 # Verify via raw query conn = db._get_conn() row = conn.execute("SELECT * FROM token_usage WHERE id = ?", (row_id,)).fetchone() assert row["cached_tokens_text"] == 600 assert row["cached_tokens_audio"] == 300 assert row["input_tokens_text"] == 1000 def test_session_cost_summary_includes_cache_rate(db: MiniMinderDB) -> None: """Cost summary should return cached totals and cache_hit_rate.""" db.log_token_usage( session_id="s1", input_tokens_text=1000, input_tokens_audio=0, cached_tokens_text=400, estimated_cost_usd=0.01, ) db.log_token_usage( session_id="s1", input_tokens_text=2000, input_tokens_audio=0, cached_tokens_text=1600, estimated_cost_usd=0.02, ) summary = db.get_session_cost_summary("s1") assert summary["cached_text"] == 2000 # 400 + 1600 assert summary["cached_audio"] == 0 assert summary["input_text"] == 3000 assert summary["response_count"] == 2 # cache_hit_rate = 2000 / 3000 = 0.6667 assert abs(summary["cache_hit_rate"] - 0.6667) < 0.001 def test_session_cost_summary_zero_input(db: MiniMinderDB) -> None: """Cache hit rate should be 0 when there are no input tokens.""" summary = db.get_session_cost_summary("nonexistent") assert summary["cache_hit_rate"] == 0.0 assert summary["cached_text"] == 0 def test_migrate_cached_token_columns(db: MiniMinderDB) -> None: """Migration should be idempotent — running it again should not error.""" conn = db._get_conn() # Run migration again (already ran during init) db._migrate_add_cached_token_columns(conn) # Should still have the columns cursor = conn.execute("PRAGMA table_info(token_usage)") columns = [row[1] for row in cursor.fetchall()] assert "cached_tokens_text" in columns assert "cached_tokens_audio" in columns # ---- Cross-Session Summary Tests ---- def test_prior_session_summary_empty(db: MiniMinderDB) -> None: """Should return empty string when no prior sessions exist.""" from reachy_mini_conversation_app.database import ConversationLogger logger = ConversationLogger(db) result = logger.get_prior_session_summary(current_session_id="current") assert result == "" def test_prior_session_summary_with_data(db: MiniMinderDB) -> None: """Should build a formatted summary from prior session turns.""" from reachy_mini_conversation_app.database import ConversationLogger logger = ConversationLogger(db) # Log turns for a prior session logger.log_turn("prior-1", "user", "I took my morning medication") logger.log_turn("prior-1", "assistant", "Great, I've noted that.") logger.log_turn("prior-1", "user", "I have a headache today") # Request summary excluding current session result = logger.get_prior_session_summary( current_session_id="current-session", max_sessions=3, ) assert "## Prior Session Context" in result assert "morning medication" in result assert "headache" in result def test_prior_session_summary_excludes_current(db: MiniMinderDB) -> None: """Should exclude the current session from the summary.""" from reachy_mini_conversation_app.database import ConversationLogger logger = ConversationLogger(db) # Log turns for current and prior sessions logger.log_turn("current", "user", "Hello today") logger.log_turn("prior", "user", "Yesterday's chat") result = logger.get_prior_session_summary( current_session_id="current", max_sessions=3, ) assert "Yesterday" in result assert "Hello today" not in result # ---- Prompt Assembly Tests ---- def test_build_session_instructions_with_graph_context() -> None: """Prompt should include graph context and prior sessions when provided.""" from reachy_mini_conversation_app.prompts import ( SessionState, build_session_instructions, ) state = SessionState( onboarding_completed=True, graph_context="## Knowledge Graph Context\n- Takes Topiramate 50mg", prior_session_summary="## Prior Sessions\n- Mentioned headache", ) prompt = build_session_instructions(state) assert "Knowledge Graph Context" in prompt assert "Topiramate" in prompt assert "Prior Sessions" in prompt assert "headache" in prompt def test_build_session_instructions_without_context() -> None: """Prompt should work fine without graph or session context.""" from reachy_mini_conversation_app.prompts import ( SessionState, build_session_instructions, ) state = SessionState(onboarding_completed=True) prompt = build_session_instructions(state) assert "Knowledge Graph Context" not in prompt assert "Prior Session" not in prompt # Should still have core sections assert len(prompt) > 100