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

320 lines
11 KiB
Python

"""Tests for bitable view config grouping validation (U5 / R4).
Layered tests:
1. Pure Pydantic validation via ``validate_view_config`` (no DB needed — always runs)
2. Route-level 422 via a mocked BitableService (no DB needed — always runs)
3. Round-trip via real PG service (skipped if PG unavailable)
The validator lives in ``agentkit.bitable.view_config`` and is called from
the PATCH /views route before ``service.update_view`` is invoked.
"""
from __future__ import annotations
from typing import Any
import httpx
import pytest
from fastapi import FastAPI
from httpx import ASGITransport
from pydantic import ValidationError
from agentkit.bitable.models import View, ViewType
from agentkit.bitable.view_config import (
MAX_GROUP_BY_FIELDS,
GroupByItem,
ViewConfigSchema,
ViewConfigValidationError,
validate_view_config,
)
from agentkit.server.routes import bitable as bitable_routes
from agentkit.server.routes.bitable import require_bitable_auth
TEST_USER_ID = "test-user-id"
# ---------------------------------------------------------------------------
# 1. Pure Pydantic validation — always runs (no DB)
# ---------------------------------------------------------------------------
def _gb(field_id: str, direction: str = "asc") -> dict[str, str]:
return {"field_id": field_id, "direction": direction}
def test_validate_accepts_empty_config() -> None:
"""Empty / None config is valid (no group_by to check)."""
validate_view_config(None)
validate_view_config({})
validate_view_config({"filters": []}) # non-U5 keys ignored
def test_validate_accepts_one_to_three_group_by_fields() -> None:
"""Spec: max 3 levels. 1, 2, 3 all accepted."""
for n in (1, 2, 3):
config = {"group_by": [_gb(f"f{i}") for i in range(n)]}
validate_view_config(config) # no raise
def test_validate_rejects_more_than_three_group_by_fields() -> None:
"""group_by with >3 fields raises ValidationError (422 at the route)."""
config = {"group_by": [_gb(f"f{i}") for i in range(MAX_GROUP_BY_FIELDS + 1)]}
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_rejects_invalid_direction() -> None:
"""direction must be 'asc' or 'desc' (Literal)."""
config = {"group_by": [_gb("f1", "sideways")]}
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_rejects_missing_field_id() -> None:
"""field_id is required."""
config = {"group_by": [{"direction": "asc"}]}
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_rejects_empty_field_id() -> None:
"""field_id must be non-empty (min_length=1)."""
config = {"group_by": [_gb("")]}
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_rejects_extra_keys_in_group_by_item() -> None:
"""extra='forbid' on GroupByItem — unknown keys rejected."""
config = {"group_by": [{"field_id": "f1", "direction": "asc", "color": "red"}]}
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_defaults_direction_to_asc() -> None:
"""direction is optional, defaults to 'asc'."""
item = GroupByItem(field_id="f1")
assert item.direction == "asc"
def test_validate_passes_through_non_u5_keys() -> None:
"""filters / sort / hidden_fields are NOT validated here — pass through."""
config = {
"filters": [{"field_id": "f1", "op": "weird-op", "value": None}],
"sort": {"field": "f1", "order": "asc"},
"hidden_fields": ["f2"],
"group_by": [_gb("f1")],
}
validate_view_config(config) # no raise — non-U5 keys ignored
def test_schema_max_length_constant() -> None:
"""MAX_GROUP_BY_FIELDS is 3 (matches spec / Feishu / Twenty UX)."""
assert MAX_GROUP_BY_FIELDS == 3
def test_schema_round_trips_through_model() -> None:
"""ViewConfigSchema.model_validate round-trips a valid config."""
raw = {
"group_by": [{"field_id": "f1", "direction": "asc"}],
"conditional_formatting": [],
}
schema = ViewConfigSchema.model_validate(raw)
assert len(schema.group_by) == 1
assert schema.group_by[0].field_id == "f1"
dumped = schema.model_dump()
assert dumped["group_by"][0]["direction"] == "asc"
def test_validation_error_carries_structured_errors() -> None:
"""ViewConfigValidationError.errors is the Pydantic error list (for 422 body)."""
config = {"group_by": [_gb("")]}
with pytest.raises(ViewConfigValidationError) as exc_info:
validate_view_config(config)
assert isinstance(exc_info.value.errors, list)
assert len(exc_info.value.errors) > 0
def test_validation_error_is_value_error_subclass() -> None:
"""Subclasses ValueError so existing handlers pick it up."""
config = {"group_by": [_gb("")]}
with pytest.raises(ValueError):
validate_view_config(config)
# ---------------------------------------------------------------------------
# 2. Route-level 422 — uses a fake service (no DB needed)
# ---------------------------------------------------------------------------
def _make_fake_view(view_id: str = "v1", table_id: str = "t1") -> View:
return View(
id=view_id,
table_id=table_id,
name="Test View",
view_type=ViewType.grid,
config={},
)
class _FakeService:
"""Minimal service stub for route-level tests.
Only implements the methods the PATCH /views route touches:
``get_view`` (existence + ownership check) and ``update_view``.
"""
def __init__(self, *, existing_view: View | None = None) -> None:
self._existing = existing_view
self.updated_config: dict[str, object] | None = None
self.update_called = False
async def get_view(self, view_id: str) -> View | None:
if self._existing is None or self._existing.id != view_id:
return None
return self._existing
async def get_table(self, table_id: str) -> Any:
# Ownership check passes — return a table owned by the test user.
class _T:
owner_user_id = TEST_USER_ID
return _T()
async def update_view(self, view_id: str, **kwargs: object) -> View | None:
self.update_called = True
self.updated_config = kwargs.get("config") # type: ignore[assignment]
if self._existing is None:
return None
config = kwargs.get("config")
if isinstance(config, dict):
self._existing = self._existing.model_copy(update={"config": config})
return self._existing
@pytest.fixture
def fake_service_app() -> FastAPI:
"""App with a fake service on state — bypasses PG entirely."""
app = FastAPI()
app.state.bitable_service = _FakeService(existing_view=_make_fake_view())
app.include_router(bitable_routes.router, prefix="/api/v1")
app.dependency_overrides[require_bitable_auth] = lambda: {
"user_id": TEST_USER_ID,
"username": "testuser",
"role": "member",
}
return app
@pytest.fixture
async def fake_client(fake_service_app: FastAPI) -> httpx.AsyncClient:
transport = ASGITransport(app=fake_service_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
async def test_patch_view_with_valid_group_by_returns_200(
fake_client: httpx.AsyncClient,
) -> None:
"""Valid group_by is accepted and persisted (round-trip at the route layer)."""
config = {"group_by": [{"field_id": "f1", "direction": "asc"}]}
resp = await fake_client.patch(
"/api/v1/bitable/views/v1",
json={"config": config},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["success"] is True
assert body["view"]["config"]["group_by"] == config["group_by"]
async def test_patch_view_with_too_many_group_by_returns_422(
fake_client: httpx.AsyncClient,
) -> None:
"""group_by with >3 fields returns 422 (not 500)."""
config = {"group_by": [{"field_id": f"f{i}"} for i in range(4)]}
resp = await fake_client.patch(
"/api/v1/bitable/views/v1",
json={"config": config},
)
assert resp.status_code == 422
body = resp.json()
assert "errors" in body["detail"]
async def test_patch_view_with_invalid_direction_returns_422(
fake_client: httpx.AsyncClient,
) -> None:
"""Invalid direction value returns 422."""
resp = await fake_client.patch(
"/api/v1/bitable/views/v1",
json={"config": {"group_by": [{"field_id": "f1", "direction": "sideways"}]}},
)
assert resp.status_code == 422
async def test_patch_view_with_non_u5_config_returns_200(
fake_client: httpx.AsyncClient,
) -> None:
"""Non-U5 config keys (filters / sort / hidden_fields) pass through unchanged."""
resp = await fake_client.patch(
"/api/v1/bitable/views/v1",
json={"config": {"hidden_fields": ["f1", "f2"]}},
)
assert resp.status_code == 200, resp.text
# ---------------------------------------------------------------------------
# 3. Round-trip via real PG — skipped if PG unavailable
# ---------------------------------------------------------------------------
@pytest.mark.postgres
async def test_round_trip_group_by_via_real_service(bitable_service) -> None:
"""PATCH config with group_by → GET view returns same group_by.
Requires PostgreSQL. Verifies the config dict survives the JSONB
round-trip and the route-layer validation does not strip fields.
"""
from agentkit.bitable.service import BitableService
assert isinstance(bitable_service, BitableService)
table = await bitable_service.create_table(name="U5 Round-Trip")
view = await bitable_service.create_view(table.id, name="V1")
config = {
"group_by": [
{"field_id": "f1", "direction": "asc"},
{"field_id": "f2", "direction": "desc"},
],
}
# Validator accepts the config (no exception).
validate_view_config(config)
# Service persists it as-is.
updated = await bitable_service.update_view(view.id, config=config)
assert updated is not None
assert updated.config.get("group_by") == config["group_by"]
# GET view returns the same group_by.
fetched = await bitable_service.get_view(view.id)
assert fetched is not None
assert fetched.config.get("group_by") == config["group_by"]
# ---------------------------------------------------------------------------
# Direct schema construction (covers Literal type errors at the model layer)
# ---------------------------------------------------------------------------
def test_group_by_item_rejects_invalid_direction_via_model() -> None:
"""Constructing GroupByItem with an invalid direction raises ValidationError."""
with pytest.raises(ValidationError):
GroupByItem(field_id="f1", direction="sideways") # type: ignore[arg-type]
def test_view_config_schema_max_length_enforced() -> None:
"""ViewConfigSchema.group_by max_length=3 enforced at the model level."""
too_many = [{"field_id": f"f{i}"} for i in range(MAX_GROUP_BY_FIELDS + 1)]
with pytest.raises(ValidationError):
ViewConfigSchema.model_validate({"group_by": too_many})