refactor: organize tests into unit/ and integration/ directories

Move unit tests from tests/ to tests/unit/ for clearer separation
from integration tests. Update pyproject.toml testpaths and Makefile
test target to reflect the new structure.
This commit is contained in:
2025-12-30 17:38:46 -05:00
parent c57e71b92a
commit e235e998e4
11 changed files with 2 additions and 2 deletions

0
tests/unit/__init__.py Normal file
View File

99
tests/unit/test_auth.py Normal file
View File

@@ -0,0 +1,99 @@
import pytest
from grist_mcp.auth import Authenticator, AuthError, Permission
from grist_mcp.config import Config, Document, Token, TokenScope
@pytest.fixture
def sample_config():
return Config(
documents={
"budget": Document(
url="https://grist.example.com",
doc_id="abc123",
api_key="doc-api-key",
),
"expenses": Document(
url="https://grist.example.com",
doc_id="def456",
api_key="doc-api-key",
),
},
tokens=[
Token(
token="valid-token",
name="test-agent",
scope=[
TokenScope(document="budget", permissions=["read", "write"]),
TokenScope(document="expenses", permissions=["read"]),
],
),
],
)
def test_authenticate_valid_token(sample_config):
auth = Authenticator(sample_config)
agent = auth.authenticate("valid-token")
assert agent.name == "test-agent"
assert agent.token == "valid-token"
def test_authenticate_invalid_token(sample_config):
auth = Authenticator(sample_config)
with pytest.raises(AuthError, match="Invalid token"):
auth.authenticate("bad-token")
def test_authorize_allowed_document_and_permission(sample_config):
auth = Authenticator(sample_config)
agent = auth.authenticate("valid-token")
# Should not raise
auth.authorize(agent, "budget", Permission.READ)
auth.authorize(agent, "budget", Permission.WRITE)
auth.authorize(agent, "expenses", Permission.READ)
def test_authorize_denied_document(sample_config):
auth = Authenticator(sample_config)
agent = auth.authenticate("valid-token")
with pytest.raises(AuthError, match="Document not in scope"):
auth.authorize(agent, "unknown-doc", Permission.READ)
def test_authorize_denied_permission(sample_config):
auth = Authenticator(sample_config)
agent = auth.authenticate("valid-token")
# expenses only has read permission
with pytest.raises(AuthError, match="Permission denied"):
auth.authorize(agent, "expenses", Permission.WRITE)
def test_get_accessible_documents(sample_config):
auth = Authenticator(sample_config)
agent = auth.authenticate("valid-token")
docs = auth.get_accessible_documents(agent)
assert len(docs) == 2
assert {"name": "budget", "permissions": ["read", "write"]} in docs
assert {"name": "expenses", "permissions": ["read"]} in docs
def test_get_document_returns_document(sample_config):
auth = Authenticator(sample_config)
doc = auth.get_document("budget")
assert doc.doc_id == "abc123"
def test_get_document_raises_on_unknown(sample_config):
auth = Authenticator(sample_config)
with pytest.raises(AuthError, match="Document 'unknown' not configured"):
auth.get_document("unknown")

86
tests/unit/test_config.py Normal file
View File

