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
251 lines
9.1 KiB
Python
251 lines
9.1 KiB
Python
"""Contract tests for load_xer MCP tool."""
|
|
|
|
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."""
|
|
# Close any existing connection
|
|
if db.is_initialized:
|
|
db.close()
|
|
yield
|
|
# Cleanup after test
|
|
if db.is_initialized:
|
|
db.close()
|
|
|
|
|
|
class TestLoadXerContract:
|
|
"""Contract tests verifying load_xer tool interface matches spec."""
|
|
|
|
async def test_load_single_project_returns_success(
|
|
self, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""load_xer with single-project file returns success and project info."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
result = await load_xer(file_path=str(sample_xer_single_project))
|
|
|
|
assert result["success"] is True
|
|
assert "project" in result
|
|
assert result["project"]["proj_id"] == "1001"
|
|
assert result["project"]["proj_short_name"] == "Test Project"
|
|
assert "activity_count" in result
|
|
assert result["activity_count"] == 5
|
|
assert "relationship_count" in result
|
|
assert result["relationship_count"] == 5
|
|
|
|
async def test_load_multi_project_without_selection_returns_list(
|
|
self, sample_xer_multi_project: Path
|
|
) -> None:
|
|
"""load_xer with multi-project file without project_id returns available projects."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
result = await load_xer(file_path=str(sample_xer_multi_project))
|
|
|
|
assert result["success"] is False
|
|
assert "available_projects" in result
|
|
assert len(result["available_projects"]) == 2
|
|
assert "message" in result
|
|
assert "project_id" in result["message"].lower()
|
|
|
|
async def test_load_multi_project_with_selection_returns_success(
|
|
self, sample_xer_multi_project: Path
|
|
) -> None:
|
|
"""load_xer with multi-project file and project_id returns selected project."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
result = await load_xer(file_path=str(sample_xer_multi_project), project_id="1001")
|
|
|
|
assert result["success"] is True
|
|
assert result["project"]["proj_id"] == "1001"
|
|
assert result["project"]["proj_short_name"] == "Project Alpha"
|
|
|
|
async def test_load_nonexistent_file_returns_error(self, nonexistent_xer_path: Path) -> None:
|
|
"""load_xer with missing file returns FILE_NOT_FOUND error."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
result = await load_xer(file_path=str(nonexistent_xer_path))
|
|
|
|
assert result["success"] is False
|
|
assert "error" in result
|
|
assert result["error"]["code"] == "FILE_NOT_FOUND"
|
|
|
|
async def test_load_invalid_file_returns_error(self, invalid_xer_file: Path) -> None:
|
|
"""load_xer with invalid file returns PARSE_ERROR error."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
result = await load_xer(file_path=str(invalid_xer_file))
|
|
|
|
assert result["success"] is False
|
|
assert "error" in result
|
|
assert result["error"]["code"] == "PARSE_ERROR"
|
|
|
|
async def test_load_replaces_previous_file(
|
|
self, sample_xer_single_project: Path, sample_xer_empty: Path
|
|
) -> None:
|
|
"""Loading a new file replaces the previous file's data."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
# Load first file
|
|
result1 = await load_xer(file_path=str(sample_xer_single_project))
|
|
assert result1["activity_count"] == 5
|
|
|
|
# Load second file (empty)
|
|
result2 = await load_xer(file_path=str(sample_xer_empty))
|
|
assert result2["activity_count"] == 0
|
|
|
|
async def test_load_returns_plan_dates(self, sample_xer_single_project: Path) -> None:
|
|
"""load_xer returns project plan start and end dates."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
result = await load_xer(file_path=str(sample_xer_single_project))
|
|
|
|
assert "plan_start_date" in result["project"]
|
|
assert "plan_end_date" in result["project"]
|
|
# Dates should be ISO8601 format
|
|
assert "T" in result["project"]["plan_start_date"]
|
|
|
|
|
|
class TestLoadXerPersistentDatabase:
|
|
"""Contract tests for persistent database functionality."""
|
|
|
|
async def test_load_xer_with_db_path_creates_file(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""load_xer with db_path creates persistent database file."""
|
|
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),
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert db_file.exists()
|
|
assert result["database"]["db_path"] == str(db_file)
|
|
assert result["database"]["is_persistent"] is True
|
|
|
|
async def test_load_xer_with_empty_db_path_auto_generates(self, tmp_path: Path) -> None:
|
|
"""load_xer with empty db_path generates path from XER filename."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
# Create XER file in tmp_path
|
|
xer_file = tmp_path / "my_schedule.xer"
|
|
from tests.conftest import SAMPLE_XER_SINGLE_PROJECT
|
|
|
|
xer_file.write_text(SAMPLE_XER_SINGLE_PROJECT)
|
|
|
|
result = await load_xer(file_path=str(xer_file), db_path="")
|
|
|
|
assert result["success"] is True
|
|
expected_db = str(tmp_path / "my_schedule.sqlite")
|
|
assert result["database"]["db_path"] == expected_db
|
|
assert result["database"]["is_persistent"] is True
|
|
assert Path(expected_db).exists()
|
|
|
|
async def test_load_xer_without_db_path_uses_memory(
|
|
self, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""load_xer without db_path uses in-memory database (backward compatible)."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
result = await load_xer(file_path=str(sample_xer_single_project))
|
|
|
|
assert result["success"] is True
|
|
assert result["database"]["db_path"] == ":memory:"
|
|
assert result["database"]["is_persistent"] is False
|
|
|
|
async def test_load_xer_database_contains_all_data(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""Persistent database contains all parsed data."""
|
|
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),
|
|
)
|
|
|
|
# Verify data via direct SQL
|
|
conn = sqlite3.connect(str(db_file))
|
|
cursor = conn.execute("SELECT COUNT(*) FROM activities")
|
|
count = cursor.fetchone()[0]
|
|
conn.close()
|
|
|
|
assert count == result["activity_count"]
|
|
|
|
async def test_load_xer_response_includes_database_info(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""load_xer response includes complete database info."""
|
|
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),
|
|
)
|
|
|
|
assert "database" in result
|
|
db_info = result["database"]
|
|
assert "db_path" in db_info
|
|
assert "is_persistent" in db_info
|
|
assert "source_file" in db_info
|
|
assert "loaded_at" in db_info
|
|
assert "schema" in db_info
|
|
|
|
async def test_load_xer_response_schema_includes_tables(
|
|
self, tmp_path: Path, sample_xer_single_project: Path
|
|
) -> None:
|
|
"""load_xer response schema includes table information."""
|
|
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"]
|
|
assert "version" in schema
|
|
assert "tables" in schema
|
|
table_names = [t["name"] for t in schema["tables"]]
|
|
assert "activities" in table_names
|
|
assert "relationships" in table_names
|
|
|
|
async def test_load_xer_error_on_invalid_path(self, sample_xer_single_project: Path) -> None:
|
|
"""load_xer returns error for invalid path."""
|
|
from xer_mcp.tools.load_xer import load_xer
|
|
|
|
result = await load_xer(
|
|
file_path=str(sample_xer_single_project),
|
|
db_path="/nonexistent/dir/file.db",
|
|
)
|
|
|
|
assert result["success"] is False
|
|
# Either FILE_NOT_WRITABLE or DATABASE_ERROR is acceptable
|
|
# depending on how SQLite reports the error
|
|
assert result["error"]["code"] in ("FILE_NOT_WRITABLE", "DATABASE_ERROR")
|
|
|
|
|
|
class TestLoadXerToolSchema:
|
|
"""Tests for MCP tool schema."""
|
|
|
|
async def test_load_xer_tool_schema_includes_db_path(self) -> None:
|
|
"""MCP tool schema includes db_path parameter."""
|
|
from xer_mcp.server import list_tools
|
|
|
|
tools = await list_tools()
|
|
load_xer_tool = next(t for t in tools if t.name == "load_xer")
|
|
props = load_xer_tool.inputSchema["properties"]
|
|
assert "db_path" in props
|
|
assert props["db_path"]["type"] == "string"
|