Implement persistent SQLite database feature that allows scripts to query schedule data directly via SQL after loading XER files through MCP. Key changes: - Extend load_xer with db_path parameter for persistent database - Add get_database_info tool to retrieve database connection details - Add schema introspection with tables, columns, primary/foreign keys - Support WAL mode for concurrent read access - Use atomic write pattern to prevent corruption New features: - db_path=None: in-memory database (default, backward compatible) - db_path="": auto-generate path from XER filename (.sqlite extension) - db_path="/path/to/db": explicit persistent database path Response includes complete DatabaseInfo: - db_path: absolute path (or :memory:) - is_persistent: boolean - source_file: loaded XER path - loaded_at: ISO timestamp - schema: tables with columns, primary keys, foreign keys, row counts Closes: User Story 1, 2, 3 from 002-direct-db-access spec
186 lines
5.7 KiB
Python
186 lines
5.7 KiB
Python
"""Integration tests for direct database access feature."""
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from xer_mcp.db import db
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_db():
|
|
"""Reset database state for each test."""
|
|
if db.is_initialized:
|
|
db.close()
|
|
yield
|
|
if db.is_initialized:
|
|
db.close()
|
|
|
|
|
|
class TestDirectDatabaseAccess:
|
|
"""Integration tests verifying external script can access database."""
|
|
|
|
async def test_external_script_can_query_database(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""External script can query database using returned path."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
db_file = tmp_path / "schedule.db"
|
|
result = await load_xer(
|
|
file_path=str(sample_xer_single_project),
|
|
db_path=str(db_file),
|
|
)
|
|
|
|
# Simulate external script access (as shown in quickstart.md)
|
|
db_path = result["database"]["db_path"]
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
# Query milestones
|
|
cursor = conn.execute("""
|
|
SELECT task_code, task_name, target_start_date, milestone_type
|
|
FROM activities
|
|
WHERE task_type IN ('TT_Mile', 'TT_FinMile')
|
|
ORDER BY target_start_date
|
|
""")
|
|
|
|
milestones = cursor.fetchall()
|
|
conn.close()
|
|
|
|
assert len(milestones) > 0
|
|
assert all(row["task_code"] for row in milestones)
|
|
|
|
async def test_external_script_can_query_critical_path(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""External script can query critical path activities."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
db_file = tmp_path / "schedule.db"
|
|
result = await load_xer(
|
|
file_path=str(sample_xer_single_project),
|
|
db_path=str(db_file),
|
|
)
|
|
|
|
db_path = result["database"]["db_path"]
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
cursor = conn.execute("""
|
|
SELECT task_code, task_name, target_start_date, target_end_date
|
|
FROM activities
|
|
WHERE driving_path_flag = 1
|
|
ORDER BY target_start_date
|
|
""")
|
|
|
|
critical_activities = cursor.fetchall()
|
|
conn.close()
|
|
|
|
assert len(critical_activities) > 0
|
|
|
|
async def test_external_script_can_join_tables(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""External script can join activities with WBS."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
db_file = tmp_path / "schedule.db"
|
|
result = await load_xer(
|
|
file_path=str(sample_xer_single_project),
|
|
db_path=str(db_file),
|
|
)
|
|
|
|
db_path = result["database"]["db_path"]
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
cursor = conn.execute("""
|
|
SELECT a.task_code, a.task_name, w.wbs_name
|
|
FROM activities a
|
|
JOIN wbs w ON a.wbs_id = w.wbs_id
|
|
LIMIT 10
|
|
""")
|
|
|
|
joined_rows = cursor.fetchall()
|
|
conn.close()
|
|
|
|
assert len(joined_rows) > 0
|
|
|
|
async def test_database_accessible_after_mcp_load(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""Database remains accessible while MCP tools are active."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
db_file = tmp_path / "schedule.db"
|
|
result = await load_xer(
|
|
file_path=str(sample_xer_single_project),
|
|
db_path=str(db_file),
|
|
)
|
|
loaded_count = result["activity_count"]
|
|
|
|
# External script queries database
|
|
conn = sqlite3.connect(str(db_file))
|
|
cursor = conn.execute("SELECT COUNT(*) FROM activities")
|
|
external_count = cursor.fetchone()[0]
|
|
conn.close()
|
|
|
|
# Both should match
|
|
assert external_count == loaded_count
|
|
|
|
async def test_schema_info_matches_actual_database(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""Returned schema info matches actual database structure."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
db_file = tmp_path / "schedule.db"
|
|
result = await load_xer(
|
|
file_path=str(sample_xer_single_project),
|
|
db_path=str(db_file),
|
|
)
|
|
|
|
schema = result["database"]["schema"]
|
|
db_path = result["database"]["db_path"]
|
|
|
|
# Verify tables exist in actual database
|
|
conn = sqlite3.connect(db_path)
|
|
cursor = conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
)
|
|
actual_tables = {row[0] for row in cursor.fetchall()}
|
|
conn.close()
|
|
|
|
schema_tables = {t["name"] for t in schema["tables"]}
|
|
assert schema_tables == actual_tables
|
|
|
|
async def test_row_counts_match_actual_data(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""Schema row counts match actual database row counts."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
db_file = tmp_path / "schedule.db"
|
|
result = await load_xer(
|
|
file_path=str(sample_xer_single_project),
|
|
db_path=str(db_file),
|
|
)
|
|
|
|
schema = result["database"]["schema"]
|
|
db_path = result["database"]["db_path"]
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
|
|
for table_info in schema["tables"]:
|
|
cursor = conn.execute(
|
|
f"SELECT COUNT(*) FROM {table_info['name']}" # noqa: S608
|
|
)
|
|
actual_count = cursor.fetchone()[0]
|
|
assert table_info["row_count"] == actual_count, (
|
|
f"Table {table_info['name']}: expected {table_info['row_count']}, "
|
|
f"got {actual_count}"
|
|
)
|
|
|
|
conn.close()
|