"""Tests for U7: bitable CLI subcommands. Requires PostgreSQL — marked ``postgres``. Uses Typer's CliRunner. """ from __future__ import annotations import os from pathlib import Path import pytest from typer.testing import CliRunner from agentkit.cli.bitable import bitable_app pytestmark = pytest.mark.postgres runner = CliRunner() @pytest.fixture def db_env(monkeypatch: pytest.MonkeyPatch) -> None: """Ensure DATABASE_URL is set for CLI tests.""" url = os.environ.get("DATABASE_URL") or os.environ.get("AGENTKIT_DATABASE_URL") if not url: pytest.skip("PostgreSQL not available (set DATABASE_URL)") monkeypatch.setenv("DATABASE_URL", url) @pytest.fixture def clean_schema(monkeypatch: pytest.MonkeyPatch) -> None: """Drop and recreate bitable schema before each test.""" import asyncio url = os.environ.get("DATABASE_URL") or os.environ.get("AGENTKIT_DATABASE_URL") if not url: pytest.skip("PostgreSQL not available") from agentkit.bitable.db import BitableDB from sqlalchemy import text async def _clean(): db = BitableDB() await db.init() async with db.engine.begin() as conn: await conn.execute(text("DROP SCHEMA IF EXISTS bitable CASCADE")) await db.init() await db.close() asyncio.run(_clean()) # --------------------------------------------------------------------------- # list-tables # --------------------------------------------------------------------------- def test_list_tables_empty(db_env, clean_schema) -> None: result = runner.invoke(bitable_app, ["list-tables"]) assert result.exit_code == 0 assert "No tables found" in result.output def test_list_tables_after_create(db_env, clean_schema) -> None: # Create a table first runner.invoke(bitable_app, ["create-table", "--name", "TestTable"]) result = runner.invoke(bitable_app, ["list-tables"]) assert result.exit_code == 0 assert "TestTable" in result.output # --------------------------------------------------------------------------- # create-table # --------------------------------------------------------------------------- def test_create_table_success(db_env, clean_schema) -> None: result = runner.invoke( bitable_app, ["create-table", "--name", "MyTable", "--description", "A test table"], ) assert result.exit_code == 0 assert "Created table" in result.output assert "MyTable" in result.output assert "A test table" in result.output def test_create_table_minimal(db_env, clean_schema) -> None: result = runner.invoke(bitable_app, ["create-table", "--name", "Minimal"]) assert result.exit_code == 0 assert "Minimal" in result.output # --------------------------------------------------------------------------- # query # --------------------------------------------------------------------------- def test_query_table_not_found(db_env, clean_schema) -> None: result = runner.invoke(bitable_app, ["query", "--table", "nonexistent-id"]) assert result.exit_code == 1 assert "not found" in result.output def test_query_empty_table(db_env, clean_schema) -> None: # Create a table first create_result = runner.invoke(bitable_app, ["create-table", "--name", "Empty"]) assert create_result.exit_code == 0 # Extract table ID from output — it's on the "ID:" line lines = create_result.output.split("\n") table_id = None for line in lines: if "ID:" in line: # Extract the cyan-colored ID table_id = line.split("ID:")[1].strip().strip("[]").split(" ")[0] # Remove rich formatting table_id = table_id.replace("[cyan]", "").replace("[/cyan]", "") break assert table_id is not None, f"Could not extract table ID from: {create_result.output}" result = runner.invoke(bitable_app, ["query", "--table", table_id]) assert result.exit_code == 0 assert "No records found" in result.output def test_query_with_records(db_env, clean_schema) -> None: """Create table + field + records via service, then query via CLI.""" import asyncio from agentkit.bitable.db import BitableDB from agentkit.bitable.models import FieldType from agentkit.bitable.service import BitableService async def _setup(): db = BitableDB() await db.init() service = BitableService(db) table = await service.create_table(name="Data") field = await service.create_field( table_id=table.id, name="name", field_type=FieldType.text ) await service.create_record(table_id=table.id, values={field.id: "Alice"}) await service.create_record(table_id=table.id, values={field.id: "Bob"}) await db.close() return table.id table_id = asyncio.run(_setup()) result = runner.invoke(bitable_app, ["query", "--table", table_id, "--limit", "10"]) assert result.exit_code == 0 assert "Alice" in result.output assert "Bob" in result.output # --------------------------------------------------------------------------- # import-excel # --------------------------------------------------------------------------- def test_import_excel_file_not_found(db_env, clean_schema) -> None: result = runner.invoke( bitable_app, ["import-excel", "--file", "/nonexistent/file.xlsx"], ) assert result.exit_code == 1 assert "not found" in result.output def test_import_excel_success(db_env, clean_schema, tmp_path: Path) -> None: """Create a real xlsx file and import it.""" from openpyxl import Workbook wb = Workbook() ws = wb.active ws.title = "Sheet1" ws.append(["name", "age"]) ws.append(["Alice", 30]) ws.append(["Bob", 25]) xlsx_path = tmp_path / "test.xlsx" wb.save(xlsx_path) result = runner.invoke( bitable_app, ["import-excel", "--file", str(xlsx_path), "--table", "Imported"], ) assert result.exit_code == 0 assert "Created table" in result.output assert "Imported" in result.output assert "Imported 2 records" in result.output assert "name" in result.output assert "age" in result.output # --------------------------------------------------------------------------- # Error path: no DATABASE_URL # --------------------------------------------------------------------------- def test_no_database_url(monkeypatch: pytest.MonkeyPatch) -> None: """CLI should exit with clear error when DATABASE_URL is not set.""" monkeypatch.delenv("DATABASE_URL", raising=False) monkeypatch.delenv("AGENTKIT_DATABASE_URL", raising=False) result = runner.invoke(bitable_app, ["list-tables"]) assert result.exit_code == 1 assert "DATABASE_URL" in result.output