Files
xer-mcp/tests/contract/test_load_xer.py
Bill Ballou d6a79bf24a feat: add direct database access for scripts (v0.2.0)
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
2026-01-08 12:54:56 -05:00

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"