421 lines
13 KiB
Python
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
|