docs: add pre-deployment testing implementation plan
This commit is contained in:
980
docs/plans/2025-12-30-pre-deployment-testing-impl.md
Normal file
980
docs/plans/2025-12-30-pre-deployment-testing-impl.md
Normal file
@@ -0,0 +1,980 @@
|
||||
# Pre-Deployment Testing Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Create a pre-deployment test pipeline with Makefile orchestration, mock Grist server, and MCP protocol integration tests.
|
||||
|
||||
**Architecture:** Makefile orchestrates unit tests, Docker builds, and integration tests. Integration tests use the MCP Python SDK to connect to the containerized grist-mcp server, which talks to a mock Grist API server. Both run in docker-compose on an isolated network.
|
||||
|
||||
**Tech Stack:** Python 3.14, pytest, MCP SDK, Starlette (mock server), Docker Compose, Make
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add Health Endpoint to grist-mcp
|
||||
|
||||
The integration tests need to poll for service readiness. Add a `/health` endpoint.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/grist_mcp/main.py:42-47`
|
||||
|
||||
**Step 1: Add health endpoint to main.py**
|
||||
|
||||
In `src/grist_mcp/main.py`, add a health route to the Starlette app:
|
||||
|
||||
```python
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
async def handle_health(request):
|
||||
return JSONResponse({"status": "ok"})
|
||||
```
|
||||
|
||||
And add the route:
|
||||
|
||||
```python
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/health", endpoint=handle_health),
|
||||
Route("/sse", endpoint=handle_sse),
|
||||
Route("/messages", endpoint=handle_messages, methods=["POST"]),
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Run existing tests**
|
||||
|
||||
Run: `uv run pytest tests/test_server.py -v`
|
||||
Expected: PASS (health endpoint doesn't break existing tests)
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/grist_mcp/main.py
|
||||
git commit -m "feat: add /health endpoint for service readiness checks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create Mock Grist Server
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/integration/mock_grist/__init__.py`
|
||||
- Create: `tests/integration/mock_grist/server.py`
|
||||
- Create: `tests/integration/mock_grist/Dockerfile`
|
||||
- Create: `tests/integration/mock_grist/requirements.txt`
|
||||
|
||||
**Step 1: Create directory structure**
|
||||
|
||||
```bash
|
||||
mkdir -p tests/integration/mock_grist
|
||||
```
|
||||
|
||||
**Step 2: Create requirements.txt**
|
||||
|
||||
Create `tests/integration/mock_grist/requirements.txt`:
|
||||
|
||||
```
|
||||
starlette>=0.41.0
|
||||
uvicorn>=0.32.0
|
||||
```
|
||||
|
||||
**Step 3: Create __init__.py**
|
||||
|
||||
Create empty `tests/integration/mock_grist/__init__.py`:
|
||||
|
||||
```python
|
||||
```
|
||||
|
||||
**Step 4: Create server.py**
|
||||
|
||||
Create `tests/integration/mock_grist/server.py`:
|
||||
|
||||
```python
|
||||
"""Mock Grist API server for integration testing."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [MOCK-GRIST] %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mock data
|
||||
MOCK_TABLES = {
|
||||
"People": {
|
||||
"columns": [
|
||||
{"id": "Name", "fields": {"type": "Text"}},
|
||||
{"id": "Age", "fields": {"type": "Int"}},
|
||||
{"id": "Email", "fields": {"type": "Text"}},
|
||||
],
|
||||
"records": [
|
||||
{"id": 1, "fields": {"Name": "Alice", "Age": 30, "Email": "alice@example.com"}},
|
||||
{"id": 2, "fields": {"Name": "Bob", "Age": 25, "Email": "bob@example.com"}},
|
||||
],
|
||||
},
|
||||
"Tasks": {
|
||||
"columns": [
|
||||
{"id": "Title", "fields": {"type": "Text"}},
|
||||
{"id": "Done", "fields": {"type": "Bool"}},
|
||||
],
|
||||
"records": [
|
||||
{"id": 1, "fields": {"Title": "Write tests", "Done": False}},
|
||||
{"id": 2, "fields": {"Title": "Deploy", "Done": False}},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Track requests for test assertions
|
||||
request_log: list[dict] = []
|
||||
|
||||
|
||||
def log_request(method: str, path: str, body: dict | None = None):
|
||||
"""Log a request for later inspection."""
|
||||
entry = {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"method": method,
|
||||
"path": path,
|
||||
"body": body,
|
||||
}
|
||||
request_log.append(entry)
|
||||
logger.info(f"{method} {path}" + (f" body={json.dumps(body)}" if body else ""))
|
||||
|
||||
|
||||
async def health(request):
|
||||
"""Health check endpoint."""
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
async def get_request_log(request):
|
||||
"""Return the request log for test assertions."""
|
||||
return JSONResponse(request_log)
|
||||
|
||||
|
||||
async def clear_request_log(request):
|
||||
"""Clear the request log."""
|
||||
request_log.clear()
|
||||
return JSONResponse({"status": "cleared"})
|
||||
|
||||
|
||||
async def list_tables(request):
|
||||
"""GET /api/docs/{doc_id}/tables"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
log_request("GET", f"/api/docs/{doc_id}/tables")
|
||||
tables = [{"id": name} for name in MOCK_TABLES.keys()]
|
||||
return JSONResponse({"tables": tables})
|
||||
|
||||
|
||||
async def get_table_columns(request):
|
||||
"""GET /api/docs/{doc_id}/tables/{table_id}/columns"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
log_request("GET", f"/api/docs/{doc_id}/tables/{table_id}/columns")
|
||||
|
||||
if table_id not in MOCK_TABLES:
|
||||
return JSONResponse({"error": "Table not found"}, status_code=404)
|
||||
|
||||
return JSONResponse({"columns": MOCK_TABLES[table_id]["columns"]})
|
||||
|
||||
|
||||
async def get_records(request):
|
||||
"""GET /api/docs/{doc_id}/tables/{table_id}/records"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
log_request("GET", f"/api/docs/{doc_id}/tables/{table_id}/records")
|
||||
|
||||
if table_id not in MOCK_TABLES:
|
||||
return JSONResponse({"error": "Table not found"}, status_code=404)
|
||||
|
||||
return JSONResponse({"records": MOCK_TABLES[table_id]["records"]})
|
||||
|
||||
|
||||
async def add_records(request):
|
||||
"""POST /api/docs/{doc_id}/tables/{table_id}/records"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
body = await request.json()
|
||||
log_request("POST", f"/api/docs/{doc_id}/tables/{table_id}/records", body)
|
||||
|
||||
# Return mock IDs for new records
|
||||
new_ids = [{"id": 100 + i} for i in range(len(body.get("records", [])))]
|
||||
return JSONResponse({"records": new_ids})
|
||||
|
||||
|
||||
async def update_records(request):
|
||||
"""PATCH /api/docs/{doc_id}/tables/{table_id}/records"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
body = await request.json()
|
||||
log_request("PATCH", f"/api/docs/{doc_id}/tables/{table_id}/records", body)
|
||||
return JSONResponse({})
|
||||
|
||||
|
||||
async def delete_records(request):
|
||||
"""POST /api/docs/{doc_id}/tables/{table_id}/data/delete"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
body = await request.json()
|
||||
log_request("POST", f"/api/docs/{doc_id}/tables/{table_id}/data/delete", body)
|
||||
return JSONResponse({})
|
||||
|
||||
|
||||
async def sql_query(request):
|
||||
"""GET /api/docs/{doc_id}/sql"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
query = request.query_params.get("q", "")
|
||||
log_request("GET", f"/api/docs/{doc_id}/sql?q={query}")
|
||||
|
||||
# Return mock SQL results
|
||||
return JSONResponse({
|
||||
"records": [
|
||||
{"fields": {"Name": "Alice", "Age": 30}},
|
||||
{"fields": {"Name": "Bob", "Age": 25}},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
async def create_tables(request):
|
||||
"""POST /api/docs/{doc_id}/tables"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
body = await request.json()
|
||||
log_request("POST", f"/api/docs/{doc_id}/tables", body)
|
||||
|
||||
# Return the created tables with their IDs
|
||||
tables = [{"id": t["id"]} for t in body.get("tables", [])]
|
||||
return JSONResponse({"tables": tables})
|
||||
|
||||
|
||||
async def add_column(request):
|
||||
"""POST /api/docs/{doc_id}/tables/{table_id}/columns"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
body = await request.json()
|
||||
log_request("POST", f"/api/docs/{doc_id}/tables/{table_id}/columns", body)
|
||||
|
||||
columns = [{"id": c["id"]} for c in body.get("columns", [])]
|
||||
return JSONResponse({"columns": columns})
|
||||
|
||||
|
||||
async def modify_column(request):
|
||||
"""PATCH /api/docs/{doc_id}/tables/{table_id}/columns/{col_id}"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
col_id = request.path_params["col_id"]
|
||||
body = await request.json()
|
||||
log_request("PATCH", f"/api/docs/{doc_id}/tables/{table_id}/columns/{col_id}", body)
|
||||
return JSONResponse({})
|
||||
|
||||
|
||||
async def delete_column(request):
|
||||
"""DELETE /api/docs/{doc_id}/tables/{table_id}/columns/{col_id}"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
col_id = request.path_params["col_id"]
|
||||
log_request("DELETE", f"/api/docs/{doc_id}/tables/{table_id}/columns/{col_id}")
|
||||
return JSONResponse({})
|
||||
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
# Test control endpoints
|
||||
Route("/health", endpoint=health),
|
||||
Route("/_test/requests", endpoint=get_request_log),
|
||||
Route("/_test/requests/clear", endpoint=clear_request_log, methods=["POST"]),
|
||||
|
||||
# Grist API endpoints
|
||||
Route("/api/docs/{doc_id}/tables", endpoint=list_tables),
|
||||
Route("/api/docs/{doc_id}/tables", endpoint=create_tables, methods=["POST"]),
|
||||
Route("/api/docs/{doc_id}/tables/{table_id}/columns", endpoint=get_table_columns),
|
||||
Route("/api/docs/{doc_id}/tables/{table_id}/columns", endpoint=add_column, methods=["POST"]),
|
||||
Route("/api/docs/{doc_id}/tables/{table_id}/columns/{col_id}", endpoint=modify_column, methods=["PATCH"]),
|
||||
Route("/api/docs/{doc_id}/tables/{table_id}/columns/{col_id}", endpoint=delete_column, methods=["DELETE"]),
|
||||
Route("/api/docs/{doc_id}/tables/{table_id}/records", endpoint=get_records),
|
||||
Route("/api/docs/{doc_id}/tables/{table_id}/records", endpoint=add_records, methods=["POST"]),
|
||||
Route("/api/docs/{doc_id}/tables/{table_id}/records", endpoint=update_records, methods=["PATCH"]),
|
||||
Route("/api/docs/{doc_id}/tables/{table_id}/data/delete", endpoint=delete_records, methods=["POST"]),
|
||||
Route("/api/docs/{doc_id}/sql", endpoint=sql_query),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.environ.get("PORT", "8484"))
|
||||
logger.info(f"Starting mock Grist server on port {port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
```
|
||||
|
||||
**Step 5: Create Dockerfile**
|
||||
|
||||
Create `tests/integration/mock_grist/Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.14-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server.py .
|
||||
|
||||
ENV PORT=8484
|
||||
EXPOSE 8484
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/integration/mock_grist/
|
||||
git commit -m "feat: add mock Grist server for integration testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Integration Test Configuration
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/integration/__init__.py`
|
||||
- Create: `tests/integration/config.test.yaml`
|
||||
|
||||
**Step 1: Create __init__.py**
|
||||
|
||||
Create empty `tests/integration/__init__.py`:
|
||||
|
||||
```python
|
||||
```
|
||||
|
||||
**Step 2: Create config.test.yaml**
|
||||
|
||||
Create `tests/integration/config.test.yaml`:
|
||||
|
||||
```yaml
|
||||
documents:
|
||||
test-doc:
|
||||
url: http://mock-grist:8484
|
||||
doc_id: test-doc-id
|
||||
api_key: test-api-key
|
||||
|
||||
tokens:
|
||||
- token: test-token
|
||||
name: test-agent
|
||||
scope:
|
||||
- document: test-doc
|
||||
permissions: [read, write, schema]
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/integration/__init__.py tests/integration/config.test.yaml
|
||||
git commit -m "feat: add integration test configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create Docker Compose Test Configuration
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-compose.test.yaml`
|
||||
|
||||
**Step 1: Create docker-compose.test.yaml**
|
||||
|
||||
Create `docker-compose.test.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
grist-mcp:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- CONFIG_PATH=/app/config.yaml
|
||||
- GRIST_MCP_TOKEN=test-token
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- ./tests/integration/config.test.yaml:/app/config.yaml:ro
|
||||
depends_on:
|
||||
mock-grist:
|
||||
condition: service_started
|
||||
networks:
|
||||
- test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mock-grist:
|
||||
build: tests/integration/mock_grist
|
||||
ports:
|
||||
- "8484:8484"
|
||||
environment:
|
||||
- PORT=8484
|
||||
networks:
|
||||
- test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8484/health')"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
test-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.test.yaml
|
||||
git commit -m "feat: add docker-compose for integration testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create Integration Test Fixtures
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/integration/conftest.py`
|
||||
|
||||
**Step 1: Create conftest.py**
|
||||
|
||||
Create `tests/integration/conftest.py`:
|
||||
|
||||
```python
|
||||
"""Fixtures for integration tests."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
|
||||
|
||||
GRIST_MCP_URL = "http://localhost:3000"
|
||||
MOCK_GRIST_URL = "http://localhost:8484"
|
||||
MAX_WAIT_SECONDS = 30
|
||||
|
||||
|
||||
def wait_for_service(url: str, timeout: int = MAX_WAIT_SECONDS) -> bool:
|
||||
"""Wait for a service to become healthy."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
response = httpx.get(f"{url}/health", timeout=2.0)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def services_ready():
|
||||
"""Ensure both services are healthy before running tests."""
|
||||
if not wait_for_service(MOCK_GRIST_URL):
|
||||
pytest.fail(f"Mock Grist server not ready at {MOCK_GRIST_URL}")
|
||||
if not wait_for_service(GRIST_MCP_URL):
|
||||
pytest.fail(f"grist-mcp server not ready at {GRIST_MCP_URL}")
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mcp_client(services_ready):
|
||||
"""Create an MCP client connected to grist-mcp via SSE."""
|
||||
async with sse_client(f"{GRIST_MCP_URL}/sse") as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_grist_client(services_ready):
|
||||
"""HTTP client for interacting with mock Grist test endpoints."""
|
||||
with httpx.Client(base_url=MOCK_GRIST_URL, timeout=10.0) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_mock_grist_log(mock_grist_client):
|
||||
"""Clear the mock Grist request log before each test."""
|
||||
mock_grist_client.post("/_test/requests/clear")
|
||||
yield
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/integration/conftest.py
|
||||
git commit -m "feat: add integration test fixtures with MCP client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create MCP Protocol Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/integration/test_mcp_protocol.py`
|
||||
|
||||
**Step 1: Create test_mcp_protocol.py**
|
||||
|
||||
Create `tests/integration/test_mcp_protocol.py`:
|
||||
|
||||
```python
|
||||
"""Test MCP protocol compliance over SSE transport."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_connection_initializes(mcp_client):
|
||||
"""Test that MCP client can connect and initialize."""
|
||||
# If we get here, connection and initialization succeeded
|
||||
assert mcp_client is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_returns_all_tools(mcp_client):
|
||||
"""Test that list_tools returns all expected tools."""
|
||||
result = await mcp_client.list_tools()
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
|
||||
expected_tools = [
|
||||
"list_documents",
|
||||
"list_tables",
|
||||
"describe_table",
|
||||
"get_records",
|
||||
"sql_query",
|
||||
"add_records",
|
||||
"update_records",
|
||||
"delete_records",
|
||||
"create_table",
|
||||
"add_column",
|
||||
"modify_column",
|
||||
"delete_column",
|
||||
]
|
||||
|
||||
for expected in expected_tools:
|
||||
assert expected in tool_names, f"Missing tool: {expected}"
|
||||
|
||||
assert len(result.tools) == 12
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_has_descriptions(mcp_client):
|
||||
"""Test that all tools have descriptions."""
|
||||
result = await mcp_client.list_tools()
|
||||
|
||||
for tool in result.tools:
|
||||
assert tool.description, f"Tool {tool.name} has no description"
|
||||
assert len(tool.description) > 10, f"Tool {tool.name} description too short"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_has_input_schemas(mcp_client):
|
||||
"""Test that all tools have input schemas."""
|
||||
result = await mcp_client.list_tools()
|
||||
|
||||
for tool in result.tools:
|
||||
assert tool.inputSchema is not None, f"Tool {tool.name} has no inputSchema"
|
||||
assert "type" in tool.inputSchema, f"Tool {tool.name} schema missing type"
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/integration/test_mcp_protocol.py
|
||||
git commit -m "feat: add MCP protocol compliance tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create Tool Integration Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/integration/test_tools_integration.py`
|
||||
|
||||
**Step 1: Create test_tools_integration.py**
|
||||
|
||||
Create `tests/integration/test_tools_integration.py`:
|
||||
|
||||
```python
|
||||
"""Test tool calls through MCP client to verify Grist API interactions."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents(mcp_client):
|
||||
"""Test list_documents returns accessible documents."""
|
||||
result = await mcp_client.call_tool("list_documents", {})
|
||||
|
||||
assert len(result.content) == 1
|
||||
data = json.loads(result.content[0].text)
|
||||
|
||||
assert "documents" in data
|
||||
assert len(data["documents"]) == 1
|
||||
assert data["documents"][0]["name"] == "test-doc"
|
||||
assert "read" in data["documents"][0]["permissions"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tables(mcp_client, mock_grist_client):
|
||||
"""Test list_tables calls correct Grist API endpoint."""
|
||||
result = await mcp_client.call_tool("list_tables", {"document": "test-doc"})
|
||||
|
||||
# Check response
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "tables" in data
|
||||
assert "People" in data["tables"]
|
||||
assert "Tasks" in data["tables"]
|
||||
|
||||
# Verify mock received correct request
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
assert len(log) >= 1
|
||||
assert log[-1]["method"] == "GET"
|
||||
assert "/tables" in log[-1]["path"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_describe_table(mcp_client, mock_grist_client):
|
||||
"""Test describe_table returns column information."""
|
||||
result = await mcp_client.call_tool(
|
||||
"describe_table",
|
||||
{"document": "test-doc", "table": "People"}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "columns" in data
|
||||
|
||||
column_ids = [c["id"] for c in data["columns"]]
|
||||
assert "Name" in column_ids
|
||||
assert "Age" in column_ids
|
||||
|
||||
# Verify API call
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
assert any("/columns" in entry["path"] for entry in log)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_records(mcp_client, mock_grist_client):
|
||||
"""Test get_records fetches records from table."""
|
||||
result = await mcp_client.call_tool(
|
||||
"get_records",
|
||||
{"document": "test-doc", "table": "People"}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "records" in data
|
||||
assert len(data["records"]) == 2
|
||||
assert data["records"][0]["Name"] == "Alice"
|
||||
|
||||
# Verify API call
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
assert any("/records" in entry["path"] and entry["method"] == "GET" for entry in log)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_query(mcp_client, mock_grist_client):
|
||||
"""Test sql_query executes SQL and returns results."""
|
||||
result = await mcp_client.call_tool(
|
||||
"sql_query",
|
||||
{"document": "test-doc", "query": "SELECT Name, Age FROM People"}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "records" in data
|
||||
assert len(data["records"]) >= 1
|
||||
|
||||
# Verify API call
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
assert any("/sql" in entry["path"] for entry in log)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_records(mcp_client, mock_grist_client):
|
||||
"""Test add_records sends correct payload to Grist."""
|
||||
new_records = [
|
||||
{"Name": "Charlie", "Age": 35, "Email": "charlie@example.com"}
|
||||
]
|
||||
|
||||
result = await mcp_client.call_tool(
|
||||
"add_records",
|
||||
{"document": "test-doc", "table": "People", "records": new_records}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "record_ids" in data
|
||||
assert len(data["record_ids"]) == 1
|
||||
|
||||
# Verify API call body
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
post_requests = [e for e in log if e["method"] == "POST" and "/records" in e["path"]]
|
||||
assert len(post_requests) >= 1
|
||||
assert post_requests[-1]["body"]["records"][0]["fields"]["Name"] == "Charlie"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_records(mcp_client, mock_grist_client):
|
||||
"""Test update_records sends correct payload to Grist."""
|
||||
updates = [
|
||||
{"id": 1, "fields": {"Age": 31}}
|
||||
]
|
||||
|
||||
result = await mcp_client.call_tool(
|
||||
"update_records",
|
||||
{"document": "test-doc", "table": "People", "records": updates}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "updated" in data
|
||||
|
||||
# Verify API call
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
patch_requests = [e for e in log if e["method"] == "PATCH" and "/records" in e["path"]]
|
||||
assert len(patch_requests) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_records(mcp_client, mock_grist_client):
|
||||
"""Test delete_records sends correct IDs to Grist."""
|
||||
result = await mcp_client.call_tool(
|
||||
"delete_records",
|
||||
{"document": "test-doc", "table": "People", "record_ids": [1, 2]}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "deleted" in data
|
||||
|
||||
# Verify API call
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
delete_requests = [e for e in log if "/data/delete" in e["path"]]
|
||||
assert len(delete_requests) >= 1
|
||||
assert delete_requests[-1]["body"] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table(mcp_client, mock_grist_client):
|
||||
"""Test create_table sends correct schema to Grist."""
|
||||
columns = [
|
||||
{"id": "Title", "type": "Text"},
|
||||
{"id": "Count", "type": "Int"},
|
||||
]
|
||||
|
||||
result = await mcp_client.call_tool(
|
||||
"create_table",
|
||||
{"document": "test-doc", "table_id": "NewTable", "columns": columns}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "table_id" in data
|
||||
|
||||
# Verify API call
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
post_tables = [e for e in log if e["method"] == "POST" and e["path"].endswith("/tables")]
|
||||
assert len(post_tables) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_column(mcp_client, mock_grist_client):
|
||||
"""Test add_column sends correct column definition."""
|
||||
result = await mcp_client.call_tool(
|
||||
"add_column",
|
||||
{
|
||||
"document": "test-doc",
|
||||
"table": "People",
|
||||
"column_id": "Phone",
|
||||
"column_type": "Text",
|
||||
}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "column_id" in data
|
||||
|
||||
# Verify API call
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
post_cols = [e for e in log if e["method"] == "POST" and "/columns" in e["path"]]
|
||||
assert len(post_cols) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_column(mcp_client, mock_grist_client):
|
||||
"""Test modify_column sends correct update."""
|
||||
result = await mcp_client.call_tool(
|
||||
"modify_column",
|
||||
{
|
||||
"document": "test-doc",
|
||||
"table": "People",
|
||||
"column_id": "Age",
|
||||
"type": "Numeric",
|
||||
}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "modified" in data
|
||||
|
||||
# Verify API call
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
patch_cols = [e for e in log if e["method"] == "PATCH" and "/columns/" in e["path"]]
|
||||
assert len(patch_cols) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_column(mcp_client, mock_grist_client):
|
||||
"""Test delete_column calls correct endpoint."""
|
||||
result = await mcp_client.call_tool(
|
||||
"delete_column",
|
||||
{
|
||||
"document": "test-doc",
|
||||
"table": "People",
|
||||
"column_id": "Email",
|
||||
}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "deleted" in data
|
||||
|
||||
# Verify API call
|
||||
log = mock_grist_client.get("/_test/requests").json()
|
||||
delete_cols = [e for e in log if e["method"] == "DELETE" and "/columns/" in e["path"]]
|
||||
assert len(delete_cols) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_document_fails(mcp_client):
|
||||
"""Test that accessing unauthorized document returns error."""
|
||||
result = await mcp_client.call_tool(
|
||||
"list_tables",
|
||||
{"document": "unauthorized-doc"}
|
||||
)
|
||||
|
||||
assert "error" in result.content[0].text.lower() or "authorization" in result.content[0].text.lower()
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/integration/test_tools_integration.py
|
||||
git commit -m "feat: add tool integration tests with Grist API validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Create Makefile
|
||||
|
||||
**Files:**
|
||||
- Create: `Makefile`
|
||||
|
||||
**Step 1: Create Makefile**
|
||||
|
||||
Create `Makefile`:
|
||||
|
||||
```makefile
|
||||
.PHONY: help test build integration-up integration-test integration-down integration pre-deploy clean
|
||||
|
||||
# Default target
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
test: ## Run unit tests
|
||||
uv run pytest tests/ -v --ignore=tests/integration
|
||||
|
||||
build: ## Build Docker images for testing
|
||||
docker compose -f docker-compose.test.yaml build
|
||||
|
||||
integration-up: ## Start integration test containers
|
||||
docker compose -f docker-compose.test.yaml up -d
|
||||
@echo "Waiting for services to be ready..."
|
||||
@sleep 5
|
||||
|
||||
integration-test: ## Run integration tests (containers must be up)
|
||||
uv run pytest tests/integration/ -v
|
||||
|
||||
integration-down: ## Stop and remove test containers
|
||||
docker compose -f docker-compose.test.yaml down -v
|
||||
|
||||
integration: build integration-up ## Full integration cycle (build, up, test, down)
|
||||
@$(MAKE) integration-test || ($(MAKE) integration-down && exit 1)
|
||||
@$(MAKE) integration-down
|
||||
|
||||
pre-deploy: test integration ## Full pre-deployment pipeline (unit tests + integration)
|
||||
@echo "Pre-deployment checks passed!"
|
||||
|
||||
clean: ## Remove all test artifacts and containers
|
||||
docker compose -f docker-compose.test.yaml down -v --rmi local 2>/dev/null || true
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true
|
||||
```
|
||||
|
||||
**Step 2: Verify Makefile syntax**
|
||||
|
||||
Run: `make help`
|
||||
Expected: List of available targets with descriptions
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add Makefile
|
||||
git commit -m "feat: add Makefile for test orchestration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Run Full Pre-Deploy Pipeline
|
||||
|
||||
**Step 1: Run unit tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All unit tests pass
|
||||
|
||||
**Step 2: Run full pre-deploy**
|
||||
|
||||
Run: `make pre-deploy`
|
||||
Expected: Unit tests pass, Docker builds succeed, integration tests pass, containers cleaned up
|
||||
|
||||
**Step 3: Commit any fixes needed**
|
||||
|
||||
If any tests fail, fix them and commit:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: resolve integration test issues"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Files created:
|
||||
- `src/grist_mcp/main.py` - Modified with /health endpoint
|
||||
- `tests/integration/mock_grist/__init__.py`
|
||||
- `tests/integration/mock_grist/server.py`
|
||||
- `tests/integration/mock_grist/Dockerfile`
|
||||
- `tests/integration/mock_grist/requirements.txt`
|
||||
- `tests/integration/__init__.py`
|
||||
- `tests/integration/config.test.yaml`
|
||||
- `tests/integration/conftest.py`
|
||||
- `tests/integration/test_mcp_protocol.py`
|
||||
- `tests/integration/test_tools_integration.py`
|
||||
- `docker-compose.test.yaml`
|
||||
- `Makefile`
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
make help # Show all targets
|
||||
make test # Unit tests only
|
||||
make integration # Integration tests only
|
||||
make pre-deploy # Full pipeline
|
||||
make clean # Cleanup
|
||||
```
|
||||
Reference in New Issue
Block a user