""" tests/test_sofiia_docs.py Unit tests for sofiia-console Projects/Documents/Sessions/Dialog Map. Tests: - DB: projects CRUD - DB: documents CRUD + SHA-256 stability - DB: sessions upsert + turn count - DB: messages + parent_msg_id branching - DB: dialog map nodes/edges - DB: fork_session copies ancestors - API: upload size limits config - API: mime validation (allowed/blocked) - API: search documents (keyword) - API: session fork returns new_session_id """ import asyncio import json import os import sys import tempfile import unittest import uuid from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch # ── path setup ────────────────────────────────────────────────────────────── _ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(_ROOT / "services" / "sofiia-console")) # Use a temp file DB for tests _TMP_DIR = tempfile.mkdtemp(prefix="sofiia_test_") os.environ["SOFIIA_DATA_DIR"] = _TMP_DIR def _run(coro): return asyncio.get_event_loop().run_until_complete(coro) # ── Import after env setup ─────────────────────────────────────────────────── try: import aiosqlite # noqa — ensure available # Try to import db module directly (may fail without full app context) try: from app import db as _db_module _DB_AVAILABLE = True except ImportError: # Import directly from file path import importlib.util _spec = importlib.util.spec_from_file_location( "sofiia_db", str(_ROOT / "services" / "sofiia-console" / "app" / "db.py"), ) _db_module = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_db_module) _DB_AVAILABLE = True _AIOSQLITE_AVAILABLE = True except (ImportError, Exception) as _e: _AIOSQLITE_AVAILABLE = False _DB_AVAILABLE = False _db_module = None @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestProjectsCRUD(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): _db_module._db_conn = None # reset connection await _db_module.init_db() self._db = _db_module async def asyncTearDown(self): await self._db.close_db() async def test_create_and_get_project(self): p = await self._db.create_project("Test Project", "desc") self.assertIn("project_id", p) self.assertEqual(p["name"], "Test Project") fetched = await self._db.get_project(p["project_id"]) self.assertIsNotNone(fetched) self.assertEqual(fetched["name"], "Test Project") async def test_list_projects_includes_default(self): projects = await self._db.list_projects() ids = [p["project_id"] for p in projects] self.assertIn("default", ids, "Default project must always exist") async def test_update_project(self): p = await self._db.create_project("Old Name") ok = await self._db.update_project(p["project_id"], name="New Name") self.assertTrue(ok) updated = await self._db.get_project(p["project_id"]) self.assertEqual(updated["name"], "New Name") async def test_get_nonexistent_project_returns_none(self): result = await self._db.get_project("nonexistent_xyz") self.assertIsNone(result) @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestDocumentsCRUD(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): _db_module._db_conn = None await _db_module.init_db() self._db = _db_module async def asyncTearDown(self): await self._db.close_db() async def test_create_document(self): doc = await self._db.create_document( project_id="default", file_id="abc123def456", sha256="a" * 64, mime="application/pdf", size_bytes=1024, filename="test.pdf", title="My Test Doc", tags=["invoice", "2026"], extracted_text="Sample text content", ) self.assertIn("doc_id", doc) self.assertEqual(doc["filename"], "test.pdf") self.assertEqual(doc["tags"], ["invoice", "2026"]) async def test_sha256_stability(self): """SHA-256 must be stored exactly as given (no mutation).""" sha = "b" * 64 doc = await self._db.create_document( "default", "fid", sha, "text/plain", 100, "file.txt" ) fetched = await self._db.get_document(doc["doc_id"]) self.assertEqual(fetched["sha256"], sha) async def test_list_documents_by_project(self): p = await self._db.create_project("DocProject") await self._db.create_document(p["project_id"], "f1", "c"*64, "text/plain", 10, "a.txt") await self._db.create_document(p["project_id"], "f2", "d"*64, "text/plain", 20, "b.txt") docs = await self._db.list_documents(p["project_id"]) self.assertEqual(len(docs), 2) async def test_search_documents_by_title(self): p = await self._db.create_project("SearchProject") await self._db.create_document(p["project_id"], "f1", "e"*64, "text/plain", 10, "budget.txt", title="Annual Budget 2026") await self._db.create_document(p["project_id"], "f2", "f"*64, "text/plain", 10, "report.txt", title="Monthly Report") results = await self._db.search_documents(p["project_id"], "Budget") self.assertEqual(len(results), 1) self.assertIn("budget.txt", results[0]["filename"]) async def test_get_document_wrong_project(self): doc = await self._db.create_document( "default", "gid", "g"*64, "text/plain", 5, "test.txt" ) fetched = await self._db.get_document(doc["doc_id"]) self.assertIsNotNone(fetched) # Simulating a "wrong project" check (as done in the API endpoint) self.assertNotEqual(fetched["project_id"], "nonexistent_project") @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestSessionsAndMessages(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): _db_module._db_conn = None await _db_module.init_db() self._db = _db_module async def asyncTearDown(self): await self._db.close_db() async def test_upsert_session_creates(self): s = await self._db.upsert_session("sess_test_001", project_id="default", title="Test Session") self.assertEqual(s["session_id"], "sess_test_001") self.assertEqual(s["title"], "Test Session") async def test_upsert_session_updates_last_active(self): await self._db.upsert_session("sess_002", project_id="default") s2 = await self._db.upsert_session("sess_002", project_id="default") self.assertEqual(s2["session_id"], "sess_002") async def test_save_message_and_retrieve(self): await self._db.upsert_session("sess_003", project_id="default") m = await self._db.save_message("sess_003", "user", "Hello Sofiia") self.assertIn("msg_id", m) self.assertEqual(m["role"], "user") self.assertEqual(m["content"], "Hello Sofiia") async def test_message_branching_parent_msg_id(self): await self._db.upsert_session("sess_branch", project_id="default") m1 = await self._db.save_message("sess_branch", "user", "First message") m2 = await self._db.save_message("sess_branch", "assistant", "First reply", parent_msg_id=m1["msg_id"]) # Fork from m1 m3 = await self._db.save_message("sess_branch", "user", "Branch question", parent_msg_id=m1["msg_id"], branch_label="branch-1") msgs = await self._db.list_messages("sess_branch", limit=10) self.assertEqual(len(msgs), 3) branch_msgs = [m for m in msgs if m["branch_label"] == "branch-1"] self.assertEqual(len(branch_msgs), 1) self.assertEqual(branch_msgs[0]["parent_msg_id"], m1["msg_id"]) async def test_turn_count_increments(self): await self._db.upsert_session("sess_count", project_id="default") for i in range(3): await self._db.save_message("sess_count", "user", f"Message {i}") s = await self._db.get_session("sess_count") self.assertGreaterEqual(s["turn_count"], 3) @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestDialogMap(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): _db_module._db_conn = None await _db_module.init_db() self._db = _db_module async def asyncTearDown(self): await self._db.close_db() async def test_dialog_map_nodes_and_edges(self): await self._db.upsert_session("sess_map", project_id="default") m1 = await self._db.save_message("sess_map", "user", "Hi there") m2 = await self._db.save_message("sess_map", "assistant", "Hello!", parent_msg_id=m1["msg_id"]) m3 = await self._db.save_message("sess_map", "user", "Follow-up", parent_msg_id=m2["msg_id"]) dmap = await self._db.get_dialog_map("sess_map") self.assertEqual(dmap["session_id"], "sess_map") self.assertEqual(len(dmap["nodes"]), 3) self.assertEqual(len(dmap["edges"]), 2) # m1→m2, m2→m3 async def test_dialog_map_empty_session(self): await self._db.upsert_session("sess_empty_map", project_id="default") dmap = await self._db.get_dialog_map("sess_empty_map") self.assertEqual(dmap["nodes"], []) self.assertEqual(dmap["edges"], []) async def test_dialog_map_node_structure(self): await self._db.upsert_session("sess_map2", project_id="default") m = await self._db.save_message("sess_map2", "user", "Test node structure") dmap = await self._db.get_dialog_map("sess_map2") node = dmap["nodes"][0] self.assertIn("id", node) self.assertIn("role", node) self.assertIn("preview", node) self.assertIn("ts", node) @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestForkSession(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): _db_module._db_conn = None await _db_module.init_db() self._db = _db_module async def asyncTearDown(self): await self._db.close_db() async def test_fork_creates_new_session(self): await self._db.upsert_session("sess_src", project_id="default") m1 = await self._db.save_message("sess_src", "user", "Message 1") m2 = await self._db.save_message("sess_src", "assistant", "Reply 1", parent_msg_id=m1["msg_id"]) m3 = await self._db.save_message("sess_src", "user", "Message 2", parent_msg_id=m2["msg_id"]) result = await self._db.fork_session("sess_src", from_msg_id=m2["msg_id"], new_title="Fork Test") self.assertIn("new_session_id", result) self.assertNotEqual(result["new_session_id"], "sess_src") self.assertGreaterEqual(result["copied_turns"], 2) # m1 and m2 are ancestors async def test_fork_messages_are_independent(self): await self._db.upsert_session("sess_src2", project_id="default") m1 = await self._db.save_message("sess_src2", "user", "Original message") result = await self._db.fork_session("sess_src2", from_msg_id=m1["msg_id"]) new_sid = result["new_session_id"] # New session exists s = await self._db.get_session(new_sid) self.assertIsNotNone(s) # Modifying original doesn't affect fork await self._db.save_message("sess_src2", "user", "New in original") new_msgs = await self._db.list_messages(new_sid) src_msgs = await self._db.list_messages("sess_src2") self.assertLess(len(new_msgs), len(src_msgs)) class TestUploadSizeLimits(unittest.TestCase): """Upload size limit configuration tests (no DB needed).""" def _get_docs_router_module(self): """Load docs_router module directly from filesystem.""" try: import importlib.util spec = importlib.util.spec_from_file_location( "sofiia_docs_router", str(_ROOT / "services" / "sofiia-console" / "app" / "docs_router.py"), ) mod = importlib.util.module_from_spec(spec) # Pre-populate with dummy deps to avoid ImportError import types dummy = types.ModuleType("app.db") sys.modules.setdefault("app", types.ModuleType("app")) sys.modules["app.db"] = dummy spec.loader.exec_module(mod) return mod except Exception: return None def _get_docs_router_limits(self): """Load docs_router module and check env-based limit defaults.""" mod = self._get_docs_router_module() if mod: return getattr(mod, "_MAX_IMAGE_MB", 10), getattr(mod, "_MAX_VIDEO_MB", 200), getattr(mod, "_MAX_DOC_MB", 50) return 10, 200, 50 def test_default_image_limit_10mb(self): img, vid, doc = self._get_docs_router_limits() self.assertEqual(img, 10) def test_default_video_limit_200mb(self): img, vid, doc = self._get_docs_router_limits() self.assertEqual(vid, 200) def test_default_doc_limit_50mb(self): img, vid, doc = self._get_docs_router_limits() self.assertEqual(doc, 50) def test_allowed_mimes_includes_pdf(self): mod = self._get_docs_router_module() if not mod: self.skipTest("docs_router not importable") self.assertIn("application/pdf", mod._ALLOWED_MIMES) def test_allowed_mimes_includes_images(self): mod = self._get_docs_router_module() if not mod: self.skipTest("docs_router not importable") self.assertIn("image/jpeg", mod._ALLOWED_MIMES) self.assertIn("image/png", mod._ALLOWED_MIMES) def test_allowed_mimes_excludes_executables(self): mod = self._get_docs_router_module() if not mod: self.skipTest("docs_router not importable") self.assertNotIn("application/x-executable", mod._ALLOWED_MIMES) self.assertNotIn("application/x-sh", mod._ALLOWED_MIMES) class TestSafeFilename(unittest.TestCase): """Filename sanitization tests.""" def _get_safe_filename(self): mod = TestUploadSizeLimits()._get_docs_router_module() return getattr(mod, "_safe_filename", None) if mod else None def test_safe_filename_strips_path(self): fn = self._get_safe_filename() if not fn: self.skipTest("docs_router not importable") self.assertEqual(fn("../../../etc/passwd"), "passwd") self.assertEqual(fn("/absolute/path/file.txt"), "file.txt") def test_safe_filename_removes_dangerous_chars(self): fn = self._get_safe_filename() if not fn: self.skipTest("docs_router not importable") result = fn("file; rm -rf /; .txt") self.assertNotIn(";", result) self.assertNotIn(" ", result) def test_safe_filename_preserves_extension(self): fn = self._get_safe_filename() if not fn: self.skipTest("docs_router not importable") self.assertTrue(fn("report.pdf").endswith(".pdf")) @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestTasksCRUD(unittest.IsolatedAsyncioTestCase): """Tests for Tasks (Kanban) persistence layer.""" async def asyncSetUp(self): _db_module._db_conn = None await _db_module.init_db() self._db = _db_module # ensure test project await self._db.create_project("TaskProject") projects = await self._db.list_projects() self._pid = next(p["project_id"] for p in projects if p["name"] == "TaskProject") async def asyncTearDown(self): await self._db.close_db() async def test_create_task(self): task = await self._db.create_task(self._pid, "Fix the bug", description="Critical bug", priority="high") self.assertIn("task_id", task) self.assertEqual(task["title"], "Fix the bug") self.assertEqual(task["status"], "backlog") self.assertEqual(task["priority"], "high") async def test_list_tasks_by_project(self): await self._db.create_task(self._pid, "Task A") await self._db.create_task(self._pid, "Task B", status="in_progress") tasks = await self._db.list_tasks(self._pid) titles = [t["title"] for t in tasks] self.assertIn("Task A", titles) self.assertIn("Task B", titles) async def test_list_tasks_filtered_by_status(self): await self._db.create_task(self._pid, "Done task", status="done") await self._db.create_task(self._pid, "Backlog task", status="backlog") done = await self._db.list_tasks(self._pid, status="done") self.assertTrue(all(t["status"] == "done" for t in done)) async def test_update_task_status(self): task = await self._db.create_task(self._pid, "Moveable task") ok = await self._db.update_task(task["task_id"], status="in_progress") self.assertTrue(ok) updated = await self._db.get_task(task["task_id"]) self.assertEqual(updated["status"], "in_progress") async def test_delete_task(self): task = await self._db.create_task(self._pid, "Deletable task") ok = await self._db.delete_task(task["task_id"]) self.assertTrue(ok) fetched = await self._db.get_task(task["task_id"]) self.assertIsNone(fetched) async def test_task_labels_round_trip(self): task = await self._db.create_task(self._pid, "Labeled task", labels=["bug", "ui", "P1"]) fetched = await self._db.get_task(task["task_id"]) self.assertEqual(fetched["labels"], ["bug", "ui", "P1"]) @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestMeetingsCRUD(unittest.IsolatedAsyncioTestCase): """Tests for Meetings persistence layer.""" async def asyncSetUp(self): _db_module._db_conn = None await _db_module.init_db() self._db = _db_module p = await self._db.create_project("MeetingProject") self._pid = p["project_id"] async def asyncTearDown(self): await self._db.close_db() async def test_create_meeting(self): m = await self._db.create_meeting( self._pid, "Sprint Planning", "2026-03-01T10:00:00Z", agenda="Goals and backlog review", duration_min=60, ) self.assertIn("meeting_id", m) self.assertEqual(m["title"], "Sprint Planning") self.assertEqual(m["duration_min"], 60) async def test_list_meetings(self): await self._db.create_meeting(self._pid, "Meeting A", "2026-03-01T09:00:00Z") await self._db.create_meeting(self._pid, "Meeting B", "2026-03-02T10:00:00Z") meetings = await self._db.list_meetings(self._pid) self.assertEqual(len(meetings), 2) # Should be sorted by starts_at ASC self.assertLess(meetings[0]["starts_at"], meetings[1]["starts_at"]) async def test_update_meeting(self): m = await self._db.create_meeting(self._pid, "Old Title", "2026-03-01T10:00:00Z") ok = await self._db.update_meeting(m["meeting_id"], title="New Title", duration_min=90) self.assertTrue(ok) updated = await self._db.get_meeting(m["meeting_id"]) self.assertEqual(updated["title"], "New Title") self.assertEqual(updated["duration_min"], 90) async def test_attendees_round_trip(self): attendees = ["user@a.com", "user@b.com"] m = await self._db.create_meeting(self._pid, "Team sync", "2026-03-03T14:00:00Z", attendees=attendees) fetched = await self._db.get_meeting(m["meeting_id"]) self.assertEqual(fetched["attendees"], attendees) async def test_delete_meeting(self): m = await self._db.create_meeting(self._pid, "Deletable", "2026-03-10T10:00:00Z") ok = await self._db.delete_meeting(m["meeting_id"]) self.assertTrue(ok) fetched = await self._db.get_meeting(m["meeting_id"]) self.assertIsNone(fetched) @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestDialogGraph(unittest.IsolatedAsyncioTestCase): """Tests for Dialog Map graph (dialog_nodes + dialog_edges).""" async def asyncSetUp(self): _db_module._db_conn = None await _db_module.init_db() self._db = _db_module p = await self._db.create_project("GraphProject") self._pid = p["project_id"] async def asyncTearDown(self): await self._db.close_db() async def test_upsert_dialog_node_creates(self): node = await self._db.upsert_dialog_node( self._pid, "task", "task_001", title="My task", summary="Do something" ) self.assertIn("node_id", node) self.assertEqual(node["node_type"], "task") self.assertEqual(node["ref_id"], "task_001") async def test_upsert_dialog_node_deduplicates(self): n1 = await self._db.upsert_dialog_node(self._pid, "doc", "doc_001", title="First") n2 = await self._db.upsert_dialog_node(self._pid, "doc", "doc_001", title="Updated") # Same ref_id → same node_id self.assertEqual(n1["node_id"], n2["node_id"]) # Title should be updated self.assertEqual(n2["title"], "Updated") async def test_create_dialog_edge(self): n1 = await self._db.upsert_dialog_node(self._pid, "message", "msg_001") n2 = await self._db.upsert_dialog_node(self._pid, "task", "task_002") edge = await self._db.create_dialog_edge( self._pid, n1["node_id"], n2["node_id"], "derives_task" ) self.assertIn("edge_id", edge) self.assertEqual(edge["edge_type"], "derives_task") async def test_get_project_dialog_map(self): n1 = await self._db.upsert_dialog_node(self._pid, "message", "msg_map_001", title="Hello") n2 = await self._db.upsert_dialog_node(self._pid, "task", "task_map_001", title="Do it") await self._db.create_dialog_edge(self._pid, n1["node_id"], n2["node_id"], "derives_task") graph = await self._db.get_project_dialog_map(self._pid) self.assertIn("nodes", graph) self.assertIn("edges", graph) self.assertGreaterEqual(graph["node_count"], 2) self.assertGreaterEqual(graph["edge_count"], 1) async def test_no_self_loop_edges(self): n = await self._db.upsert_dialog_node(self._pid, "goal", "goal_001", title="Self loop test") # Self-loop should silently fail (SQLite CHECK constraint) edge = await self._db.create_dialog_edge( self._pid, n["node_id"], n["node_id"], "references" ) # Edge won't be in the graph (self-loop blocked) graph = await self._db.get_project_dialog_map(self._pid) self_loops = [e for e in graph["edges"] if e["from_node_id"] == e["to_node_id"]] self.assertEqual(len(self_loops), 0) async def test_entity_link_created(self): link = await self._db.create_entity_link( self._pid, "message", "msg_x", "task", "task_x", "derives_task" ) self.assertIn("link_id", link) self.assertEqual(link["link_type"], "derives_task") async def test_doc_version_round_trip(self): # Create a dummy document first doc = await self._db.create_document( self._pid, "f_ver", "v"*64, "text/plain", 100, "versioned.txt", extracted_text="original content" ) v = await self._db.save_doc_version(doc["doc_id"], "new content v2", author_id="test_user") self.assertIn("version_id", v) content = await self._db.get_doc_version_content(v["version_id"]) self.assertEqual(content, "new content v2") versions = await self._db.list_doc_versions(doc["doc_id"]) self.assertGreaterEqual(len(versions), 1) async def test_dialog_view_upsert(self): view = await self._db.upsert_dialog_view( self._pid, "default", filters={"node_types": ["task", "doc"]}, layout={"zoom": 1.0, "pan": [0, 0]}, ) self.assertEqual(view["name"], "default") self.assertIn("task", view["filters"].get("node_types", [])) # Upsert again (update) view2 = await self._db.upsert_dialog_view(self._pid, "default", layout={"zoom": 2.0}) self.assertEqual(view2["layout"].get("zoom"), 2.0) @unittest.skipUnless(_DB_AVAILABLE, "aiosqlite not available") class TestTransactionalIntegrity(unittest.IsolatedAsyncioTestCase): """Graph Contract: every artifact creation is atomic with its dialog_node.""" async def asyncSetUp(self): self._db = _db_module await self._db.close_db() self._db._db_conn = None self._pid = f"tx_proj_{uuid.uuid4().hex[:8]}" await self._db.init_db() await self._db.create_project("TX Test Project", project_id=self._pid) async def asyncTearDown(self): pass # Keep DB open across test classes (shared global connection) async def test_create_task_creates_node_atomically(self): """create_task must produce task + dialog_node in one transaction.""" task = await self._db.create_task( self._pid, "Atomic Task", description="desc", created_by="test" ) self.assertIn("node_id", task, "create_task must return node_id") self.assertIsNotNone(task["node_id"]) graph = await self._db.get_project_dialog_map(self._pid) task_nodes = [n for n in graph["nodes"] if n["node_type"] == "task" and n["ref_id"] == task["task_id"]] self.assertEqual(len(task_nodes), 1, "Task node must be in dialog map") async def test_create_meeting_creates_node_atomically(self): """create_meeting must produce meeting + dialog_node atomically.""" meeting = await self._db.create_meeting( self._pid, "Atomic Meeting", starts_at="2026-03-01T10:00:00Z", created_by="test" ) self.assertIn("node_id", meeting) graph = await self._db.get_project_dialog_map(self._pid) m_nodes = [n for n in graph["nodes"] if n["node_type"] == "meeting" and n["ref_id"] == meeting["meeting_id"]] self.assertEqual(len(m_nodes), 1, "Meeting node must be in dialog map") async def test_create_task_with_source_msg_creates_edge(self): """create_task with source_msg_id must create derives_task edge.""" msg_id = f"msg_{uuid.uuid4().hex[:8]}" task = await self._db.create_task( self._pid, "Task from msg", source_msg_id=msg_id, created_by="test" ) graph = await self._db.get_project_dialog_map(self._pid) derives_edges = [ e for e in graph["edges"] if e["edge_type"] == "derives_task" and e["to_node_id"] == task["node_id"] ] self.assertGreaterEqual(len(derives_edges), 1, "Must have derives_task edge from message") async def test_create_meeting_with_source_msg_creates_edge(self): """create_meeting with source_msg_id must create schedules_meeting edge.""" msg_id = f"msg_{uuid.uuid4().hex[:8]}" meeting = await self._db.create_meeting( self._pid, "Meeting from msg", starts_at="2026-03-02T15:00:00Z", source_msg_id=msg_id, created_by="test", ) graph = await self._db.get_project_dialog_map(self._pid) sched_edges = [ e for e in graph["edges"] if e["edge_type"] == "schedules_meeting" and e["to_node_id"] == meeting["node_id"] ] self.assertGreaterEqual(len(sched_edges), 1, "Must have schedules_meeting edge from message") @unittest.skipUnless(_DB_AVAILABLE, "aiosqlite not available") class TestGraphIntegrity(unittest.IsolatedAsyncioTestCase): """Graph Contract: check_graph_integrity detects violations.""" async def asyncSetUp(self): self._db = _db_module await self._db.close_db() self._db._db_conn = None self._pid = f"integrity_proj_{uuid.uuid4().hex[:8]}" await self._db.init_db() await self._db.create_project("Integrity Test", project_id=self._pid) async def asyncTearDown(self): pass # Keep DB open async def test_clean_project_passes_integrity(self): """A freshly created project with proper artifacts should pass.""" await self._db.create_task(self._pid, "Clean task") await self._db.create_meeting(self._pid, "Clean meeting", starts_at="2026-04-01T09:00:00Z") result = await self._db.check_graph_integrity(self._pid) self.assertTrue(result["ok"], f"Integrity must pass, violations: {result['violations']}") self.assertEqual(result["violations"], []) self.assertGreaterEqual(result["stats"]["node_count"], 2) async def test_integrity_detects_dangling_task_node(self): """Manually inserted task node without matching task row should be detected.""" db = await self._db.get_db() fake_task_id = f"fake_{uuid.uuid4().hex}" node_id = str(uuid.uuid4()) now = "2026-01-01T00:00:00Z" await db.execute( "INSERT INTO dialog_nodes(node_id,project_id,node_type,ref_id,title,summary,props,created_by,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?)", (node_id, self._pid, "task", fake_task_id, "Orphan", "", "{}", "test", now, now), ) await db.commit() result = await self._db.check_graph_integrity(self._pid) self.assertFalse(result["ok"], "Should detect dangling task node") violation_types = [v["type"] for v in result["violations"]] self.assertIn("dangling_task_nodes", violation_types) async def test_no_self_loops_after_operations(self): """After normal CRUD operations, there must be no self-loop edges.""" task = await self._db.create_task(self._pid, "Loop check task") meeting = await self._db.create_meeting( self._pid, "Loop check meeting", starts_at="2026-05-01T08:00:00Z" ) await self._db.create_dialog_edge( self._pid, task["node_id"], meeting["node_id"], "relates_to" ) result = await self._db.check_graph_integrity(self._pid) self.assertGreaterEqual(result["stats"]["edge_count"], 1) loop_violations = [v for v in result["violations"] if v["type"] == "self_loop_edges"] self.assertEqual(loop_violations, []) @unittest.skipUnless(_DB_AVAILABLE, "aiosqlite not available") class TestEvidencePackEngine(unittest.IsolatedAsyncioTestCase): """Evidence Pack Engine: Supervisor run → node + tasks + edges atomically.""" async def asyncSetUp(self): self._db = _db_module await self._db.close_db() self._db._db_conn = None self._pid = f"evidence_proj_{uuid.uuid4().hex[:8]}" await self._db.init_db() await self._db.create_project("Evidence Test", project_id=self._pid) async def asyncTearDown(self): pass # Keep DB open async def test_evidence_pack_creates_agent_run_node(self): """create_evidence_pack must create an agent_run node.""" run_id = f"run_{uuid.uuid4().hex[:8]}" pack = await self._db.create_evidence_pack( project_id=self._pid, run_id=run_id, graph_name="release_check", result_data={"status": "completed", "summary": "All checks passed"}, ) self.assertTrue(pack["ok"]) self.assertIsNotNone(pack["node_id"]) graph = await self._db.get_project_dialog_map(self._pid) run_nodes = [n for n in graph["nodes"] if n["node_type"] == "agent_run" and n["ref_id"] == run_id] self.assertEqual(len(run_nodes), 1, "agent_run node must be in dialog map") async def test_evidence_pack_creates_follow_up_tasks(self): """create_evidence_pack with follow_up_tasks must create tasks + produced_by edges.""" run_id = f"run_{uuid.uuid4().hex[:8]}" pack = await self._db.create_evidence_pack( project_id=self._pid, run_id=run_id, graph_name="incident_triage", result_data={ "status": "completed", "follow_up_tasks": [ {"title": "Fix DB index", "priority": "high"}, {"title": "Update runbook", "priority": "normal"}, ], }, ) self.assertEqual(pack["tasks_created"], 2) self.assertEqual(len(pack["task_ids"]), 2) tasks = await self._db.list_tasks(self._pid) task_titles = {t["title"] for t in tasks} self.assertIn("Fix DB index", task_titles) self.assertIn("Update runbook", task_titles) graph = await self._db.get_project_dialog_map(self._pid) produced_edges = [e for e in graph["edges"] if e["edge_type"] == "produced_by"] self.assertEqual(len(produced_edges), 2, "Must have produced_by edges for each task") async def test_evidence_pack_idempotent_on_rerun(self): """Re-recording same run_id must not duplicate nodes (ON CONFLICT DO UPDATE).""" run_id = f"run_{uuid.uuid4().hex[:8]}" pack1 = await self._db.create_evidence_pack( self._pid, run_id, "release_check", {"status": "completed", "summary": "First run"} ) pack2 = await self._db.create_evidence_pack( self._pid, run_id, "release_check", {"status": "completed", "summary": "Updated summary"} ) self.assertEqual(pack1["node_id"], pack2["node_id"], "Node ID must be stable on re-run") async def test_full_integrity_after_evidence_pack(self): """After creating an evidence pack, integrity check must still pass.""" run_id = f"run_{uuid.uuid4().hex[:8]}" await self._db.create_evidence_pack( self._pid, run_id, "postmortem_draft", result_data={ "status": "completed", "follow_up_tasks": [{"title": "Write postmortem", "priority": "urgent"}], }, ) result = await self._db.check_graph_integrity(self._pid) self.assertTrue(result["ok"], f"Integrity must pass after evidence pack: {result['violations']}") @unittest.skipUnless(_DB_AVAILABLE, "aiosqlite not available") class TestGraphHygiene(unittest.IsolatedAsyncioTestCase): """Graph Hygiene Engine: fingerprints, dedup, lifecycle, importance.""" async def asyncSetUp(self): self._db = _db_module await self._db.close_db() self._db._db_conn = None self._pid = f"hygiene_proj_{uuid.uuid4().hex[:8]}" await self._db.init_db() await self._db.create_project("Hygiene Test", project_id=self._pid) async def asyncTearDown(self): await self._db.close_db() async def test_importance_baseline_scores(self): """Base importance scores must match contract values.""" self.assertAlmostEqual(self._db._compute_importance("decision"), 0.95, places=2) self.assertAlmostEqual(self._db._compute_importance("goal"), 0.90, places=2) self.assertAlmostEqual(self._db._compute_importance("task"), 0.70, places=2) self.assertAlmostEqual(self._db._compute_importance("message"), 0.15, places=2) # Done task halved self.assertAlmostEqual(self._db._compute_importance("task", task_status="done"), 0.35, places=2) async def test_importance_lifecycle_multiplier(self): """Archived and superseded nodes must have reduced importance.""" active = self._db._compute_importance("decision", lifecycle="active") superseded = self._db._compute_importance("decision", lifecycle="superseded") archived = self._db._compute_importance("decision", lifecycle="archived") self.assertGreater(active, superseded) self.assertGreater(superseded, archived) async def test_importance_bump_factors(self): """High risk and pinned nodes get importance bumps.""" base = self._db._compute_importance("task") with_risk = self._db._compute_importance("task", risk_level="high") pinned = self._db._compute_importance("task", pinned=True) self.assertGreater(with_risk, base) self.assertGreater(pinned, base) async def test_fingerprint_is_deterministic(self): """Same title+summary must always produce same fingerprint.""" fp1 = self._db._compute_fingerprint("task", "Fix DB index", "Critical bug") fp2 = self._db._compute_fingerprint("task", "Fix DB index", "Critical bug") fp3 = self._db._compute_fingerprint("task", " Fix DB Index ", "critical bug") # normalized self.assertEqual(fp1, fp2) self.assertEqual(fp1, fp3) # lowercase + strip normalization async def test_fingerprint_differs_for_different_content(self): """Different titles must produce different fingerprints.""" fp1 = self._db._compute_fingerprint("task", "Fix index", "") fp2 = self._db._compute_fingerprint("task", "Deploy service", "") self.assertNotEqual(fp1, fp2) async def test_hygiene_dry_run_detects_duplicates(self): """Dry-run must find duplicates without writing changes.""" # Create two tasks with same title (will get same fingerprint) t1 = await self._db.create_task(self._pid, "Duplicate Decision", created_by="test") # Manually insert second node with same fingerprint-equivalent title db = await self._db.get_db() n2_id = str(uuid.uuid4()) now = "2025-01-01T00:00:00Z" fp = self._db._compute_fingerprint("task", "Duplicate Decision", "") await db.execute( "INSERT INTO dialog_nodes(node_id,project_id,node_type,ref_id,title,summary,props,fingerprint,lifecycle,importance,created_by,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", (n2_id, self._pid, "task", "fake_task_dup", "Duplicate Decision", "", "{}", fp, "active", 0.7, "test", now, now), ) await db.commit() result = await self._db.run_graph_hygiene(self._pid, dry_run=True) self.assertTrue(result["ok"]) self.assertTrue(result["dry_run"]) # Must find the duplicate self.assertGreater(result["stats"]["duplicates_found"], 0, "Should detect duplicates") # Dry-run: no changes applied archived_changes = [c for c in result["changes"] if c["action"] == "archive_duplicate"] self.assertGreater(len(archived_changes), 0) # Node lifecycle must NOT be changed (dry_run=True) async with db.execute("SELECT lifecycle FROM dialog_nodes WHERE node_id=?", (n2_id,)) as cur: row = await cur.fetchone() self.assertEqual(row[0], "active", "Dry-run must not modify lifecycle") async def test_hygiene_apply_archives_duplicates(self): """Non-dry-run hygiene must archive duplicate nodes.""" # Create two nodes with identical fingerprint-equivalent titles t1 = await self._db.create_task(self._pid, "Archive Dup Task", created_by="test") db = await self._db.get_db() n2_id = str(uuid.uuid4()) fp = self._db._compute_fingerprint("task", "Archive Dup Task", "") # older created_at → will be archived (canonical = latest) await db.execute( "INSERT INTO dialog_nodes(node_id,project_id,node_type,ref_id,title,summary,props,fingerprint,lifecycle,importance,created_by,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", (n2_id, self._pid, "task", "fake_dup2", "Archive Dup Task", "", "{}", fp, "active", 0.7, "test", "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z"), ) await db.commit() result = await self._db.run_graph_hygiene(self._pid, dry_run=False) self.assertFalse(result["dry_run"]) self.assertGreater(result["stats"]["archived"], 0, "Should archive duplicates") # The older node must now be archived async with db.execute("SELECT lifecycle FROM dialog_nodes WHERE node_id=?", (n2_id,)) as cur: row = await cur.fetchone() self.assertIn(row[0], ("archived", "superseded"), "Duplicate must be archived/superseded") async def test_hygiene_idempotent(self): """Running hygiene twice must not create new violations.""" await self._db.create_task(self._pid, "Idempotent Task") r1 = await self._db.run_graph_hygiene(self._pid, dry_run=False) r2 = await self._db.run_graph_hygiene(self._pid, dry_run=False) # Second run should find no new duplicates to archive self.assertEqual(r2["stats"]["archived"], 0, "Second hygiene run must be idempotent") async def test_hygiene_recomputes_importance(self): """Hygiene must update importance for nodes without it set.""" # Create task node and manually clear its importance task = await self._db.create_task(self._pid, "Importance Test Task") db = await self._db.get_db() await db.execute("UPDATE dialog_nodes SET importance=0.0 WHERE node_id=?", (task["node_id"],)) await db.commit() result = await self._db.run_graph_hygiene(self._pid, dry_run=False) importance_changes = [c for c in result["changes"] if c["action"] == "update_importance"] self.assertGreater(len(importance_changes), 0, "Hygiene must recompute importance") @unittest.skipUnless(_DB_AVAILABLE, "aiosqlite not available") class TestSelfReflection(unittest.IsolatedAsyncioTestCase): """Self-Reflection Engine: supervisor run analysis.""" async def asyncSetUp(self): self._db = _db_module await self._db.close_db() self._db._db_conn = None self._pid = f"reflect_proj_{uuid.uuid4().hex[:8]}" await self._db.init_db() await self._db.create_project("Reflection Test", project_id=self._pid) async def asyncTearDown(self): await self._db.close_db() async def _create_run(self, run_id: str, graph: str = "release_check") -> dict: """Helper: create evidence pack (agent_run node) first.""" return await self._db.create_evidence_pack( self._pid, run_id, graph, result_data={"status": "completed", "summary": f"Run {run_id[:8]}"}, ) async def test_reflection_creates_decision_node(self): """create_run_reflection must create a decision node.""" run_id = f"run_{uuid.uuid4().hex[:8]}" await self._create_run(run_id) result = await self._db.create_run_reflection( self._pid, run_id, evidence_data={ "summary": "Release checks passed", "findings": [{"name": "tests", "status": "pass"}, {"name": "lint", "status": "pass"}], }, ) self.assertTrue(result["ok"]) self.assertIsNotNone(result["node_id"]) graph = await self._db.get_project_dialog_map(self._pid) reflection_nodes = [n for n in graph["nodes"] if n["node_type"] == "decision" and "reflection" in n.get("ref_id", "")] self.assertGreaterEqual(len(reflection_nodes), 1) async def test_reflection_links_to_agent_run(self): """Reflection must create reflects_on edge to agent_run node.""" run_id = f"run_{uuid.uuid4().hex[:8]}" pack = await self._create_run(run_id) result = await self._db.create_run_reflection(self._pid, run_id, evidence_data={}) self.assertIsNotNone(result["edge_id"]) graph = await self._db.get_project_dialog_map(self._pid) reflects_edges = [e for e in graph["edges"] if e["edge_type"] == "reflects_on"] self.assertGreaterEqual(len(reflects_edges), 1) async def test_reflection_scores_completeness(self): """Reflection must compute plan_completeness_score from findings.""" run_id = f"run_{uuid.uuid4().hex[:8]}" await self._create_run(run_id) result = await self._db.create_run_reflection( self._pid, run_id, evidence_data={ "findings": [ {"name": "a", "status": "pass"}, {"name": "b", "status": "pass"}, {"name": "c", "status": "fail"}, {"name": "d", "status": "pass"}, ], }, ) refl = result["reflection"] # 3/4 passed = 0.75 self.assertAlmostEqual(refl["plan_completeness_score"], 0.75, places=2) self.assertEqual(refl["confidence"], "medium") self.assertEqual(len(refl["open_risks"]), 1) async def test_reflection_creates_risk_tasks(self): """Failed findings must auto-create risk tasks.""" run_id = f"run_{uuid.uuid4().hex[:8]}" await self._create_run(run_id) result = await self._db.create_run_reflection( self._pid, run_id, evidence_data={ "findings": [ {"name": "DB migration", "status": "fail", "detail": "Migration pending"}, {"name": "Security scan", "status": "error", "message": "CVE-2024-001"}, ], }, ) self.assertGreater(result["risk_tasks_created"], 0) tasks = await self._db.list_tasks(self._pid) risk_titles = [t["title"] for t in tasks if "[RISK]" in t["title"]] self.assertGreater(len(risk_titles), 0) async def test_reflection_idempotent(self): """Reflecting on same run_id twice must not duplicate nodes.""" run_id = f"run_{uuid.uuid4().hex[:8]}" await self._create_run(run_id) r1 = await self._db.create_run_reflection(self._pid, run_id, evidence_data={}) r2 = await self._db.create_run_reflection(self._pid, run_id, evidence_data={}) self.assertEqual(r1["node_id"], r2["node_id"], "Reflection node must be stable") async def test_full_integrity_after_reflection(self): """After reflection, graph integrity must still pass.""" run_id = f"run_{uuid.uuid4().hex[:8]}" await self._create_run(run_id, "incident_triage") await self._db.create_run_reflection( self._pid, run_id, evidence_data={ "findings": [{"name": "x", "status": "fail", "detail": "Timeout"}], }, ) integrity = await self._db.check_graph_integrity(self._pid) self.assertTrue(integrity["ok"], f"Integrity must pass after reflection: {integrity['violations']}") @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestStrategicCTOLayer(unittest.IsolatedAsyncioTestCase): """Tests for graph_snapshots and graph_signals (Strategic CTO Layer).""" async def asyncSetUp(self): self._db = _db_module await self._db.close_db() self._db._db_conn = None await self._db.init_db() # Use unique project_id per test to avoid UNIQUE conflicts across test methods self._pid = f"cto-{uuid.uuid4().hex[:10]}" r = await self._db.create_project("CTO Test Project", project_id=self._pid) self._pid = r["project_id"] async def asyncTearDown(self): await self._db.close_db() self._db._db_conn = None # ── Snapshot Tests ──────────────────────────────────────────────────────── async def test_snapshot_empty_project(self): """Snapshot on an empty project must return zero metrics without errors.""" result = await self._db.compute_graph_snapshot(self._pid, window="7d") self.assertTrue(result["ok"]) m = result["metrics"] self.assertEqual(m["tasks_created"], 0) self.assertEqual(m["tasks_done"], 0) self.assertEqual(m["wip"], 0) self.assertEqual(m["risk_tasks_open"], 0) self.assertEqual(m["agent_runs_total"], 0) async def test_snapshot_with_tasks(self): """Snapshot correctly counts tasks_created and wip.""" await self._db.create_task(self._pid, "Task A", status="backlog") await self._db.create_task(self._pid, "Task B", status="in_progress") await self._db.create_task(self._pid, "Task C", status="done") result = await self._db.compute_graph_snapshot(self._pid, window="7d") m = result["metrics"] self.assertEqual(m["tasks_created"], 3) self.assertGreaterEqual(m["wip"], 1) self.assertGreaterEqual(m["tasks_done"], 1) async def test_snapshot_risk_tasks_count(self): """Snapshot correctly counts open [RISK] tasks.""" await self._db.create_task(self._pid, "[RISK] Critical vuln A", status="backlog", priority="high") await self._db.create_task(self._pid, "[RISK] Critical vuln B", status="done", priority="high") await self._db.create_task(self._pid, "Normal task", status="backlog") result = await self._db.compute_graph_snapshot(self._pid, window="7d") m = result["metrics"] self.assertEqual(m["risk_tasks_open"], 1, "Only non-done [RISK] tasks should count") async def test_snapshot_idempotent_same_day(self): """Two calls on same day produce single snapshot (ON CONFLICT DO UPDATE).""" await self._db.compute_graph_snapshot(self._pid, window="7d") await self._db.compute_graph_snapshot(self._pid, window="7d") db = await self._db.get_db() async with db.execute( "SELECT COUNT(*) FROM graph_snapshots WHERE project_id=? AND window='7d'", (self._pid,), ) as cur: count = (await cur.fetchone())[0] self.assertEqual(count, 1, "Should upsert not duplicate snapshot") async def test_get_latest_snapshot(self): """get_latest_snapshot returns None before first compute, data after.""" snap = await self._db.get_latest_snapshot(self._pid, window="7d") self.assertIsNone(snap) await self._db.compute_graph_snapshot(self._pid, window="7d") snap = await self._db.get_latest_snapshot(self._pid, window="7d") self.assertIsNotNone(snap) self.assertIn("metrics", snap) self.assertIsInstance(snap["metrics"], dict) async def test_snapshot_graph_density(self): """graph_density metric equals edges/nodes ratio.""" await self._db.compute_graph_snapshot(self._pid, window="7d") snap = await self._db.get_latest_snapshot(self._pid, window="7d") m = snap["metrics"] if m["node_count"] > 0: expected = round(m["edge_count"] / m["node_count"], 3) self.assertAlmostEqual(m["graph_density"], expected, places=2) # ── Signals Tests ───────────────────────────────────────────────────────── async def test_signals_empty_project_no_signals(self): """Empty project generates no signals.""" result = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) self.assertTrue(result["ok"]) self.assertEqual(result["signals_generated"], 0) self.assertEqual(result["signals_upserted"], 0) async def test_signal_dry_run_does_not_persist(self): """dry_run=True computes signals but does not write to DB.""" # Create conditions for run_quality_regression rule for i in range(3): run_id = f"run_{uuid.uuid4().hex[:8]}" await self._db.create_evidence_pack( project_id=self._pid, run_id=run_id, graph_name="release_check", result_data={"status": "completed", "findings": [{"name": "test", "status": "fail", "detail": "bad"}]}, ) await self._db.create_run_reflection(self._pid, run_id, evidence_data={ "findings": [{"name": "x", "status": "fail", "detail": "Critical fail"}] * 3, }) dry = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=True) live = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) # Dry run: no upserts self.assertEqual(dry["signals_upserted"], 0) # Dry and live should detect same signals_generated count self.assertEqual(dry["signals_generated"], live["signals_generated"]) async def test_signal_idempotency(self): """Running signals twice with same conditions must not create new signals.""" # Create risk tasks for risk_cluster rule for i in range(3): await self._db.create_task( self._pid, f"[RISK] Issue {i}", status="backlog", priority="high", labels=["backend", "security"] ) r1 = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) r2 = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) # Second run must not create new signals (may be skip_cooldown or refresh, but not new) new_in_r2 = [d for d in r2["diff"] if d["action"] == "new"] self.assertEqual(len(new_in_r2), 0, "Second run must not create new signals") non_new = [d for d in r2["diff"] if d["action"] in ("skip_cooldown", "refresh", "exists", "cooldown")] self.assertGreater(len(non_new), 0, "Should see non-new actions on second run") async def test_signal_risk_cluster_rule(self): """risk_cluster signal fires when 3+ [RISK] tasks share a label.""" for i in range(4): await self._db.create_task( self._pid, f"[RISK] DB problem {i}", status="backlog", priority="high", labels=["database"] ) result = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=True) types = [d["signal_type"] for d in result["diff"]] self.assertIn("risk_cluster", types, "risk_cluster signal must fire for 4 tasks with shared label") async def test_signal_stale_goal(self): """stale_goal signal fires for goals not updated in 14 days.""" import datetime db = await self._db.get_db() old_date = (datetime.datetime.utcnow() - datetime.timedelta(days=20)).strftime("%Y-%m-%dT%H:%M:%SZ") node_id = str(uuid.uuid4()) await db.execute( """INSERT INTO dialog_nodes(node_id, project_id, node_type, ref_id, title, lifecycle, importance, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)""", (node_id, self._pid, "goal", node_id, "Old Stale Goal", "active", 0.9, old_date, old_date), ) await db.commit() result = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=True) types = [d["signal_type"] for d in result["diff"]] self.assertIn("stale_goal", types, "stale_goal must fire for goal not updated in 20 days") async def test_signal_ack_changes_status(self): """ack action changes signal status to 'ack'.""" # Create a signal manually db = await self._db.get_db() sig_id = str(uuid.uuid4()) now = self._db._now() await db.execute( "INSERT INTO graph_signals(id,project_id,signal_type,severity,title,summary,evidence,status,fingerprint,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (sig_id, self._pid, "stale_goal", "medium", "Test Signal", "", "{}", "open", "fp123", now, now), ) await db.commit() result = await self._db.update_signal_status(sig_id, "ack") self.assertIsNotNone(result) self.assertEqual(result["status"], "ack") async def test_signal_evidence_node_ids_valid(self): """risk_cluster signal evidence contains valid task IDs.""" task_ids = [] for i in range(3): t = await self._db.create_task( self._pid, f"[RISK] infra problem {i}", status="backlog", priority="high", labels=["infra"] ) task_ids.append(t["task_id"]) result = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) # Load saved signals signals = await self._db.get_graph_signals(self._pid, status="open") cluster = [s for s in signals if s["signal_type"] == "risk_cluster"] self.assertTrue(len(cluster) > 0, "risk_cluster signal must exist") ev_ids = cluster[0]["evidence"].get("task_ids", []) for eid in ev_ids[:3]: self.assertIn(eid, task_ids, f"Signal evidence must reference valid task IDs: {eid}") async def test_get_signals_by_status(self): """get_graph_signals filters correctly by status.""" db = await self._db.get_db() now = self._db._now() for status, fp in [("open", "fp1"), ("ack", "fp2"), ("dismissed", "fp3")]: await db.execute( "INSERT INTO graph_signals(id,project_id,signal_type,severity,title,summary,evidence,status,fingerprint,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (str(uuid.uuid4()), self._pid, "stale_goal", "medium", f"Sig {status}", "", "{}", status, fp, now, now), ) await db.commit() open_sigs = await self._db.get_graph_signals(self._pid, status="open") self.assertTrue(all(s["status"] == "open" for s in open_sigs)) all_sigs = await self._db.get_graph_signals(self._pid, status="all") self.assertGreaterEqual(len(all_sigs), 3) @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestOpsGraphBridging(unittest.IsolatedAsyncioTestCase): """Tests for upsert_ops_run_node (Ops Graph Bridging).""" async def asyncSetUp(self): self._db = _db_module await self._db.close_db() self._db._db_conn = None await self._db.init_db() self._pid = f"ops-{uuid.uuid4().hex[:10]}" r = await self._db.create_project("Ops Test", project_id=self._pid) self._pid = r["project_id"] async def asyncTearDown(self): await self._db.close_db() self._db._db_conn = None async def test_ops_run_node_created(self): """upsert_ops_run_node creates dialog_node with node_type=ops_run.""" run_id = f"ops-{uuid.uuid4().hex[:8]}" result = await self._db.upsert_ops_run_node( project_id=self._pid, ops_run_id=run_id, action_id="smoke_gateway", node_id="NODA1", status="ok", elapsed_ms=250, ) self.assertIn("node_id", result) db = await self._db.get_db() async with db.execute( "SELECT node_type, title FROM dialog_nodes WHERE node_id=?", (result["node_id"],) ) as cur: row = await cur.fetchone() self.assertIsNotNone(row) self.assertEqual(row[0], "ops_run") self.assertIn("smoke_gateway", row[1]) async def test_ops_run_node_idempotent(self): """Calling upsert_ops_run_node twice with same ops_run_id updates, not duplicates.""" run_id = f"ops-{uuid.uuid4().hex[:8]}" r1 = await self._db.upsert_ops_run_node(self._pid, run_id, "drift_check", "NODA1", "ok") r2 = await self._db.upsert_ops_run_node(self._pid, run_id, "drift_check", "NODA1", "failed") self.assertEqual(r1["node_id"], r2["node_id"], "Same run_id must return same node_id") db = await self._db.get_db() async with db.execute( "SELECT COUNT(*) FROM dialog_nodes WHERE project_id=? AND node_type='ops_run' AND ref_id=?", (self._pid, run_id), ) as cur: count = (await cur.fetchone())[0] self.assertEqual(count, 1, "No duplicate nodes on upsert") async def test_ops_run_failed_higher_importance(self): """Failed ops_run nodes have higher importance than successful ones.""" ok_id = f"ops-ok-{uuid.uuid4().hex[:6]}" fail_id = f"ops-fail-{uuid.uuid4().hex[:6]}" r_ok = await self._db.upsert_ops_run_node(self._pid, ok_id, "smoke_all", "NODA1", "ok") r_fail = await self._db.upsert_ops_run_node(self._pid, fail_id, "smoke_all", "NODA1", "failed") db = await self._db.get_db() async with db.execute( "SELECT importance FROM dialog_nodes WHERE node_id=?", (r_ok["node_id"],) ) as cur: imp_ok = (await cur.fetchone())[0] async with db.execute( "SELECT importance FROM dialog_nodes WHERE node_id=?", (r_fail["node_id"],) ) as cur: imp_fail = (await cur.fetchone())[0] self.assertGreater(imp_fail, imp_ok, "Failed ops_run must have higher importance") async def test_ops_run_links_to_source_agent_run(self): """ops_run node gets a produced_by edge from source supervisor run.""" # Create a source agent_run node src_run_id = f"run-{uuid.uuid4().hex[:8]}" await self._db.create_evidence_pack( self._pid, src_run_id, "release_check", result_data={"status": "completed"} ) ops_id = f"ops-{uuid.uuid4().hex[:8]}" result = await self._db.upsert_ops_run_node( self._pid, ops_id, "smoke_gateway", "NODA1", "ok", source_run_id=src_run_id, ) self.assertIsNotNone(result["edge_id"], "Edge must be created when source_run_id is given") db = await self._db.get_db() async with db.execute( "SELECT edge_type FROM dialog_edges WHERE edge_id=?", (result["edge_id"],) ) as cur: row = await cur.fetchone() self.assertIsNotNone(row) self.assertEqual(row[0], "produced_by") @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestMitigationPlanner(unittest.IsolatedAsyncioTestCase): """Tests for create_mitigation_plan (Mitigation Planner).""" async def asyncSetUp(self): self._db = _db_module await self._db.close_db() self._db._db_conn = None await self._db.init_db() self._pid = f"mit-{uuid.uuid4().hex[:10]}" r = await self._db.create_project("Mit Test", project_id=self._pid) self._pid = r["project_id"] # Create a test signal db = await self._db.get_db() self._sig_id = str(uuid.uuid4()) now = self._db._now() await db.execute( "INSERT INTO graph_signals(id,project_id,signal_type,severity,title,summary,evidence,status,fingerprint,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?,?)", (self._sig_id, self._pid, "release_blocker", "critical", "Test Release Blocker", "Test summary", '{"blocker_count": 2}', "open", f"fp-{uuid.uuid4().hex[:8]}", now, now), ) await db.commit() async def asyncTearDown(self): await self._db.close_db() self._db._db_conn = None async def test_mitigation_creates_plan_node(self): """create_mitigation_plan creates a decision node for the plan.""" result = await self._db.create_mitigation_plan(self._pid, self._sig_id) self.assertTrue(result["ok"]) self.assertIn("plan_node_id", result) db = await self._db.get_db() async with db.execute( "SELECT node_type, title FROM dialog_nodes WHERE node_id=?", (result["plan_node_id"],), ) as cur: row = await cur.fetchone() self.assertIsNotNone(row) self.assertEqual(row[0], "decision") self.assertIn("Mitigation Plan", row[1]) async def test_mitigation_creates_tasks_from_templates(self): """Mitigation plan creates tasks matching release_blocker templates.""" result = await self._db.create_mitigation_plan(self._pid, self._sig_id) self.assertGreater(result["task_count"], 0) self.assertEqual(len(result["task_ids"]), result["task_count"]) # Verify tasks exist in DB db = await self._db.get_db() for tid in result["task_ids"]: async with db.execute("SELECT title FROM tasks WHERE task_id=?", (tid,)) as cur: row = await cur.fetchone() self.assertIsNotNone(row, f"Task {tid} must exist in DB") self.assertIn("[Mitigation]", row[0]) async def test_mitigation_task_count_by_signal_type(self): """Each signal_type has the expected number of mitigation templates.""" expected_counts = { "release_blocker": 4, "ops_instability": 3, "stale_goal": 3, "risk_cluster": 4, "run_quality_regression": 3, } db = await self._db.get_db() now = self._db._now() for sig_type, expected in expected_counts.items(): sid = str(uuid.uuid4()) await db.execute( "INSERT INTO graph_signals(id,project_id,signal_type,severity,title,summary,evidence,status,fingerprint,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?,?)", (sid, self._pid, sig_type, "high", f"Test {sig_type}", "", "{}", "open", f"fp-{sid[:8]}", now, now), ) await db.commit() result = await self._db.create_mitigation_plan(self._pid, sid) self.assertEqual(result["task_count"], expected, f"{sig_type} should have {expected} tasks, got {result['task_count']}") async def test_mitigation_creates_plan_to_task_edges(self): """Each mitigation task has a derives_task edge from the plan node.""" result = await self._db.create_mitigation_plan(self._pid, self._sig_id) db = await self._db.get_db() plan_nid = result["plan_node_id"] # Get task node_ids from dialog_nodes task_node_ids = [] for tid in result["task_ids"]: async with db.execute( "SELECT node_id FROM dialog_nodes WHERE project_id=? AND node_type='task' AND ref_id=?", (self._pid, tid), ) as cur: row = await cur.fetchone() if row: task_node_ids.append(row[0]) # Check derives_task edges from plan_node for tnid in task_node_ids: async with db.execute( "SELECT COUNT(*) FROM dialog_edges WHERE project_id=? AND from_node_id=? AND to_node_id=? AND edge_type='derives_task'", (self._pid, plan_nid, tnid), ) as cur: count = (await cur.fetchone())[0] self.assertEqual(count, 1, f"Missing derives_task edge for task node {tnid}") async def test_mitigation_updates_signal_evidence(self): """After mitigation, signal.evidence contains plan_node_id and mitigation_task_ids.""" result = await self._db.create_mitigation_plan(self._pid, self._sig_id) db = await self._db.get_db() async with db.execute("SELECT evidence FROM graph_signals WHERE id=?", (self._sig_id,)) as cur: row = await cur.fetchone() import json as _json ev = _json.loads(row[0]) self.assertIn("plan_node_id", ev) self.assertEqual(ev["plan_node_id"], result["plan_node_id"]) self.assertIn("mitigation_task_ids", ev) async def test_mitigation_idempotent(self): """Running mitigation twice does not duplicate plan node.""" r1 = await self._db.create_mitigation_plan(self._pid, self._sig_id) r2 = await self._db.create_mitigation_plan(self._pid, self._sig_id) self.assertEqual(r1["plan_node_id"], r2["plan_node_id"], "Plan node must be stable") async def test_mitigation_invalid_signal_raises(self): """create_mitigation_plan raises ValueError for unknown signal.""" with self.assertRaises(ValueError): await self._db.create_mitigation_plan(self._pid, "nonexistent-signal-id") @unittest.skipUnless(_AIOSQLITE_AVAILABLE and _DB_AVAILABLE, "aiosqlite or db not available") class TestSignalLifecycle(unittest.IsolatedAsyncioTestCase): """Tests for signal merge/reopen/cooldown and auto-resolve.""" async def asyncSetUp(self): self._db = _db_module await self._db.close_db() self._db._db_conn = None await self._db.init_db() self._pid = f"slc-{uuid.uuid4().hex[:10]}" r = await self._db.create_project("SLC Test", project_id=self._pid) self._pid = r["project_id"] async def asyncTearDown(self): await self._db.close_db() self._db._db_conn = None async def _make_signal(self, sig_type="stale_goal", severity="medium", status="open", fp=None): """Helper: insert a signal directly.""" db = await self._db.get_db() sid = str(uuid.uuid4()) now = self._db._now() _fp = fp or f"fp-{uuid.uuid4().hex[:8]}" await db.execute( "INSERT INTO graph_signals(id,project_id,signal_type,severity,title,summary,evidence,status,fingerprint,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?,?)", (sid, self._pid, sig_type, severity, f"Test {sig_type}", "", '{"cooldown_hours": 24}', status, _fp, now, now), ) await db.commit() return sid, _fp # ── Cooldown / Reopen ───────────────────────────────────────────────────── async def test_signal_new_creates_correctly(self): """First-time signal is created with last_triggered_at in evidence.""" for i in range(3): await self._db.create_task( self._pid, f"[RISK] Cluster task {i}", status="backlog", priority="high", labels=["infra-cluster"] ) result = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) diff_new = [d for d in result["diff"] if d["action"] == "new"] self.assertGreater(len(diff_new), 0) # Verify evidence has last_triggered_at sigs = await self._db.get_graph_signals(self._pid) for s in sigs: self.assertIn("last_triggered_at", s["evidence"]) self.assertIn("cooldown_hours", s["evidence"]) async def test_cooldown_prevents_duplicate(self): """Second recompute within cooldown skips already-active signal.""" for i in range(3): await self._db.create_task( self._pid, f"[RISK] X {i}", status="backlog", priority="high", labels=["comp-x"] ) r1 = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) r2 = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) # All r2 diff entries should be skip_cooldown (not new) r2_new = [d for d in r2["diff"] if d["action"] == "new"] self.assertEqual(len(r2_new), 0, "Second recompute in cooldown must not create new signals") skip = [d for d in r2["diff"] if d["action"] == "skip_cooldown"] self.assertGreater(len(skip), 0, "skip_cooldown must appear in diff") async def test_resolved_signal_reopens_after_cooldown(self): """A resolved signal with expired cooldown gets reopened on next recompute.""" import datetime db = await self._db.get_db() # Create stale goal to trigger stale_goal rule node_id = str(uuid.uuid4()) old_ts = (datetime.datetime.utcnow() - datetime.timedelta(days=20)).strftime("%Y-%m-%dT%H:%M:%SZ") await db.execute( "INSERT INTO dialog_nodes(node_id,project_id,node_type,ref_id,title,lifecycle,importance,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?)", (node_id, self._pid, "goal", node_id, "Stale Test Goal", "active", 0.9, old_ts, old_ts), ) await db.commit() # First recompute: creates the signal r1 = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) new_sigs = [d for d in r1["diff"] if d["action"] == "new" and d["signal_type"] == "stale_goal"] self.assertGreater(len(new_sigs), 0, "stale_goal signal must be created") # Find and mark it resolved, with old updated_at (cooldown expired) sigs = await self._db.get_graph_signals(self._pid) stale_sig = next((s for s in sigs if s["signal_type"] == "stale_goal"), None) self.assertIsNotNone(stale_sig) old_updated = (datetime.datetime.utcnow() - datetime.timedelta(days=2)).strftime("%Y-%m-%dT%H:%M:%SZ") await db.execute( "UPDATE graph_signals SET status='resolved', updated_at=?, evidence=? WHERE id=?", (old_updated, '{"cooldown_hours": 1, "last_triggered_at": "' + old_updated + '"}', stale_sig["id"]), ) await db.commit() # Second recompute: should reopen (cooldown of 1h expired) r2 = await self._db.recompute_graph_signals(self._pid, window="7d", dry_run=False) reopen_entries = [d for d in r2["diff"] if d.get("action") == "reopen"] self.assertGreater(len(reopen_entries), 0, "Resolved signal must be reopened after cooldown expires") # ── Auto-resolve ────────────────────────────────────────────────────────── async def test_auto_resolve_dry_run_does_not_change_status(self): """auto_resolve dry_run computes but does not change signal status.""" sid, _ = await self._make_signal("release_blocker", status="open") result = await self._db.auto_resolve_signals(self._pid, dry_run=True) self.assertTrue(result["ok"]) self.assertEqual(result["resolved"], 0) # Status unchanged db = await self._db.get_db() async with db.execute("SELECT status FROM graph_signals WHERE id=?", (sid,)) as cur: row = await cur.fetchone() self.assertEqual(row[0], "open") async def test_auto_resolve_release_blocker_when_no_risks(self): """release_blocker resolves when no open [RISK] tasks remain.""" sid, _ = await self._make_signal("release_blocker", status="open") # No [RISK] tasks → criteria met result = await self._db.auto_resolve_signals(self._pid, dry_run=False) resolved = [d for d in result["diff"] if d.get("action") == "resolved" and d["signal_type"] == "release_blocker"] self.assertGreater(len(resolved), 0, "release_blocker must resolve when no [RISK] tasks") # Verify status in DB db = await self._db.get_db() async with db.execute("SELECT status, evidence FROM graph_signals WHERE id=?", (sid,)) as cur: row = await cur.fetchone() self.assertEqual(row[0], "resolved") import json as _j ev = _j.loads(row[1]) self.assertIn("resolved_at", ev) self.assertIn("resolution_reason", ev) async def test_auto_resolve_release_blocker_stays_open_with_risks(self): """release_blocker stays open when [RISK] tasks exist.""" await self._db.create_task(self._pid, "[RISK] Critical blocker", status="backlog", priority="high") sid, _ = await self._make_signal("release_blocker", status="open") result = await self._db.auto_resolve_signals(self._pid, dry_run=False) still_open = [d for d in result["diff"] if d.get("action") == "still_open"] self.assertGreater(len(still_open), 0) async def test_auto_resolve_run_quality_regression_resolves(self): """run_quality_regression resolves when avg completeness >= 75%.""" sid, _ = await self._make_signal("run_quality_regression", status="open") # Insert 3 high-quality reflections db = await self._db.get_db() now = self._db._now() for i in range(3): import json as _j props = _j.dumps({"plan_completeness_score": 0.85, "confidence": "high"}) await db.execute( "INSERT INTO dialog_nodes(node_id,project_id,node_type,ref_id,title,props,lifecycle,importance,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?)", (str(uuid.uuid4()), self._pid, "decision", f"refl-{i}-{uuid.uuid4().hex[:6]}", f"Reflection: run{i}", props, "active", 0.7, now, now), ) await db.commit() result = await self._db.auto_resolve_signals(self._pid, dry_run=False) resolved = [d for d in result["diff"] if d.get("action") == "resolved" and d["signal_type"] == "run_quality_regression"] self.assertGreater(len(resolved), 0, "run_quality_regression must resolve with good quality") async def test_auto_resolve_returns_correct_counts(self): """auto_resolve result has accurate checked/resolved counts.""" sid1, _ = await self._make_signal("release_blocker", status="open") sid2, _ = await self._make_signal("stale_goal", status="ack") result = await self._db.auto_resolve_signals(self._pid, dry_run=True) self.assertEqual(result["checked"], 2) self.assertEqual(len(result["diff"]), 2) class TestPlaybooks(unittest.IsolatedAsyncioTestCase): """Tests for Playbooks v1 (Graph Learning Layer).""" async def asyncSetUp(self): import uuid as _uuid self._pid = f"pb-{_uuid.uuid4().hex[:10]}" _db_module._db_conn = None try: await _db_module.close_db() except Exception: pass _db_module._db_conn = None _db_module._DB_PATH = ":memory:" await _db_module.get_db() await _db_module.init_db() await _db_module.create_project(name="PB Test", project_id=self._pid) self._db = _db_module async def asyncTearDown(self): try: await _db_module.close_db() except Exception: pass _db_module._db_conn = None async def _make_mitigated_signal(self, signal_type: str = "risk_cluster", label: str = "auth"): """Create a signal and run mitigation on it.""" import uuid as _uuid sig_id = str(_uuid.uuid4()) now = "2026-02-26T12:00:00" evidence = {"label": label, "count": 3} db = await self._db.get_db() await db.execute( "INSERT INTO graph_signals(id,project_id,signal_type,severity,title,summary,evidence,status,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?)", (sig_id, self._pid, signal_type, "high", f"Test {signal_type}", "", json.dumps(evidence), "open", now, now), ) await db.commit() # Create mitigation result = await self._db.create_mitigation_plan(self._pid, sig_id) return sig_id, result def test_context_key_risk_cluster(self): """compute_context_key returns label: