580 lines
20 KiB
Python
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
|