fischer-agentkit/tests/unit/bitable/test_routes.py

580 lines
20 KiB
Python

"""Tests for bitable REST API routes (U2).
Requires PostgreSQL — marked ``postgres``. Uses ``httpx.AsyncClient`` with
``ASGITransport`` so the async DB engine and the HTTP client share one event
loop (TestClient runs in a separate thread/loop, which breaks asyncpg's
loop-bound connections).
"""
from __future__ import annotations
import json
from typing import Any
import httpx
import pytest
from fastapi import FastAPI
from httpx import ASGITransport
from agentkit.bitable.service import BitableService
from agentkit.server.routes import bitable as bitable_routes
from agentkit.server.routes.bitable import require_bitable_auth
pytestmark = pytest.mark.postgres
TEST_USER_ID = "test-user-id"
def _make_test_user() -> dict[str, Any]:
return {"user_id": TEST_USER_ID, "username": "testuser", "role": "member"}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def app(bitable_service: BitableService) -> FastAPI:
"""Test app with bitable_service on app.state and auth bypassed."""
app = FastAPI()
app.state.bitable_service = bitable_service
app.include_router(bitable_routes.router, prefix="/api/v1")
app.dependency_overrides[require_bitable_auth] = lambda: _make_test_user()
return app
@pytest.fixture
def unauth_app(bitable_service: BitableService) -> FastAPI:
"""App without auth override — simulates unauthenticated requests."""
app = FastAPI()
app.state.bitable_service = bitable_service
app.include_router(bitable_routes.router, prefix="/api/v1")
return app
@pytest.fixture
def no_service_app() -> FastAPI:
"""App without bitable_service on state — simulates uninitialized subsystem."""
app = FastAPI()
app.include_router(bitable_routes.router, prefix="/api/v1")
app.dependency_overrides[require_bitable_auth] = lambda: _make_test_user()
return app
@pytest.fixture
async def client(app: FastAPI) -> httpx.AsyncClient:
"""Async HTTP client — shares event loop with async DB fixtures."""
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def unauth_client(unauth_app: FastAPI) -> httpx.AsyncClient:
transport = ASGITransport(app=unauth_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def no_service_client(no_service_app: FastAPI) -> httpx.AsyncClient:
transport = ASGITransport(app=no_service_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
# ---------------------------------------------------------------------------
# Auth + service availability
# ---------------------------------------------------------------------------
async def test_create_table_requires_auth(unauth_client: httpx.AsyncClient) -> None:
"""No auth → 401."""
resp = await unauth_client.post("/api/v1/bitable/tables", json={"name": "T"})
assert resp.status_code == 401
async def test_endpoint_returns_503_when_service_unavailable(
no_service_client: httpx.AsyncClient,
) -> None:
"""No service on app.state → 503."""
resp = await no_service_client.post("/api/v1/bitable/tables", json={"name": "T"})
assert resp.status_code == 503
# ---------------------------------------------------------------------------
# Tables CRUD
# ---------------------------------------------------------------------------
async def test_create_table_success(client: httpx.AsyncClient) -> None:
resp = await client.post(
"/api/v1/bitable/tables", json={"name": "Orders", "description": "desc"}
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["table"]["name"] == "Orders"
assert data["table"]["description"] == "desc"
assert "id" in data["table"]
async def test_list_tables_returns_created(client: httpx.AsyncClient) -> None:
for name in ("A", "B", "C"):
await client.post("/api/v1/bitable/tables", json={"name": name})
resp = await client.get("/api/v1/bitable/tables")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert len(data["tables"]) == 3
names = {t["name"] for t in data["tables"]}
assert names == {"A", "B", "C"}
async def test_get_table_404_when_missing(client: httpx.AsyncClient) -> None:
resp = await client.get("/api/v1/bitable/tables/nonexistent-id")
assert resp.status_code == 404
async def test_update_table_success(client: httpx.AsyncClient) -> None:
create_resp = await client.post("/api/v1/bitable/tables", json={"name": "Old"})
table_id = create_resp.json()["table"]["id"]
resp = await client.patch(f"/api/v1/bitable/tables/{table_id}", json={"name": "New"})
assert resp.status_code == 200
assert resp.json()["table"]["name"] == "New"
async def test_delete_table_success(client: httpx.AsyncClient) -> None:
create_resp = await client.post("/api/v1/bitable/tables", json={"name": "T"})
table_id = create_resp.json()["table"]["id"]
resp = await client.delete(f"/api/v1/bitable/tables/{table_id}")
assert resp.status_code == 200
assert resp.json()["success"] is True
# Verify gone
assert (await client.get(f"/api/v1/bitable/tables/{table_id}")).status_code == 404
# ---------------------------------------------------------------------------
# Fields CRUD + dependency check (409)
# ---------------------------------------------------------------------------
async def test_create_field_success(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
resp = await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "title", "field_type": "text", "owner": "agent"},
)
assert resp.status_code == 200
data = resp.json()
assert data["field"]["name"] == "title"
assert data["field"]["field_type"] == "text"
assert data["field"]["owner"] == "agent"
async def test_list_fields(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
for name in ("f1", "f2"):
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": name, "field_type": "text"},
)
resp = await client.get(f"/api/v1/bitable/tables/{table_id}/fields")
assert resp.status_code == 200
assert len(resp.json()["fields"]) == 2
async def test_delete_field_no_deps(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
field_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "f", "field_type": "text"},
)
).json()["field"]["id"]
resp = await client.delete(f"/api/v1/bitable/fields/{field_id}")
assert resp.status_code == 200
async def test_delete_field_returns_409_when_referenced_by_formula(
client: httpx.AsyncClient,
) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
source_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "src", "field_type": "number"},
)
).json()["field"]["id"]
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={
"name": "calc",
"field_type": "formula",
"config": {"formula_expr": f"={source_id} * 2"},
},
)
resp = await client.delete(f"/api/v1/bitable/fields/{source_id}")
assert resp.status_code == 409
detail = resp.json()["detail"]
assert "dependencies" in detail
assert "formula_fields" in detail["dependencies"]
async def test_delete_field_force_cascades(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
source_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "src", "field_type": "number", "owner": "agent"},
)
).json()["field"]["id"]
# Create a record with the source field
await client.post(
f"/api/v1/bitable/tables/{table_id}/records",
json={"records": [{source_id: 42}]},
)
resp = await client.delete(f"/api/v1/bitable/fields/{source_id}?force=true")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Records CRUD + cursor pagination
# ---------------------------------------------------------------------------
async def test_create_records_batch(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
resp = await client.post(
f"/api/v1/bitable/tables/{table_id}/records",
json={"records": [{"a": 1}, {"a": 2}, {"a": 3}]},
)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 3
assert len(data["records"]) == 3
async def test_list_records_cursor_pagination(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
await client.post(
f"/api/v1/bitable/tables/{table_id}/records",
json={"records": [{"i": i} for i in range(5)]},
)
# Page 1
resp = await client.get(f"/api/v1/bitable/tables/{table_id}/records?limit=2")
assert resp.status_code == 200
data = resp.json()
assert len(data["records"]) == 2
assert data["next_cursor"] is not None
# Page 2
resp2 = await client.get(
f"/api/v1/bitable/tables/{table_id}/records?limit=2&cursor={data['next_cursor']}"
)
data2 = resp2.json()
assert len(data2["records"]) == 2
# No overlap
ids1 = {r["id"] for r in data["records"]}
ids2 = {r["id"] for r in data2["records"]}
assert ids1.isdisjoint(ids2)
async def test_list_records_with_filters(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
num_field_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "amt", "field_type": "number", "owner": "agent"},
)
).json()["field"]["id"]
await client.post(
f"/api/v1/bitable/tables/{table_id}/records",
json={"records": [{num_field_id: 10}, {num_field_id: 50}, {num_field_id: 100}]},
)
filters = json.dumps([{"field_id": num_field_id, "op": "gt", "value": 40}])
resp = await client.get(
f"/api/v1/bitable/tables/{table_id}/records", params={"filters": filters}
)
assert resp.status_code == 200
data = resp.json()
assert len(data["records"]) == 2 # 50 and 100
async def test_update_record(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
record_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/records", json={"records": [{"a": 1}]}
)
).json()["records"][0]["id"]
resp = await client.patch(f"/api/v1/bitable/records/{record_id}", json={"values": {"a": 99}})
assert resp.status_code == 200
assert resp.json()["record"]["values"]["a"] == 99
async def test_delete_single_record(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
record_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/records", json={"records": [{"a": 1}]}
)
).json()["records"][0]["id"]
resp = await client.delete(f"/api/v1/bitable/records/{record_id}")
assert resp.status_code == 200
# Verify gone
resp2 = await client.get(f"/api/v1/bitable/tables/{table_id}/records")
assert len(resp2.json()["records"]) == 0
# ---------------------------------------------------------------------------
# Upsert endpoint (KTD8)
# ---------------------------------------------------------------------------
async def test_upsert_inserts_then_updates(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
pk_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "id", "field_type": "text", "owner": "agent"},
)
).json()["field"]["id"]
data_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "data", "field_type": "text", "owner": "agent"},
)
).json()["field"]["id"]
await client.patch(f"/api/v1/bitable/tables/{table_id}", json={"primary_key_field_id": pk_id})
# First: insert
resp = await client.post(
f"/api/v1/bitable/tables/{table_id}/upsert",
json={
"records": [{pk_id: "r1", data_id: "v1"}],
"primary_key_field_id": pk_id,
},
)
assert resp.status_code == 200
assert resp.json()["inserted"] == 1
assert resp.json()["updated"] == 0
# Second: update
resp2 = await client.post(
f"/api/v1/bitable/tables/{table_id}/upsert",
json={
"records": [{pk_id: "r1", data_id: "v2"}],
"primary_key_field_id": pk_id,
},
)
assert resp2.status_code == 200
assert resp2.json()["inserted"] == 0
assert resp2.json()["updated"] == 1
# Verify value
records = (await client.get(f"/api/v1/bitable/tables/{table_id}/records")).json()["records"]
assert len(records) == 1
assert records[0]["values"][data_id] == "v2"
async def test_upsert_preserves_user_columns(client: httpx.AsyncClient) -> None:
"""KTD8 via API: upsert updates agent columns, user columns untouched."""
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
pk_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "id", "field_type": "text", "owner": "agent"},
)
).json()["field"]["id"]
agent_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "agent_data", "field_type": "text", "owner": "agent"},
)
).json()["field"]["id"]
user_id = (
await client.post(
f"/api/v1/bitable/tables/{table_id}/fields",
json={"name": "user_data", "field_type": "text", "owner": "user"},
)
).json()["field"]["id"]
await client.patch(f"/api/v1/bitable/tables/{table_id}", json={"primary_key_field_id": pk_id})
# Insert with both agent and user values
await client.post(
f"/api/v1/bitable/tables/{table_id}/upsert",
json={
"records": [{pk_id: "r1", agent_id: "a1", user_id: "u1"}],
"primary_key_field_id": pk_id,
},
)
# Manually set user column (simulating user edit via PATCH)
records = (await client.get(f"/api/v1/bitable/tables/{table_id}/records")).json()["records"]
rec_id = records[0]["id"]
await client.patch(
f"/api/v1/bitable/records/{rec_id}",
json={"values": {**records[0]["values"], user_id: "USER_EDITED"}},
)
# Second upsert: tries to change user column — should be ignored
await client.post(
f"/api/v1/bitable/tables/{table_id}/upsert",
json={
"records": [{pk_id: "r1", agent_id: "a2", user_id: "SHOULD_NOT_APPLY"}],
"primary_key_field_id": pk_id,
},
)
records = (await client.get(f"/api/v1/bitable/tables/{table_id}/records")).json()["records"]
assert len(records) == 1
assert records[0]["values"][agent_id] == "a2" # updated
assert records[0]["values"][user_id] == "USER_EDITED" # preserved
# ---------------------------------------------------------------------------
# Views CRUD
# ---------------------------------------------------------------------------
async def test_create_view_success(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
resp = await client.post(
f"/api/v1/bitable/tables/{table_id}/views",
json={"name": "Grid View", "view_type": "grid", "config": {}},
)
assert resp.status_code == 200
assert resp.json()["view"]["name"] == "Grid View"
assert resp.json()["view"]["view_type"] == "grid"
async def test_list_views(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
for name in ("v1", "v2"):
await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": name})
resp = await client.get(f"/api/v1/bitable/tables/{table_id}/views")
assert resp.status_code == 200
assert len(resp.json()["views"]) == 2
async def test_update_view(client: httpx.AsyncClient) -> None:
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
"id"
]
view_id = (
await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "Old"})
).json()["view"]["id"]
resp = await client.patch(f"/api/v1/bitable/views/{view_id}", json={"name": "New"})
assert resp.status_code == 200
assert resp.json()["view"]["name"] == "New"
# ---------------------------------------------------------------------------
# Formula validation (U5b)
# ---------------------------------------------------------------------------
async def test_validate_formula_valid(client: httpx.AsyncClient) -> None:
"""Valid formula returns valid=true."""
resp = await client.post(
"/api/v1/bitable/fields/validate-formula",
json={"formula": "1 + 2"},
)
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is True
assert "error" not in data
async def test_validate_formula_with_field_ref(client: httpx.AsyncClient) -> None:
"""Formula with field reference is valid syntax."""
resp = await client.post(
"/api/v1/bitable/fields/validate-formula",
json={"formula": "{field_abc} + 1"},
)
assert resp.status_code == 200
assert resp.json()["valid"] is True
async def test_validate_formula_with_function(client: httpx.AsyncClient) -> None:
"""Formula with built-in function is valid."""
resp = await client.post(
"/api/v1/bitable/fields/validate-formula",
json={"formula": "SUM({f1}) + AVG({f2})"},
)
assert resp.status_code == 200
assert resp.json()["valid"] is True
async def test_validate_formula_syntax_error(client: httpx.AsyncClient) -> None:
"""Syntax error returns valid=false with error message."""
resp = await client.post(
"/api/v1/bitable/fields/validate-formula",
json={"formula": "1 +"},
)
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert "error" in data
async def test_validate_formula_security_error(client: httpx.AsyncClient) -> None:
"""Dangerous constructs (import) are rejected."""
resp = await client.post(
"/api/v1/bitable/fields/validate-formula",
json={"formula": "__import__('os')"},
)
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert "error" in data
async def test_validate_formula_unknown_function(client: httpx.AsyncClient) -> None:
"""Unknown function is rejected."""
resp = await client.post(
"/api/v1/bitable/fields/validate-formula",
json={"formula": "UNKNOWN_FUNC(1)"},
)
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert "error" in data
async def test_validate_formula_requires_auth(unauth_client: httpx.AsyncClient) -> None:
"""No auth → 401."""
resp = await unauth_client.post(
"/api/v1/bitable/fields/validate-formula",
json={"formula": "1 + 2"},
)
assert resp.status_code == 401