"""Tests for diary-to-graph import feature. Covers: - Database.get_all_conversation_summaries() method - /api/graph/import-diary streaming endpoint (requires flask) """ import json import sqlite3 import sys import types from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest # Mock modules that may not be available in the test environment _MOCK_MODULES = [ "PyQt6", "PyQt6.QtWidgets", "PyQt6.QtCore", "PyQt6.QtGui", "PyQt6.QtWebEngineWidgets", "PyQt6.sip", "requests", "requests.exceptions", "psutil", ] for _mod in _MOCK_MODULES: if _mod not in sys.modules: sys.modules[_mod] = MagicMock() # Ensure requests.exceptions.Timeout is a proper exception class sys.modules["requests"].exceptions.Timeout = type("Timeout", (Exception,), {}) from src.jarvis.memory.db import Database # ── Database method tests ───────────────────────────────────────────── @pytest.fixture def db_with_summaries(tmp_path): """Provide a database pre-populated with conversation summaries.""" db = Database(str(tmp_path / "test.db"), sqlite_vss_path=None) # Insert some summaries in non-chronological order to test ordering summaries = [ ("2025-03-15", "User discussed work projects and deadlines.", "work,planning", "jarvis"), ("2025-01-10", "User talked about favourite coffee shops.", "food,coffee", "jarvis"), ("2025-06-22", "User mentioned upcoming holiday plans.", "travel,holiday", "jarvis"), ("2025-02-01", "User shared fitness routine details.", "health,fitness", "jarvis"), ] for date_utc, summary, topics, source_app in summaries: ts_utc = datetime.now(timezone.utc).isoformat() db.conn.execute( """INSERT INTO conversation_summaries (date_utc, ts_utc, summary, topics, source_app) VALUES (?, ?, ?, ?, ?)""", (date_utc, ts_utc, summary, topics, source_app), ) db.conn.commit() yield db db.close() @pytest.mark.unit class TestGetAllConversationSummaries: """Tests for Database.get_all_conversation_summaries().""" def test_returns_all_summaries(self, db_with_summaries): """Should return every summary in the database.""" rows = db_with_summaries.get_all_conversation_summaries() assert len(rows) == 4 def test_ordered_by_date_ascending(self, db_with_summaries): """Summaries should be ordered oldest-first for chronological import.""" rows = db_with_summaries.get_all_conversation_summaries() dates = [row["date_utc"] for row in rows] assert dates == sorted(dates) assert dates[0] == "2025-01-10" assert dates[-1] == "2025-06-22" def test_empty_database(self, db): """Should return an empty list when no summaries exist.""" rows = db.get_all_conversation_summaries() assert rows == [] def test_returns_expected_fields(self, db_with_summaries): """Each row should have the standard conversation_summaries fields.""" rows = db_with_summaries.get_all_conversation_summaries() row = rows[0] assert "date_utc" in row.keys() assert "summary" in row.keys() assert "topics" in row.keys() assert "source_app" in row.keys() def test_contains_summary_text(self, db_with_summaries): """Summaries should contain the actual text that was stored.""" rows = db_with_summaries.get_all_conversation_summaries() texts = [row["summary"] for row in rows] assert any("coffee" in t for t in texts) assert any("fitness" in t for t in texts) # ── Import endpoint tests ───────────────────────────────────────────── try: import flask as _flask # noqa: F401 _HAS_FLASK = True except ImportError: _HAS_FLASK = False @pytest.mark.unit @pytest.mark.skipif(not _HAS_FLASK, reason="Flask not available") class TestImportDiaryEndpoint: """Tests for /api/graph/import-diary streaming endpoint.""" @pytest.fixture(autouse=True) def setup_app(self, tmp_path): """Set up Flask test client with a temporary database.""" from src.desktop_app.memory_viewer import app, get_graph_store self.db_path = str(tmp_path / "test.db") # Create database with summaries self.db = Database(self.db_path, sqlite_vss_path=None) self.db.conn.execute( """INSERT INTO conversation_summaries (date_utc, ts_utc, summary, topics, source_app) VALUES (?, ?, ?, ?, ?)""", ("2025-03-15", "2025-03-15T12:00:00Z", "User likes dark roast coffee.", "food", "jarvis"), ) self.db.conn.execute( """INSERT INTO conversation_summaries (date_utc, ts_utc, summary, topics, source_app) VALUES (?, ?, ?, ?, ?)""", ("2025-03-16", "2025-03-16T12:00:00Z", "User works at Acme Corp.", "work", "jarvis"), ) self.db.conn.commit() app.config["TESTING"] = True self.client = app.test_client() yield self.db.close() def _parse_ndjson(self, data: bytes) -> list[dict]: """Parse newline-delimited JSON from response data.""" lines = data.decode("utf-8").strip().split("\n") return [json.loads(line) for line in lines if line.strip()] @patch("src.desktop_app.memory_viewer._get_db_path") @patch("src.desktop_app.memory_viewer.load_settings") @patch("src.jarvis.memory.graph_ops.call_llm_direct") def test_import_streams_progress(self, mock_llm, mock_settings, mock_db_path): """Should stream start, progress, and complete messages.""" mock_db_path.return_value = self.db_path cfg = MagicMock() cfg.ollama_base_url = "http://localhost:11434" cfg.ollama_chat_model = "test-model" cfg.llm_chat_timeout_sec = 10.0 cfg.llm_thinking_enabled = False mock_settings.return_value = cfg # LLM returns facts for extraction, NONE for placement (writes to root) mock_llm.side_effect = [ '["Likes dark roast coffee"]', # extract facts from summary 1 "NONE", # traverse for fact 1 (no children, goes to root) '["Works at Acme Corp"]', # extract facts from summary 2 "NONE", # traverse for fact 2 ] resp = self.client.post("/api/graph/import-diary") assert resp.status_code == 200 messages = self._parse_ndjson(resp.data) types = [m["type"] for m in messages] assert "start" in types assert "progress" in types assert "complete" in types start_msg = next(m for m in messages if m["type"] == "start") assert start_msg["total"] == 2 complete_msg = next(m for m in messages if m["type"] == "complete") assert complete_msg["processed"] == 2 @patch("src.desktop_app.memory_viewer._get_db_path") @patch("src.desktop_app.memory_viewer.load_settings") def test_import_empty_diary(self, mock_settings, mock_db_path, tmp_path): """Should handle empty diary gracefully.""" empty_db_path = str(tmp_path / "empty.db") empty_db = Database(empty_db_path, sqlite_vss_path=None) mock_db_path.return_value = empty_db_path cfg = MagicMock() mock_settings.return_value = cfg resp = self.client.post("/api/graph/import-diary") messages = self._parse_ndjson(resp.data) assert len(messages) == 1 assert messages[0]["type"] == "complete" assert messages[0]["processed"] == 0 empty_db.close() @patch("src.desktop_app.memory_viewer._get_db_path") @patch("src.desktop_app.memory_viewer.load_settings") @patch("src.jarvis.memory.graph_ops.call_llm_direct") def test_import_continues_on_per_summary_error(self, mock_llm, mock_settings, mock_db_path): """If one summary fails, the import should continue with the rest.""" mock_db_path.return_value = self.db_path cfg = MagicMock() cfg.ollama_base_url = "http://localhost:11434" cfg.ollama_chat_model = "test-model" cfg.llm_chat_timeout_sec = 10.0 cfg.llm_thinking_enabled = False mock_settings.return_value = cfg # First summary extraction fails, second succeeds mock_llm.side_effect = [ None, # extraction fails for summary 1 '["Works at Acme Corp"]', # extract facts from summary 2 "NONE", # traverse ] resp = self.client.post("/api/graph/import-diary") messages = self._parse_ndjson(resp.data) progress_msgs = [m for m in messages if m["type"] == "progress"] assert len(progress_msgs) == 2 # Both summaries processed complete_msg = next(m for m in messages if m["type"] == "complete") assert complete_msg["processed"] == 2 @pytest.mark.unit @pytest.mark.skipif(not _HAS_FLASK, reason="Flask not available") class TestImportDialogueDismissal: """Regression: after diary import succeeds, loadStats must not re-show the modal.""" def test_html_contains_diary_import_done_guard(self): """The loadStats check should be gated by diaryImportDone flag.""" from src.desktop_app.memory_viewer import app app.config["TESTING"] = True client = app.test_client() resp = client.get("/") html = resp.data.decode("utf-8") # The flag must be declared assert "let diaryImportDone = false;" in html # The flag must be set on import completion assert "diaryImportDone = true;" in html # The loadStats check must include the guard assert "&& !diaryImportDone" in html # The gate must be based on stored knowledge (total_tokens), not node count. # Guards against a regression to the old `totalNodes <= 1` condition that kept # re-prompting after a successful import filled the root node. assert "totalTokens === 0" in html assert "totalNodes <= 1" not in html