From 7acd602ffd5ad3d67df40a9cb469440275ce2c20 Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 30 Dec 2025 11:34:21 -0500 Subject: [PATCH] feat: add mock Grist server for integration testing --- tests/integration/mock_grist/Dockerfile | 13 ++ tests/integration/mock_grist/__init__.py | 0 tests/integration/mock_grist/requirements.txt | 2 + tests/integration/mock_grist/server.py | 217 ++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 tests/integration/mock_grist/Dockerfile create mode 100644 tests/integration/mock_grist/__init__.py create mode 100644 tests/integration/mock_grist/requirements.txt create mode 100644 tests/integration/mock_grist/server.py diff --git a/tests/integration/mock_grist/Dockerfile b/tests/integration/mock_grist/Dockerfile new file mode 100644 index 0000000..96017d9 --- /dev/null +++ b/tests/integration/mock_grist/Dockerfile @@ -0,0 +1,13 @@ +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"] diff --git a/tests/integration/mock_grist/__init__.py b/tests/integration/mock_grist/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/mock_grist/requirements.txt b/tests/integration/mock_grist/requirements.txt new file mode 100644 index 0000000..11a1360 --- /dev/null +++ b/tests/integration/mock_grist/requirements.txt @@ -0,0 +1,2 @@ +starlette>=0.41.0 +uvicorn>=0.32.0 diff --git a/tests/integration/mock_grist/server.py b/tests/integration/mock_grist/server.py new file mode 100644 index 0000000..7731f14 --- /dev/null +++ b/tests/integration/mock_grist/server.py @@ -0,0 +1,217 @@ +"""Mock Grist API server for integration testing.""" + +import json +import logging +import os +from datetime import datetime, timezone + +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.now(timezone.utc).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)