"""Tests for BitableTool (U4). Tests the full HTTP flow: BitableTool → bitable REST API → BitableService. Uses ``httpx.AsyncClient`` + ``ASGITransport`` so the tool's HTTP calls and the bitable DB share one event loop. Covers: - KTD11: X-Internal-Token auth (valid token accepted, invalid rejected) - Batch chunking: 1200 records → 3 HTTP requests (500+500+200) - Resume from partial failure - Three ingestion types: Excel, database, API collector - create_table, upsert_records, query_records """ from __future__ import annotations import io 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 from agentkit.tools.bitable_tool import BATCH_SIZE, BitableTool pytestmark = pytest.mark.postgres TEST_TOKEN = "test-internal-token-abc123" TEST_USER = {"user_id": "test-user", "username": "tester", "role": "member"} # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def app(bitable_service: BitableService) -> FastAPI: """Test app with bitable_service + internal token on app.state.""" app = FastAPI() app.state.bitable_service = bitable_service app.state.bitable_internal_token = TEST_TOKEN app.include_router(bitable_routes.router, prefix="/api/v1") # Override auth so JWT path also works (for non-internal-token tests) app.dependency_overrides[require_bitable_auth] = lambda: TEST_USER return app @pytest.fixture def app_no_override(bitable_service: BitableService) -> FastAPI: """App without auth override — tests real X-Internal-Token path.""" app = FastAPI() app.state.bitable_service = bitable_service app.state.bitable_internal_token = TEST_TOKEN app.include_router(bitable_routes.router, prefix="/api/v1") return app @pytest.fixture def app_no_token(bitable_service: BitableService) -> FastAPI: """App without internal token configured.""" app = FastAPI() app.state.bitable_service = bitable_service app.include_router(bitable_routes.router, prefix="/api/v1") app.dependency_overrides[require_bitable_auth] = lambda: TEST_USER return app def _make_client(app: FastAPI, token: str | None = None) -> httpx.AsyncClient: """Create an httpx AsyncClient backed by ASGITransport. If token is provided, the X-Internal-Token header is set as default on the client — mirroring how BitableTool._get_client configures it. """ base = "http://test/api/v1/bitable" transport = ASGITransport(app=app) headers: dict[str, str] = {} if token: headers["X-Internal-Token"] = token return httpx.AsyncClient(transport=transport, base_url=base, headers=headers) @pytest.fixture async def tool(app: FastAPI) -> BitableTool: """BitableTool pointing at the test app via ASGITransport. ponytail: We patch _client to use ASGITransport instead of real HTTP — this shares the event loop with the async DB fixtures. """ client = _make_client(app, token=TEST_TOKEN) t = BitableTool(base_url="http://test/api/v1/bitable", internal_token=TEST_TOKEN) t._client = client yield t await client.aclose() @pytest.fixture async def tool_no_token(app_no_token: FastAPI) -> BitableTool: """BitableTool without internal token.""" client = _make_client(app_no_token, token=None) t = BitableTool(base_url="http://test/api/v1/bitable", internal_token=None) t._client = client yield t await client.aclose() @pytest.fixture async def tool_real_auth(app_no_override: FastAPI) -> BitableTool: """BitableTool that sends real X-Internal-Token header (no auth override).""" client = _make_client(app_no_override, token=TEST_TOKEN) t = BitableTool(base_url="http://test/api/v1/bitable", internal_token=TEST_TOKEN) t._client = client yield t await client.aclose() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_xlsx(sheets: dict[str, list[list]]) -> bytes: """Create an in-memory .xlsx file.""" from openpyxl import Workbook wb = Workbook() wb.remove(wb.active) for name, rows in sheets.items(): ws = wb.create_sheet(title=name) for row in rows: ws.append(row) buf = io.BytesIO() wb.save(buf) return buf.getvalue() async def _setup_table_with_pk(tool: BitableTool, name: str = "T") -> tuple[str, str, str]: """Create a table with a text PK field and a number data field. Returns (table_id, pk_field_id, data_field_id). """ result = await tool.execute(action="create_table", table_name=name) assert result["success"], result table_id = result["table"]["id"] client = await tool._get_client() # Create PK field resp = await client.post( f"/tables/{table_id}/fields", json={"name": "id", "field_type": "text", "owner": "agent"}, ) resp.raise_for_status() pk_field_id = resp.json()["field"]["id"] # Create data field resp = await client.post( f"/tables/{table_id}/fields", json={"name": "val", "field_type": "number", "owner": "agent"}, ) resp.raise_for_status() data_field_id = resp.json()["field"]["id"] # Set PK resp = await client.patch(f"/tables/{table_id}", json={"primary_key_field_id": pk_field_id}) resp.raise_for_status() return table_id, pk_field_id, data_field_id # --------------------------------------------------------------------------- # create_table # --------------------------------------------------------------------------- async def test_create_table(tool: BitableTool) -> None: """create_table action creates a bitable table via HTTP.""" result = await tool.execute(action="create_table", table_name="MyTable") assert result["success"] is True assert result["table"]["name"] == "MyTable" async def test_create_table_missing_name(tool: BitableTool) -> None: """Missing table_name → error.""" result = await tool.execute(action="create_table") assert result["success"] is False assert "table_name" in result["error"] # --------------------------------------------------------------------------- # KTD11: Internal token auth # --------------------------------------------------------------------------- async def test_internal_token_accepted(tool_real_auth: BitableTool) -> None: """Valid X-Internal-Token → request succeeds (no JWT needed).""" result = await tool_real_auth.execute(action="create_table", table_name="Authed") assert result["success"] is True async def test_invalid_token_rejected(app_no_override: FastAPI) -> None: """Wrong X-Internal-Token → 401.""" transport = ASGITransport(app=app_no_override) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( "/api/v1/bitable/tables", json={"name": "X"}, headers={"X-Internal-Token": "wrong-token"}, ) assert resp.status_code == 401 async def test_no_auth_rejected(app_no_override: FastAPI) -> None: """No auth at all → 401.""" transport = ASGITransport(app=app_no_override) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post("/api/v1/bitable/tables", json={"name": "X"}) assert resp.status_code == 401 # --------------------------------------------------------------------------- # Batch chunking (BATCH_SIZE=500) # --------------------------------------------------------------------------- async def test_batch_upsert_1200_records(tool: BitableTool) -> None: """1200 records → 3 batches (500+500+200), all succeed.""" table_id, pk_fid, data_fid = await _setup_table_with_pk(tool) records = [{pk_fid: f"r{i}", data_fid: i * 10} for i in range(1200)] result = await tool.execute( action="upsert_records", table_id=table_id, records=records, primary_key_field_id=pk_fid, ) assert result["success"] is True assert result["successful_count"] == 1200 assert result["total"] == 1200 assert "errors" not in result async def test_batch_size_is_500() -> None: """Verify BATCH_SIZE constant is 500.""" assert BATCH_SIZE == 500 async def test_resume_from_partial_failure(tool: BitableTool) -> None: """resume_from skips already-successful records.""" table_id, pk_fid, data_fid = await _setup_table_with_pk(tool) # First, insert 500 records successfully batch1 = [{pk_fid: f"r{i}", data_fid: i} for i in range(500)] result1 = await tool.execute( action="upsert_records", table_id=table_id, records=batch1, primary_key_field_id=pk_fid, ) assert result1["successful_count"] == 500 # Now resume from 500 with the remaining 700 all_records = [{pk_fid: f"r{i}", data_fid: i} for i in range(1200)] remaining = all_records[500:] result2 = await tool.execute( action="upsert_records", table_id=table_id, records=remaining, primary_key_field_id=pk_fid, resume_from=0, # remaining is already sliced ) assert result2["successful_count"] == 700 # --------------------------------------------------------------------------- # query_records # --------------------------------------------------------------------------- async def test_query_records(tool: BitableTool) -> None: """query_records returns records from the table.""" table_id, pk_fid, data_fid = await _setup_table_with_pk(tool) # Insert some records await tool.execute( action="upsert_records", table_id=table_id, records=[{pk_fid: "a", data_fid: 1}, {pk_fid: "b", data_fid: 2}], primary_key_field_id=pk_fid, ) # Query result = await tool.execute(action="query_records", table_id=table_id) assert result["success"] is True assert len(result["records"]) == 2 async def test_query_records_with_limit(tool: BitableTool) -> None: """query_records with limit returns fewer records.""" table_id, pk_fid, data_fid = await _setup_table_with_pk(tool) await tool.execute( action="upsert_records", table_id=table_id, records=[{pk_fid: f"r{i}", data_fid: i} for i in range(10)], primary_key_field_id=pk_fid, ) result = await tool.execute(action="query_records", table_id=table_id, limit=5) assert result["success"] is True assert len(result["records"]) == 5 # --------------------------------------------------------------------------- # import_excel # --------------------------------------------------------------------------- async def test_import_excel_file(tool: BitableTool, tmp_path) -> None: """import_excel from file path → creates table + fields + records.""" xlsx_bytes = _make_xlsx({"Products": [["name", "price"], ["Widget", 9.99], ["Gadget", 19.99]]}) file_path = tmp_path / "test.xlsx" file_path.write_bytes(xlsx_bytes) result = await tool.execute(action="import_excel", file_path=str(file_path)) assert result["success"] is True sheet_result = result["sheets"][0] assert sheet_result["record_count"] == 2 assert sheet_result["field_count"] == 2 # Verify data was actually written table_id = sheet_result["table_id"] query = await tool.execute(action="query_records", table_id=table_id) assert len(query["records"]) == 2 async def test_import_excel_empty_sheet(tool: BitableTool, tmp_path) -> None: """Excel with only headers (no data rows) → table created, 0 records.""" xlsx_bytes = _make_xlsx({"Empty": [["col1", "col2"]]}) file_path = tmp_path / "empty.xlsx" file_path.write_bytes(xlsx_bytes) result = await tool.execute(action="import_excel", file_path=str(file_path)) assert result["success"] is True assert result["sheets"][0]["record_count"] == 0 assert result["sheets"][0]["field_count"] == 2 async def test_import_excel_missing_path(tool: BitableTool) -> None: """No file_path or file_url → error.""" result = await tool.execute(action="import_excel") assert result["success"] is False assert "file_path" in result["error"] or "file_url" in result["error"] # --------------------------------------------------------------------------- # collect_api # --------------------------------------------------------------------------- async def test_collect_api(tool: BitableTool) -> None: """collect_api transforms records via field_mapping and upserts.""" table_id, pk_fid, data_fid = await _setup_table_with_pk(tool) result = await tool.execute( action="collect_api", table_id=table_id, records=[ {"user_id": "u1", "score": 100}, {"user_id": "u2", "score": 200}, ], field_mapping={"user_id": pk_fid, "score": data_fid}, primary_key_field_id=pk_fid, ) assert result["success"] is True assert result["successful_count"] == 2 # Verify query = await tool.execute(action="query_records", table_id=table_id) assert len(query["records"]) == 2 async def test_collect_api_missing_fields(tool: BitableTool) -> None: """Missing required fields → error.""" result = await tool.execute(action="collect_api", records=[]) assert result["success"] is False # --------------------------------------------------------------------------- # Error handling # --------------------------------------------------------------------------- async def test_unknown_action(tool: BitableTool) -> None: """Unknown action → error.""" result = await tool.execute(action="bogus") assert result["success"] is False assert "Unknown action" in result["error"] async def test_query_nonexistent_table(tool: BitableTool) -> None: """Querying a non-existent table → error.""" result = await tool.execute(action="query_records", table_id="nonexistent-id") assert result["success"] is False # --------------------------------------------------------------------------- # Database ingestion (type mapping only — no real external DB needed) # --------------------------------------------------------------------------- def test_db_type_mapping_integer() -> None: """Integer type → 'number'.""" from sqlalchemy import Integer from agentkit.bitable.ingestion.database import infer_field_type assert infer_field_type(Integer()) == "number" assert infer_field_type(Integer) == "number" def test_db_type_mapping_varchar() -> None: """String type → 'text'.""" from sqlalchemy import String from agentkit.bitable.ingestion.database import infer_field_type assert infer_field_type(String(255)) == "text" def test_db_type_mapping_datetime() -> None: """DateTime type → 'date'.""" from sqlalchemy import DateTime from agentkit.bitable.ingestion.database import infer_field_type assert infer_field_type(DateTime()) == "date" def test_db_type_mapping_unknown_fallback() -> None: """Unknown type → 'text' (safe fallback).""" from agentkit.bitable.ingestion.database import infer_field_type class CustomType: pass assert infer_field_type(CustomType()) == "text" # --------------------------------------------------------------------------- # API collector transform # --------------------------------------------------------------------------- def test_transform_records_basic() -> None: """transform_records maps source keys to field IDs.""" from agentkit.bitable.ingestion.api_collector import transform_records result = transform_records( records=[{"name": "Alice", "age": 30, "extra": "dropped"}], field_mapping={"name": "fld_abc", "age": "fld_def"}, ) assert result == [{"fld_abc": "Alice", "fld_def": 30}] def test_transform_records_empty() -> None: """Empty records → empty result.""" from agentkit.bitable.ingestion.api_collector import transform_records assert transform_records([], {"a": "b"}) == [] assert transform_records([{"a": 1}], {}) == [] def test_transform_records_missing_keys() -> None: """Source keys not in mapping are silently dropped.""" from agentkit.bitable.ingestion.api_collector import transform_records result = transform_records( records=[{"a": 1, "b": 2}], field_mapping={"a": "fld_a"}, # b is not mapped ) assert result == [{"fld_a": 1}] # --------------------------------------------------------------------------- # U6: View & field CRUD actions (create_view, update_view, update_field, # delete_view) — agent parity with the REST API. # --------------------------------------------------------------------------- def test_action_enum_has_10_actions() -> None: """input_schema.action.enum lists all 10 actions (6 original + 4 new).""" tool = BitableTool(base_url="http://test/api/v1/bitable") actions = tool.input_schema["properties"]["action"]["enum"] assert len(actions) == 10 for new_action in ("create_view", "update_view", "update_field", "delete_view"): assert new_action in actions def test_execute_handlers_dict_has_10_actions() -> None: """execute() handlers dict contains all 10 action keys (KTD10).""" import re src = open( "src/agentkit/tools/bitable_tool.py", encoding="utf-8", ).read() handlers_match = re.search(r"handlers\s*=\s*\{([^}]*)\}", src, re.DOTALL) handler_keys = re.findall(r'"([a-z_]+)":\s*self\._', handlers_match.group(1)) assert len(handler_keys) == 10 for new_action in ("create_view", "update_view", "update_field", "delete_view"): assert new_action in handler_keys async def test_create_view_action(tool: BitableTool) -> None: """create_view action POSTs /tables/{id}/views with name + view_type + config.""" result = await tool.execute(action="create_table", table_name="VC") table_id = result["table"]["id"] resp = await tool.execute( action="create_view", table_id=table_id, name="Kanban Plan", view_type="kanban", config={"group_by": [{"field_id": "fld_x", "direction": "asc"}]}, ) assert resp["success"] is True assert resp["view"]["name"] == "Kanban Plan" assert resp["view"]["view_type"] == "kanban" async def test_create_view_defaults_to_grid(tool: BitableTool) -> None: """create_view without view_type defaults to grid.""" result = await tool.execute(action="create_table", table_name="VG") table_id = result["table"]["id"] resp = await tool.execute(action="create_view", table_id=table_id, name="Default") assert resp["success"] is True assert resp["view"]["view_type"] == "grid" async def test_create_view_missing_table_id(tool: BitableTool) -> None: """Missing table_id → error.""" resp = await tool.execute(action="create_view", name="x") assert resp["success"] is False assert "table_id" in resp["error"] async def test_update_view_action(tool: BitableTool) -> None: """update_view action PATCHes /views/{id} with name + config.""" result = await tool.execute(action="create_table", table_name="VU") table_id = result["table"]["id"] view_id = ( await tool.execute(action="create_view", table_id=table_id, name="Old") )["view"]["id"] resp = await tool.execute( action="update_view", view_id=view_id, name="Renamed", config={"group_by": [{"field_id": "fld_a", "direction": "asc"}]}, ) assert resp["success"] is True assert resp["view"]["name"] == "Renamed" async def test_update_view_missing_view_id(tool: BitableTool) -> None: """Missing view_id → error.""" resp = await tool.execute(action="update_view", name="x") assert resp["success"] is False assert "view_id" in resp["error"] async def test_update_field_action(tool: BitableTool) -> None: """update_field action PATCHes /fields/{id} (equivalent to REST PATCH /fields).""" result = await tool.execute(action="create_table", table_name="FU") table_id = result["table"]["id"] client = await tool._get_client() field_id = ( await client.post( f"/tables/{table_id}/fields", json={"name": "col", "field_type": "text", "owner": "user"}, ) ).json()["field"]["id"] resp = await tool.execute( action="update_field", field_id=field_id, name="renamed_col", config={"description": "updated"}, ) assert resp["success"] is True assert resp["field"]["name"] == "renamed_col" async def test_update_field_missing_field_id(tool: BitableTool) -> None: """Missing field_id → error.""" resp = await tool.execute(action="update_field", name="x") assert resp["success"] is False assert "field_id" in resp["error"] async def test_delete_view_action(tool: BitableTool) -> None: """delete_view action DELETEs /views/{id}; last-view protection applies.""" result = await tool.execute(action="create_table", table_name="VD") table_id = result["table"]["id"] v1 = (await tool.execute(action="create_view", table_id=table_id, name="v1"))["view"]["id"] await tool.execute(action="create_view", table_id=table_id, name="v2") resp = await tool.execute(action="delete_view", view_id=v1) assert resp["success"] is True assert resp["deleted"] is True async def test_delete_view_action_409_on_last_view(tool: BitableTool) -> None: """delete_view on the last view → HTTP 409 surfaced as error.""" result = await tool.execute(action="create_table", table_name="VL") table_id = result["table"]["id"] only = (await tool.execute(action="create_view", table_id=table_id, name="only"))["view"]["id"] resp = await tool.execute(action="delete_view", view_id=only) assert resp["success"] is False assert "409" in resp["error"] async def test_delete_view_missing_view_id(tool: BitableTool) -> None: """Missing view_id → error.""" resp = await tool.execute(action="delete_view") assert resp["success"] is False assert "view_id" in resp["error"] async def test_create_view_with_r3_r4_config(tool: BitableTool) -> None: """create_view forwards group_by + conditional_formatting config (R3/R4 parity).""" result = await tool.execute(action="create_table", table_name="R34") table_id = result["table"]["id"] client = await tool._get_client() # Create a field so group_by can reference a real field id. fid = ( await client.post( f"/tables/{table_id}/fields", json={"name": "status", "field_type": "select", "owner": "user"}, ) ).json()["field"]["id"] resp = await tool.execute( action="create_view", table_id=table_id, name="GroupedView", config={ "group_by": [{"field_id": fid, "direction": "asc"}], "conditional_formatting": [ { "field_id": fid, "operator": "equals", "value": "done", "color_key": "green", } ], }, ) assert resp["success"] is True cfg = resp["view"]["config"] assert len(cfg["group_by"]) == 1 assert cfg["group_by"][0]["field_id"] == fid assert cfg["conditional_formatting"][0]["color_key"] == "green" # --------------------------------------------------------------------------- # X-Internal-Token transparent passthrough on the 4 new actions (KTD11) # --------------------------------------------------------------------------- async def test_new_actions_internal_token_passthrough( tool_real_auth: BitableTool, ) -> None: """X-Internal-Token bypasses ownership for all 4 new actions (KTD11).""" # create_table (existing action) — establishes an admin-owned table. result = await tool_real_auth.execute(action="create_table", table_name="TokenT") table_id = result["table"]["id"] # create_view via the new action. v = await tool_real_auth.execute( action="create_view", table_id=table_id, name="tv1" ) assert v["success"] is True view_id = v["view"]["id"] # update_view via the new action. uv = await tool_real_auth.execute(action="update_view", view_id=view_id, name="tv1-renamed") assert uv["success"] is True # update_field: create a field first via the tool's HTTP client, then update. client = await tool_real_auth._get_client() fid = ( await client.post( f"/tables/{table_id}/fields", json={"name": "c", "field_type": "text", "owner": "user"}, ) ).json()["field"]["id"] uf = await tool_real_auth.execute(action="update_field", field_id=fid, name="c2") assert uf["success"] is True # delete_view: add a second view so last-view protection doesn't block. await tool_real_auth.execute(action="create_view", table_id=table_id, name="tv2") dv = await tool_real_auth.execute(action="delete_view", view_id=view_id) assert dv["success"] is True async def test_delete_view_internal_token_404_when_missing( tool_real_auth: BitableTool, ) -> None: """X-Internal-Token calling delete_view on a non-existent view → 404 (no silent success).""" resp = await tool_real_auth.execute(action="delete_view", view_id="no-such-view") assert resp["success"] is False assert "404" in resp["error"]