"""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