494 lines
15 KiB
Python
494 lines
15 KiB
Python
"""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
|