@@ -0,0 +1,86 @@
import pytest
from grist_mcp.config import load_config, Config, Document, Token, TokenScope
def test_load_config_parses_documents(tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
documents:
my-doc:
url: https://grist.example.com
doc_id: abc123
api_key: secret-key
tokens: []
""")
config = load_config(str(config_file))
assert "my-doc" in config.documents
doc = config.documents["my-doc"]
assert doc.url == "https://grist.example.com"
assert doc.doc_id == "abc123"
assert doc.api_key == "secret-key"
def test_load_config_parses_tokens(tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
documents:
budget:
url: https://grist.example.com
doc_id: abc123
api_key: key123
tokens:
- token: my-secret-token
name: test-agent
scope:
- document: budget
permissions: [read, write]
""")
config = load_config(str(config_file))
assert len(config.tokens) == 1
token = config.tokens[0]
assert token.token == "my-secret-token"
assert token.name == "test-agent"
assert len(token.scope) == 1
assert token.scope[0].document == "budget"
assert token.scope[0].permissions == ["read", "write"]
def test_load_config_substitutes_env_vars(tmp_path, monkeypatch):
monkeypatch.setenv("TEST_API_KEY", "env-secret-key")
config_file = tmp_path / "config.yaml"
config_file.write_text("""
documents:
my-doc:
url: https://grist.example.com
doc_id: abc123
api_key: ${TEST_API_KEY}
tokens: []
""")
config = load_config(str(config_file))
assert config.documents["my-doc"].api_key == "env-secret-key"
def test_load_config_raises_on_missing_env_var(tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
documents:
my-doc:
url: https://grist.example.com
doc_id: abc123
api_key: ${MISSING_VAR}
tokens: []
""")
with pytest.raises(ValueError, match="MISSING_VAR"):
load_config(str(config_file))

View File

@@ -0,0 +1,198 @@
import pytest
from pytest_httpx import HTTPXMock
from grist_mcp.grist_client import GristClient
from grist_mcp.config import Document
@pytest.fixture
def doc():
return Document(
url="https://grist.example.com",
doc_id="abc123",
api_key="test-api-key",
)
@pytest.fixture
def client(doc):
return GristClient(doc)
@pytest.mark.asyncio
async def test_list_tables(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables",
json={"tables": [{"id": "Table1"}, {"id": "Table2"}]},
)
tables = await client.list_tables()
assert tables == ["Table1", "Table2"]
@pytest.mark.asyncio
async def test_describe_table(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns",
json={
"columns": [
{"id": "Name", "fields": {"type": "Text", "formula": ""}},
{"id": "Amount", "fields": {"type": "Numeric", "formula": "$Price * $Qty"}},
]
},
)
columns = await client.describe_table("Table1")
assert len(columns) == 2
assert columns[0] == {"id": "Name", "type": "Text", "formula": ""}
assert columns[1] == {"id": "Amount", "type": "Numeric", "formula": "$Price * $Qty"}
@pytest.mark.asyncio
async def test_get_records(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables/Table1/records",
json={
"records": [
{"id": 1, "fields": {"Name": "Alice", "Amount": 100}},
{"id": 2, "fields": {"Name": "Bob", "Amount": 200}},
]
},
)
records = await client.get_records("Table1")
assert len(records) == 2
assert records[0] == {"id": 1, "Name": "Alice", "Amount": 100}
@pytest.mark.asyncio
async def test_add_records(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables/Table1/records",
method="POST",
json={"records": [{"id": 3}, {"id": 4}]},
)
ids = await client.add_records("Table1", [
{"Name": "Charlie", "Amount": 300},
{"Name": "Diana", "Amount": 400},
])
assert ids == [3, 4]
@pytest.mark.asyncio
async def test_update_records(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables/Table1/records",
method="PATCH",
json={},
)
# Should not raise
await client.update_records("Table1", [
{"id": 1, "fields": {"Amount": 150}},
])
@pytest.mark.asyncio
async def test_delete_records(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables/Table1/data/delete",
method="POST",
json={},
)
# Should not raise
await client.delete_records("Table1", [1, 2])
@pytest.mark.asyncio
async def test_sql_query(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/sql?q=SELECT+*+FROM+Table1",
method="GET",
json={
"statement": "SELECT * FROM Table1",
"records": [
{"fields": {"Name": "Alice", "Amount": 100}},
],
},
)
result = await client.sql_query("SELECT * FROM Table1")
assert result == [{"Name": "Alice", "Amount": 100}]
@pytest.mark.asyncio
async def test_create_table(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables",
method="POST",
json={"tables": [{"id": "NewTable"}]},
)
table_id = await client.create_table("NewTable", [
{"id": "Col1", "type": "Text"},
{"id": "Col2", "type": "Numeric"},
])
assert table_id == "NewTable"
@pytest.mark.asyncio
async def test_add_column(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns",
method="POST",
json={"columns": [{"id": "NewCol"}]},
)
col_id = await client.add_column("Table1", "NewCol", "Text", formula=None)
assert col_id == "NewCol"
@pytest.mark.asyncio
async def test_modify_column(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns/Amount",
method="PATCH",
json={},
)
# Should not raise
await client.modify_column("Table1", "Amount", type="Int", formula="$Price * $Qty")
@pytest.mark.asyncio
async def test_delete_column(client, httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns/OldCol",
method="DELETE",
json={},
)
# Should not raise
await client.delete_column("Table1", "OldCol")
# SQL validation tests
def test_sql_validation_rejects_non_select(client):
with pytest.raises(ValueError, match="Only SELECT queries are allowed"):
client._validate_sql_query("DROP TABLE users")
def test_sql_validation_rejects_multiple_statements(client):
with pytest.raises(ValueError, match="Multiple statements not allowed"):
client._validate_sql_query("SELECT * FROM users; DROP TABLE users")
def test_sql_validation_allows_trailing_semicolon(client):
# Should not raise
client._validate_sql_query("SELECT * FROM users;")

52
tests/unit/test_server.py Normal file
View File

@@ -0,0 +1,52 @@
import pytest
from mcp.types import ListToolsRequest
from grist_mcp.server import create_server
@pytest.mark.asyncio
async def test_create_server_registers_tools(tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
documents:
test-doc:
url: https://grist.example.com
doc_id: abc123
api_key: test-key
tokens:
- token: test-token
name: test-agent
scope:
- document: test-doc
permissions: [read, write, schema]
""")
server = create_server(str(config_file), token="test-token")
# Server should have tools registered
assert server is not None
# Get the list_tools handler and call it
handler = server.request_handlers.get(ListToolsRequest)
assert handler is not None
req = ListToolsRequest(method="tools/list")
result = await handler(req)
# Check tool names are registered
tool_names = [t.name for t in result.root.tools]
assert "list_documents" in tool_names
assert "list_tables" in tool_names
assert "describe_table" in tool_names
assert "get_records" in tool_names
assert "sql_query" in tool_names
assert "add_records" in tool_names
assert "update_records" in tool_names
assert "delete_records" in tool_names
assert "create_table" in tool_names
assert "add_column" in tool_names
assert "modify_column" in tool_names
assert "delete_column" in tool_names
# Should have all 12 tools
assert len(result.root.tools) == 12

