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

421 lines
13 KiB
Python

"""Tests for bitable view config conditional formatting validation (U5 / R4).
Covers all 7 operators + 8 color keys. Layered like test_grouping.py:
1. Pure Pydantic validation (no DB) — always runs
2. Route-level 422 via fake service (no DB) — always runs
3. Round-trip via real PG service — skipped if PG unavailable
"""
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 (
ColorKey,
ConditionalFormatRule,
ConditionalOperator,
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"
# Exhaustive lists — kept in sync with the Literal definitions in view_config.py.
ALL_OPERATORS: list[ConditionalOperator] = [
"equals",
"not-equals",
"contains",
"is-empty",
"greater-than",
"less-than",
"between",
]
ALL_COLOR_KEYS: list[ColorKey] = [
"red",
"orange",
"yellow",
"green",
"blue",
"purple",
"gray",
"neutral",
]
def _rule(
*,
field_id: str = "f1",
operator: ConditionalOperator = "equals",
value: str = "v",
color_key: ColorKey = "red",
bold: bool = True,
enabled: bool = True,
) -> dict[str, object]:
return {
"field_id": field_id,
"operator": operator,
"value": value,
"color_key": color_key,
"bold": bold,
"enabled": enabled,
}
# ---------------------------------------------------------------------------
# 1. Pure Pydantic validation — always runs
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("op", ALL_OPERATORS)
def test_validate_accepts_each_operator(op: ConditionalOperator) -> None:
"""All 7 operators accepted (spec test scenario 8)."""
config = {"conditional_formatting": [_rule(operator=op)]}
validate_view_config(config) # no raise
@pytest.mark.parametrize("color", ALL_COLOR_KEYS)
def test_validate_accepts_each_color_key(color: ColorKey) -> None:
"""All 8 color keys accepted."""
config = {"conditional_formatting": [_rule(color_key=color)]}
validate_view_config(config) # no raise
def test_validate_rejects_invalid_operator() -> None:
"""Operator not in the 7-key whitelist is rejected."""
config = {"conditional_formatting": [_rule(operator="starts-with")]} # type: ignore[dict-item]
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_rejects_invalid_color_key() -> None:
"""color_key not in the 8-color whitelist is rejected."""
config = {"conditional_formatting": [_rule(color_key="pink")]} # type: ignore[dict-item]
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_rejects_missing_field_id() -> None:
"""field_id is required."""
rule = _rule()
del rule["field_id"]
config = {"conditional_formatting": [rule]}
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_rejects_missing_operator() -> None:
"""operator is required."""
rule = _rule()
del rule["operator"]
config = {"conditional_formatting": [rule]}
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_rejects_missing_color_key() -> None:
"""color_key is required."""
rule = _rule()
del rule["color_key"]
config = {"conditional_formatting": [rule]}
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_value_defaults_to_empty_string() -> None:
"""value is optional, defaults to '' (UI sends '' for is-empty)."""
rule = _rule()
del rule["value"]
parsed = ConditionalFormatRule.model_validate(rule)
assert parsed.value == ""
def test_validate_bold_defaults_to_true() -> None:
"""bold defaults to True (WCAG 1.4.1 — color alone is not enough)."""
rule = _rule()
del rule["bold"]
parsed = ConditionalFormatRule.model_validate(rule)
assert parsed.bold is True
def test_validate_enabled_defaults_to_true() -> None:
"""enabled defaults to True."""
rule = _rule()
del rule["enabled"]
parsed = ConditionalFormatRule.model_validate(rule)
assert parsed.enabled is True
def test_validate_rejects_extra_keys_in_rule() -> None:
"""extra='forbid' on ConditionalFormatRule — unknown keys rejected."""
rule = _rule()
rule["extra_key"] = "oops"
config = {"conditional_formatting": [rule]}
with pytest.raises(ViewConfigValidationError):
validate_view_config(config)
def test_validate_accepts_empty_conditional_formatting_list() -> None:
"""Empty rule list is valid (no rules = no coloring)."""
validate_view_config({"conditional_formatting": []})
def test_validate_accepts_multiple_rules() -> None:
"""Multiple rules accepted (first-match-wins is enforced client-side)."""
config = {
"conditional_formatting": [
_rule(field_id="f1", operator="equals", value="a", color_key="red"),
_rule(field_id="f2", operator="greater-than", value="10", color_key="green"),
_rule(field_id="f3", operator="is-empty", color_key="gray"),
]
}
validate_view_config(config) # no raise
def test_validate_ignores_non_u5_keys_with_cf_present() -> None:
"""filters / sort / hidden_fields pass through when cf is also present."""
config = {
"filters": [{"field_id": "f1", "op": "weird-op"}],
"hidden_fields": ["f2"],
"conditional_formatting": [_rule()],
}
validate_view_config(config) # no raise
def test_schema_round_trips_conditional_formatting() -> None:
"""ViewConfigSchema round-trips a CF config without loss."""
raw = {
"group_by": [],
"conditional_formatting": [
{
"field_id": "f1",
"operator": "between",
"value": "10,20",
"color_key": "blue",
"bold": False,
"enabled": True,
}
],
}
schema = ViewConfigSchema.model_validate(raw)
assert len(schema.conditional_formatting) == 1
rule = schema.conditional_formatting[0]
assert rule.operator == "between"
assert rule.color_key == "blue"
assert rule.bold is False
dumped = schema.model_dump()
assert dumped["conditional_formatting"][0]["value"] == "10,20"
def test_rule_model_rejects_invalid_operator_directly() -> None:
"""ConditionalFormatRule rejects invalid operator at construction."""
with pytest.raises(ValidationError):
ConditionalFormatRule(
field_id="f1",
operator="starts-with", # type: ignore[arg-type]
value="v",
color_key="red",
)
def test_rule_model_rejects_invalid_color_key_directly() -> None:
"""ConditionalFormatRule rejects invalid color_key at construction."""
with pytest.raises(ValidationError):
ConditionalFormatRule(
field_id="f1",
operator="equals",
value="v",
color_key="pink", # type: ignore[arg-type]
)
def test_constants_match_spec() -> None:
"""7 operators + 8 color keys (spec invariant)."""
assert len(ALL_OPERATORS) == 7
assert len(ALL_COLOR_KEYS) == 8
# ---------------------------------------------------------------------------
# 2. Route-level 422 — uses a fake service (no DB)
# ---------------------------------------------------------------------------
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 — same shape as test_grouping.py's stub."""
def __init__(self, *, existing_view: View | None = None) -> None:
self._existing = existing_view
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:
class _T:
owner_user_id = TEST_USER_ID
return _T()
async def update_view(self, view_id: str, **kwargs: object) -> View | None:
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 = 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_cf_returns_200(
fake_client: httpx.AsyncClient,
) -> None:
"""Valid conditional_formatting is accepted and persisted."""
config = {
"conditional_formatting": [
{
"field_id": "f1",
"operator": "equals",
"value": "done",
"color_key": "green",
}
]
}
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["view"]["config"]["conditional_formatting"] == config["conditional_formatting"]
async def test_patch_view_with_invalid_operator_returns_422(
fake_client: httpx.AsyncClient,
) -> None:
"""Invalid operator returns 422."""
resp = await fake_client.patch(
"/api/v1/bitable/views/v1",
json={
"config": {
"conditional_formatting": [
{"field_id": "f1", "operator": "starts-with", "value": "x", "color_key": "red"}
]
}
},
)
assert resp.status_code == 422
async def test_patch_view_with_invalid_color_key_returns_422(
fake_client: httpx.AsyncClient,
) -> None:
"""Invalid color_key returns 422."""
resp = await fake_client.patch(
"/api/v1/bitable/views/v1",
json={
"config": {
"conditional_formatting": [
{"field_id": "f1", "operator": "equals", "value": "x", "color_key": "pink"}
]
}
},
)
assert resp.status_code == 422
async def test_patch_view_with_missing_field_id_returns_422(
fake_client: httpx.AsyncClient,
) -> None:
"""Missing field_id in a CF rule returns 422."""
resp = await fake_client.patch(
"/api/v1/bitable/views/v1",
json={
"config": {
"conditional_formatting": [{"operator": "equals", "value": "x", "color_key": "red"}]
}
},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# 3. Round-trip via real PG — skipped if PG unavailable
# ---------------------------------------------------------------------------
@pytest.mark.postgres
async def test_round_trip_conditional_formatting_via_real_service(bitable_service) -> None:
"""PATCH config with conditional_formatting → GET view returns same."""
from agentkit.bitable.service import BitableService
assert isinstance(bitable_service, BitableService)
table = await bitable_service.create_table(name="U5 CF Round-Trip")
view = await bitable_service.create_view(table.id, name="V1")
cf_rules = [
{
"field_id": "f1",
"operator": "equals",
"value": "done",
"color_key": "green",
"bold": True,
"enabled": True,
},
{
"field_id": "f2",
"operator": "between",
"value": "10,20",
"color_key": "blue",
"bold": False,
"enabled": False,
},
]
config = {"conditional_formatting": cf_rules}
validate_view_config(config)
updated = await bitable_service.update_view(view.id, config=config)
assert updated is not None
assert updated.config.get("conditional_formatting") == cf_rules
fetched = await bitable_service.get_view(view.id)
assert fetched is not None
assert fetched.config.get("conditional_formatting") == cf_rules