"""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 INTERNAL_TOKEN = "internal-token-routes-abc" @pytest.fixture def internal_app(bitable_service: BitableService) -> FastAPI: """App with X-Internal-Token configured and NO auth override. Exercises the real ``require_bitable_auth`` path: the internal token yields a synthetic admin user that bypasses ownership (KTD11). """ app = FastAPI() app.state.bitable_service = bitable_service app.state.bitable_internal_token = INTERNAL_TOKEN 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 internal_client(internal_app: FastAPI) -> httpx.AsyncClient: """Client that sends X-Internal-Token (real auth path, no override).""" transport = ASGITransport(app=internal_app) async with httpx.AsyncClient( transport=transport, base_url="http://test", headers={"X-Internal-Token": INTERNAL_TOKEN}, ) 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" # --------------------------------------------------------------------------- # DELETE /views/{view_id} (U6) # --------------------------------------------------------------------------- async def test_delete_view_success(client: httpx.AsyncClient) -> None: """DELETE an existing view with >=2 views → 204 No Content.""" table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][ "id" ] # Create 2 views so the last-view protection does not trigger. v1 = ( await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "v1"}) ).json()["view"]["id"] await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "v2"}) resp = await client.delete(f"/api/v1/bitable/views/{v1}") assert resp.status_code == 204 assert resp.content == b"" # Confirm it's gone. views = (await client.get(f"/api/v1/bitable/tables/{table_id}/views")).json()["views"] assert all(v["id"] != v1 for v in views) async def test_delete_view_404_when_missing(client: httpx.AsyncClient) -> None: """DELETE a non-existent view → 404.""" resp = await client.delete("/api/v1/bitable/views/nonexistent-view-id") assert resp.status_code == 404 async def test_delete_view_404_when_not_owner( client: httpx.AsyncClient, bitable_service: BitableService ) -> None: """DELETE a view on a table owned by another user → 404 (not 403). Pattern 4 (IDOR): existence is never disclosed to a non-owner. """ # Table owned by a different user. other_table = await bitable_service.create_table(name="Other", owner_user_id="other-user") view = await bitable_service.create_view(other_table.id, name="v") resp = await client.delete(f"/api/v1/bitable/views/{view.id}") assert resp.status_code == 404 async def test_delete_view_409_when_last_view(client: httpx.AsyncClient) -> None: """DELETE the last remaining view of a table → 409 Conflict.""" table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][ "id" ] only_view = ( await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "only"}) ).json()["view"]["id"] resp = await client.delete(f"/api/v1/bitable/views/{only_view}") assert resp.status_code == 409 # The view is still present. views = (await client.get(f"/api/v1/bitable/tables/{table_id}/views")).json()["views"] assert len(views) == 1 async def test_delete_view_internal_token_passthrough(internal_client: httpx.AsyncClient) -> None: """X-Internal-Token bypasses ownership: DELETE succeeds on another user's table.""" # Create a table as the internal admin user; ownership is bypassed. table_id = ( await internal_client.post("/api/v1/bitable/tables", json={"name": "InternalT"}) ).json()["table"]["id"] v1 = ( await internal_client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "v1"}) ).json()["view"]["id"] await internal_client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "v2"}) resp = await internal_client.delete(f"/api/v1/bitable/views/{v1}") assert resp.status_code == 204 async def test_delete_view_internal_token_404_when_missing( internal_client: httpx.AsyncClient, ) -> None: """X-Internal-Token still gets 404 for a non-existent view (no silent success).""" resp = await internal_client.delete("/api/v1/bitable/views/does-not-exist") assert resp.status_code == 404 # --------------------------------------------------------------------------- # 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