View File

@@ -0,0 +1,29 @@
import pytest
from grist_mcp.tools.discovery import list_documents
from grist_mcp.auth import Agent
from grist_mcp.config import Token, TokenScope
@pytest.fixture
def agent():
token_obj = Token(
token="test-token",
name="test-agent",
scope=[
TokenScope(document="budget", permissions=["read", "write"]),
TokenScope(document="expenses", permissions=["read"]),
],
)
return Agent(token="test-token", name="test-agent", _token_obj=token_obj)
@pytest.mark.asyncio
async def test_list_documents_returns_accessible_docs(agent):
result = await list_documents(agent)
assert result == {
"documents": [
{"name": "budget", "permissions": ["read", "write"]},
{"name": "expenses", "permissions": ["read"]},
]
}

View File

@@ -0,0 +1,82 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from grist_mcp.tools.read import list_tables, describe_table, get_records, sql_query
from grist_mcp.auth import Authenticator, Agent, Permission
from grist_mcp.config import Config, Document, Token, TokenScope
@pytest.fixture
def config():
return Config(
documents={
"budget": Document(
url="https://grist.example.com",
doc_id="abc123",
api_key="key",
),
},
tokens=[
Token(
token="test-token",
name="test-agent",
scope=[TokenScope(document="budget", permissions=["read"])],
),
],
)
@pytest.fixture
def auth(config):
return Authenticator(config)
@pytest.fixture
def agent(auth):
return auth.authenticate("test-token")
@pytest.fixture
def mock_client():
client = AsyncMock()
client.list_tables.return_value = ["Table1", "Table2"]
client.describe_table.return_value = [
{"id": "Name", "type": "Text", "formula": ""},
]
client.get_records.return_value = [
{"id": 1, "Name": "Alice"},
]
client.sql_query.return_value = [{"Name": "Alice"}]
return client
@pytest.mark.asyncio
async def test_list_tables(agent, auth, mock_client):
result = await list_tables(agent, auth, "budget", client=mock_client)
assert result == {"tables": ["Table1", "Table2"]}
mock_client.list_tables.assert_called_once()
@pytest.mark.asyncio
async def test_describe_table(agent, auth, mock_client):
result = await describe_table(agent, auth, "budget", "Table1", client=mock_client)
assert result == {
"table": "Table1",
"columns": [{"id": "Name", "type": "Text", "formula": ""}],
}
@pytest.mark.asyncio
async def test_get_records(agent, auth, mock_client):
result = await get_records(agent, auth, "budget", "Table1", client=mock_client)
assert result == {"records": [{"id": 1, "Name": "Alice"}]}
@pytest.mark.asyncio
async def test_sql_query(agent, auth, mock_client):
result = await sql_query(agent, auth, "budget", "SELECT * FROM Table1", client=mock_client)
assert result == {"records": [{"Name": "Alice"}]}

View File

