"""Tests for bitable Pydantic v2 data models (U1). Covers: enum values, round-trip serialization, field config shapes per field_type, Record.values empty-dict legality, default factories. """ from __future__ import annotations from datetime import datetime, timezone import pytest from pydantic import ValidationError from agentkit.bitable.models import ( AutomationActionType, AutomationLog, AutomationRule, AutomationStatus, AutomationTriggerType, CrossTableDep, CrossTableDepType, Field, FieldOwner, FieldType, Record, RecalcStatus, RecalcTask, RelationLink, RelationType, Table, View, ViewType, ) # --------------------------------------------------------------------------- # Enums # --------------------------------------------------------------------------- def test_field_type_values() -> None: """FieldType has the 11 supported types with correct string values (V3 adds relation + rollup).""" expected = { "text", "number", "date", "select", "multiselect", "attachment", "image", "formula", "relation", "lookup", "rollup", } assert {ft.value for ft in FieldType} == expected def test_field_owner_values() -> None: """FieldOwner distinguishes agent vs user (drives upsert merge).""" assert FieldOwner.agent.value == "agent" assert FieldOwner.user.value == "user" def test_view_type_values() -> None: """ViewType enumerates the 5 view kinds (v1 only grid is implemented).""" assert {vt.value for vt in ViewType} == {"grid", "kanban", "gantt", "gallery", "form"} def test_recalc_status_lifecycle() -> None: """RecalcStatus covers the full recalc lifecycle.""" assert {rs.value for rs in RecalcStatus} == {"pending", "calculating", "done", "error"} def test_relation_type_values() -> None: """RelationType covers single/bi/multi (R5).""" assert {rt.value for rt in RelationType} == { "one_to_one", "one_to_many", "many_to_many", } def test_automation_trigger_type_values() -> None: """AutomationTriggerType covers create/update/delete (R13).""" assert {t.value for t in AutomationTriggerType} == { "record_created", "record_updated", "record_deleted", } def test_automation_action_type_values() -> None: """AutomationActionType covers 5 action kinds (R14).""" assert {a.value for a in AutomationActionType} == { "create_record", "update_record", "send_webhook", "send_email", "send_message", } def test_cross_table_dep_type_values() -> None: """CrossTableDepType covers formula/lookup/rollup edges (KTD-3).""" assert {d.value for d in CrossTableDepType} == {"formula", "lookup", "rollup"} # --------------------------------------------------------------------------- # Table # --------------------------------------------------------------------------- def test_table_minimal_construction() -> None: """Table requires id + name; other fields have defaults.""" table = Table(id="t1", name="Orders") assert table.id == "t1" assert table.name == "Orders" assert table.description == "" assert table.primary_key_field_id is None assert table.owner_user_id is None assert isinstance(table.created_at, datetime) assert isinstance(table.updated_at, datetime) def test_table_round_trip() -> None: """Table serializes to dict and re-parses losslessly.""" table = Table( id="t1", name="Orders", description="Customer orders", primary_key_field_id="f_pk", owner_user_id="u1", ) data = table.model_dump(mode="json") restored = Table.model_validate(data) assert restored == table def test_table_requires_id_and_name() -> None: """Table requires id and name (non-optional).""" with pytest.raises(ValidationError): Table(name="no id") # type: ignore[call-arg] with pytest.raises(ValidationError): Table(id="t1") # type: ignore[call-arg] # --------------------------------------------------------------------------- # Field # --------------------------------------------------------------------------- def test_field_text_default_config() -> None: """Text field has empty config by default and user owner.""" field = Field(id="f1", table_id="t1", name="Title", field_type=FieldType.text) assert field.config == {} assert field.owner == FieldOwner.user def test_field_select_config_shape() -> None: """Select field config carries options list.""" field = Field( id="f1", table_id="t1", name="Status", field_type=FieldType.select, config={"options": [{"label": "Open", "value": "open"}]}, owner=FieldOwner.agent, ) assert field.config["options"][0]["value"] == "open" assert field.owner == FieldOwner.agent def test_field_formula_config_shape() -> None: """Formula field config carries formula_expr.""" field = Field( id="f1", table_id="t1", name="Total", field_type=FieldType.formula, config={"formula_expr": "=SUM({f_price})"}, ) assert field.config["formula_expr"] == "=SUM({f_price})" def test_field_lookup_config_shape() -> None: """Lookup field config carries lookup_target with table/field ids.""" field = Field( id="f1", table_id="t1", name="Customer Name", field_type=FieldType.lookup, config={ "lookup_target": { "table_id": "t_customers", "field_id": "f_name", "filter_field_id": "f_id", "filter_value": "cust-123", } }, ) assert field.config["lookup_target"]["field_id"] == "f_name" def test_field_round_trip() -> None: """Field serializes and re-parses losslessly across types.""" field = Field( id="f1", table_id="t1", name="Score", field_type=FieldType.number, config={"precision": 2}, owner=FieldOwner.agent, ) restored = Field.model_validate(field.model_dump(mode="json")) assert restored == field assert restored.field_type == FieldType.number assert restored.owner == FieldOwner.agent def test_field_type_accepts_string() -> None: """FieldType coerces from string (JSON round-trip scenario).""" field = Field(id="f1", table_id="t1", name="X", field_type="number") # type: ignore[arg-type] assert field.field_type == FieldType.number # --------------------------------------------------------------------------- # Record # --------------------------------------------------------------------------- def test_record_empty_values_allowed() -> None: """Record.values defaults to empty dict (new row before data entry).""" record = Record(id="r1", table_id="t1") assert record.values == {} def test_record_values_round_trip() -> None: """Record.values (JSONB-shaped dict) round-trips through JSON.""" record = Record( id="r1", table_id="t1", values={"f_name": "Alice", "f_age": 30, "f_tags": ["a", "b"]}, ) restored = Record.model_validate(record.model_dump(mode="json")) assert restored.values == record.values assert restored.values["f_tags"] == ["a", "b"] def test_record_values_with_null() -> None: """Record.values can carry None for unset fields.""" record = Record(id="r1", table_id="t1", values={"f_name": None}) assert record.values["f_name"] is None # --------------------------------------------------------------------------- # View # --------------------------------------------------------------------------- def test_view_defaults_to_grid() -> None: """View defaults to grid type with empty config.""" view = View(id="v1", table_id="t1", name="All") assert view.view_type == ViewType.grid assert view.config == {} def test_view_round_trip() -> None: """View with filter/sort config round-trips.""" view = View( id="v1", table_id="t1", name="Open only", view_type=ViewType.grid, config={ "filters": [{"field_id": "f_status", "op": "eq", "value": "open"}], "sorts": [{"field_id": "f_created", "direction": "desc"}], "hidden_fields": ["f_internal"], }, ) restored = View.model_validate(view.model_dump(mode="json")) assert restored == view assert restored.config["filters"][0]["op"] == "eq" # --------------------------------------------------------------------------- # RecalcTask # --------------------------------------------------------------------------- def test_recalc_task_defaults() -> None: """RecalcTask defaults to pending status, no error, no completed_at.""" task = RecalcTask(id="q1", table_id="t1", record_id="r1", field_id="f1") assert task.status == RecalcStatus.pending assert task.error_message is None assert task.completed_at is None assert isinstance(task.queued_at, datetime) def test_recalc_task_error_state() -> None: """RecalcTask in error state carries message and completed_at.""" task = RecalcTask( id="q1", table_id="t1", record_id="r1", field_id="f1", status=RecalcStatus.error, error_message="division by zero", completed_at=datetime.now(timezone.utc), ) assert task.status == RecalcStatus.error assert task.error_message == "division by zero" def test_recalc_task_round_trip() -> None: """RecalcTask round-trips through JSON.""" task = RecalcTask( id="q1", table_id="t1", record_id="r1", field_id="f1", status=RecalcStatus.done, ) restored = RecalcTask.model_validate(task.model_dump(mode="json")) assert restored == task assert restored.status == RecalcStatus.done # --------------------------------------------------------------------------- # from_attributes (ORM row compatibility) # --------------------------------------------------------------------------- def test_table_from_attributes() -> None: """Table.model_validate accepts an ORM-like object (from_attributes).""" class _Row: id = "t1" name = "Orders" description = "desc" primary_key_field_id = None owner_user_id = None created_at = datetime.now(timezone.utc) updated_at = datetime.now(timezone.utc) table = Table.model_validate(_Row()) assert table.id == "t1" assert table.name == "Orders" # --------------------------------------------------------------------------- # V3 models: RecalcTask.is_cross_table, RelationLink, AutomationRule, # AutomationLog, CrossTableDep # --------------------------------------------------------------------------- def test_recalc_task_is_cross_table_default() -> None: """RecalcTask.is_cross_table defaults to False (same-table recalc).""" task = RecalcTask(id="q1", table_id="t1", record_id="r1", field_id="f1") assert task.is_cross_table is False def test_recalc_task_cross_table_flag() -> None: """RecalcTask can carry is_cross_table=True (U6).""" task = RecalcTask( id="q1", table_id="t1", record_id="r1", field_id="f1", is_cross_table=True, ) assert task.is_cross_table is True def test_relation_link_construction() -> None: """RelationLink represents a junction-table row (KTD-1).""" link = RelationLink( id="rl1", relation_field_id="f_rel", source_record_id="r_src", target_record_id="r_tgt", ) assert link.relation_field_id == "f_rel" assert link.source_record_id == "r_src" assert link.target_record_id == "r_tgt" assert isinstance(link.created_at, datetime) def test_relation_link_round_trip() -> None: """RelationLink round-trips through JSON.""" link = RelationLink( id="rl1", relation_field_id="f_rel", source_record_id="r_src", target_record_id="r_tgt", ) restored = RelationLink.model_validate(link.model_dump(mode="json")) assert restored == link def test_automation_rule_construction() -> None: """AutomationRule carries trigger_config + action_config + token.""" rule = AutomationRule( id="a1", table_id="t1", name="Notify on done", trigger_config={"type": "record_updated", "field_ids": ["f_status"]}, action_config={"type": "send_webhook", "url": "https://example.com"}, ) assert rule.enabled is True assert rule.webhook_token is None assert rule.trigger_config["type"] == "record_updated" assert rule.action_config["url"] == "https://example.com" def test_automation_rule_round_trip() -> None: """AutomationRule round-trips through JSON.""" rule = AutomationRule( id="a1", table_id="t1", name="Auto", trigger_config={"type": "record_created"}, action_config={"type": "send_message", "channel": "general"}, webhook_token="tok_123", ) restored = AutomationRule.model_validate(rule.model_dump(mode="json")) assert restored == rule assert restored.webhook_token == "tok_123" def test_automation_log_construction() -> None: """AutomationLog carries status + attempt + error_message.""" log = AutomationLog( id="log1", automation_id="a1", trigger_record_id="r1", status=AutomationStatus.retrying, attempt=2, error_message="transient", ) assert log.status == AutomationStatus.retrying assert log.attempt == 2 assert log.error_message == "transient" assert log.completed_at is None def test_cross_table_dep_construction() -> None: """CrossTableDep records a source_field → target_field edge (KTD-3).""" dep = CrossTableDep( id="d1", source_table_id="t_src", source_field_id="f_src", target_table_id="t_tgt", target_field_id="f_tgt", dep_type=CrossTableDepType.rollup, ) assert dep.dep_type == CrossTableDepType.rollup assert dep.target_table_id == "t_tgt" def test_field_rollup_config_shape() -> None: """Rollup field config carries relation_field_id + aggregation (KTD-7).""" field = Field( id="f1", table_id="t1", name="Total Amount", field_type=FieldType.rollup, config={ "relation_field_id": "f_rel", "target_field_id": "f_amount", "aggregation": "sum", }, ) assert field.field_type == FieldType.rollup assert field.config["aggregation"] == "sum" def test_field_relation_config_shape() -> None: """Relation field (lookup with relation config) carries relation_type.""" field = Field( id="f1", table_id="t1", name="Customer", field_type=FieldType.lookup, config={ "relation_type": "many_to_many", "target_table_id": "t_customers", "is_bidirectional": True, "reverse_field_id": "f_reverse", }, ) assert field.config["relation_type"] == "many_to_many" assert field.config["is_bidirectional"] is True