@@ -0,0 +1,109 @@
import pytest
from unittest.mock import AsyncMock
from grist_mcp.tools.schema import create_table, add_column, modify_column, delete_column
from grist_mcp.auth import Authenticator, AuthError
from grist_mcp.config import Config, Document, Token, TokenScope
@pytest.fixture
def config():
return Config(
documents={
"budget": Document(
url="https://grist.example.com",
doc_id="abc123",
api_key="key",
),
},
tokens=[
Token(
token="schema-token",
name="schema-agent",
scope=[TokenScope(document="budget", permissions=["read", "write", "schema"])],
),
Token(
token="write-token",
name="write-agent",
scope=[TokenScope(document="budget", permissions=["read", "write"])],
),
],
)
@pytest.fixture
def auth(config):
return Authenticator(config)
@pytest.fixture
def mock_client():
client = AsyncMock()
client.create_table.return_value = "NewTable"
client.add_column.return_value = "NewCol"
client.modify_column.return_value = None
client.delete_column.return_value = None
return client
@pytest.mark.asyncio
async def test_create_table(auth, mock_client):
agent = auth.authenticate("schema-token")
result = await create_table(
agent, auth, "budget", "NewTable",
columns=[{"id": "Name", "type": "Text"}],
client=mock_client,
)
assert result == {"table_id": "NewTable"}
@pytest.mark.asyncio
async def test_create_table_denied_without_schema(auth, mock_client):
agent = auth.authenticate("write-token")
with pytest.raises(AuthError, match="Permission denied"):
await create_table(
agent, auth, "budget", "NewTable",
columns=[{"id": "Name", "type": "Text"}],
client=mock_client,
)
@pytest.mark.asyncio
async def test_add_column(auth, mock_client):
agent = auth.authenticate("schema-token")
result = await add_column(
agent, auth, "budget", "Table1", "NewCol", "Text",
client=mock_client,
)
assert result == {"column_id": "NewCol"}
@pytest.mark.asyncio
async def test_modify_column(auth, mock_client):
agent = auth.authenticate("schema-token")
result = await modify_column(
agent, auth, "budget", "Table1", "Col1",
type="Int",
formula="$A + $B",
client=mock_client,
)
assert result == {"modified": True}
@pytest.mark.asyncio
async def test_delete_column(auth, mock_client):
agent = auth.authenticate("schema-token")
result = await delete_column(
agent, auth, "budget", "Table1", "OldCol",
client=mock_client,
)
assert result == {"deleted": True}

View File

@@ -0,0 +1,96 @@
import pytest
from unittest.mock import AsyncMock
from grist_mcp.tools.write import add_records, update_records, delete_records
from grist_mcp.auth import Authenticator, AuthError
from grist_mcp.config import Config, Document, Token, TokenScope
@pytest.fixture
def config():
return Config(
documents={
"budget": Document(
url="https://grist.example.com",
doc_id="abc123",
api_key="key",
),
},
tokens=[
Token(
token="write-token",
name="write-agent",
scope=[TokenScope(document="budget", permissions=["read", "write"])],
),
Token(
token="read-token",
name="read-agent",
scope=[TokenScope(document="budget", permissions=["read"])],
),
],
)
@pytest.fixture
def auth(config):
return Authenticator(config)
@pytest.fixture
def mock_client():
client = AsyncMock()
client.add_records.return_value = [1, 2]
client.update_records.return_value = None
client.delete_records.return_value = None
return client
@pytest.mark.asyncio
async def test_add_records(auth, mock_client):
agent = auth.authenticate("write-token")
result = await add_records(
agent, auth, "budget", "Table1",
records=[{"Name": "Alice"}, {"Name": "Bob"}],
client=mock_client,
)
assert result == {"inserted_ids": [1, 2]}
@pytest.mark.asyncio
async def test_add_records_denied_without_write(auth, mock_client):
agent = auth.authenticate("read-token")
with pytest.raises(AuthError, match="Permission denied"):
await add_records(
agent, auth, "budget", "Table1",
records=[{"Name": "Alice"}],
client=mock_client,
)
@pytest.mark.asyncio
async def test_update_records(auth, mock_client):
agent = auth.authenticate("write-token")
result = await update_records(
agent, auth, "budget", "Table1",
records=[{"id": 1, "fields": {"Name": "Updated"}}],
client=mock_client,
)
assert result == {"updated": True}
@pytest.mark.asyncio
async def test_delete_records(auth, mock_client):
agent = auth.authenticate("write-token")
result = await delete_records(
agent, auth, "budget", "Table1",
record_ids=[1, 2],
client=mock_client,
)
assert result == {"deleted": True}