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

297 lines
12 KiB
Python

"""Tests for bitable service layer (U2): upsert, field deletion, view filtering.
Requires PostgreSQL — marked ``postgres``.
"""
from __future__ import annotations
import pytest
from agentkit.bitable.models import FieldOwner, FieldType
from agentkit.bitable.service import FieldDependencyError
pytestmark = pytest.mark.postgres
# ---------------------------------------------------------------------------
# Upsert (KTD8: jsonb_set preserves user columns)
# ---------------------------------------------------------------------------
async def test_upsert_inserts_new_records(bitable_service) -> None:
"""First upsert inserts all records."""
table = await bitable_service.create_table(name="T")
pk_field = await bitable_service.create_field(
table_id=table.id, name="id", field_type=FieldType.text, owner=FieldOwner.agent
)
data_field = await bitable_service.create_field(
table_id=table.id, name="data", field_type=FieldType.text, owner=FieldOwner.agent
)
await bitable_service.update_table(table.id, primary_key_field_id=pk_field.id)
result = await bitable_service.upsert_records(
table.id,
[
{pk_field.id: "row1", data_field.id: "hello"},
{pk_field.id: "row2", data_field.id: "world"},
],
pk_field.id,
)
assert result == {"inserted": 2, "updated": 0, "skipped": 0}
records, _ = await bitable_service.list_records(table.id)
assert len(records) == 2
async def test_upsert_updates_existing_preserves_user_columns(bitable_service) -> None:
"""KTD8: upsert updates agent columns via jsonb_set, user columns untouched."""
table = await bitable_service.create_table(name="T")
pk_field = await bitable_service.create_field(
table_id=table.id, name="id", field_type=FieldType.text, owner=FieldOwner.agent
)
agent_field = await bitable_service.create_field(
table_id=table.id, name="agent_data", field_type=FieldType.text, owner=FieldOwner.agent
)
user_field = await bitable_service.create_field(
table_id=table.id, name="user_data", field_type=FieldType.text, owner=FieldOwner.user
)
await bitable_service.update_table(table.id, primary_key_field_id=pk_field.id)
# First: insert with both agent and user values
await bitable_service.upsert_records(
table.id,
[{pk_field.id: "row1", agent_field.id: "agent_v1", user_field.id: "user_v1"}],
pk_field.id,
)
# Manually set user column (simulating user edit)
records, _ = await bitable_service.list_records(table.id)
assert len(records) == 1
rec = records[0]
await bitable_service.update_record_values(rec.id, {**rec.values, user_field.id: "USER_EDITED"})
# Second upsert: only agent column changes
result = await bitable_service.upsert_records(
table.id,
[{pk_field.id: "row1", agent_field.id: "agent_v2", user_field.id: "SHOULD_NOT_APPLY"}],
pk_field.id,
)
assert result == {"inserted": 0, "updated": 1, "skipped": 0}
# Verify: agent column updated, user column preserved
records, _ = await bitable_service.list_records(table.id)
assert len(records) == 1
rec = records[0]
assert rec.values[agent_field.id] == "agent_v2" # updated
assert rec.values[user_field.id] == "USER_EDITED" # preserved (NOT "SHOULD_NOT_APPLY")
async def test_upsert_skips_records_without_pk(bitable_service) -> None:
"""Records without PK value are skipped."""
table = await bitable_service.create_table(name="T")
pk_field = await bitable_service.create_field(
table_id=table.id, name="id", field_type=FieldType.text, owner=FieldOwner.agent
)
await bitable_service.update_table(table.id, primary_key_field_id=pk_field.id)
result = await bitable_service.upsert_records(
table.id,
[{pk_field.id: "row1"}, {}], # second has no PK
pk_field.id,
)
assert result == {"inserted": 1, "updated": 0, "skipped": 1}
async def test_upsert_empty_batch(bitable_service) -> None:
"""Empty batch returns all zeros."""
table = await bitable_service.create_table(name="T")
pk_field = await bitable_service.create_field(
table_id=table.id, name="id", field_type=FieldType.text, owner=FieldOwner.agent
)
result = await bitable_service.upsert_records(table.id, [], pk_field.id)
assert result == {"inserted": 0, "updated": 0, "skipped": 0}
async def test_upsert_without_pk_field_raises(bitable_service) -> None:
"""Upsert without primary_key_field_id raises ValueError."""
table = await bitable_service.create_table(name="T")
with pytest.raises(ValueError, match="primary_key_field_id"):
await bitable_service.upsert_records(table.id, [{}], "")
# ---------------------------------------------------------------------------
# Field deletion with dependency checking
# ---------------------------------------------------------------------------
async def test_delete_field_no_dependencies(bitable_service) -> None:
"""Deleting a field with no dependencies succeeds."""
table = await bitable_service.create_table(name="T")
field = await bitable_service.create_field(
table_id=table.id, name="f", field_type=FieldType.text
)
deleted = await bitable_service.delete_field(field.id)
assert deleted is True
assert await bitable_service.get_field(field.id) is None
async def test_delete_field_referenced_by_formula_returns_deps(bitable_service) -> None:
"""Deleting a field referenced by a formula raises FieldDependencyError."""
table = await bitable_service.create_table(name="T")
source_field = await bitable_service.create_field(
table_id=table.id, name="source", field_type=FieldType.number
)
formula_field = await bitable_service.create_field(
table_id=table.id,
name="calc",
field_type=FieldType.formula,
config={"formula_expr": f"={source_field.id} * 2"},
)
with pytest.raises(FieldDependencyError) as exc_info:
await bitable_service.delete_field(source_field.id)
deps = exc_info.value.dependencies
assert "formula_fields" in deps
assert any(f["id"] == formula_field.id for f in deps["formula_fields"])
async def test_delete_primary_key_field_returns_deps(bitable_service) -> None:
"""Deleting the primary key field raises FieldDependencyError."""
table = await bitable_service.create_table(name="T")
pk_field = await bitable_service.create_field(
table_id=table.id, name="id", field_type=FieldType.text
)
await bitable_service.update_table(table.id, primary_key_field_id=pk_field.id)
with pytest.raises(FieldDependencyError) as exc_info:
await bitable_service.delete_field(pk_field.id)
assert exc_info.value.dependencies.get("is_primary_key") is True
async def test_delete_field_force_casces_cleanup(bitable_service) -> None:
"""Force delete cascades: removes field from records, marks formula as error."""
table = await bitable_service.create_table(name="T")
source_field = await bitable_service.create_field(
table_id=table.id, name="source", field_type=FieldType.number, owner=FieldOwner.agent
)
formula_field = await bitable_service.create_field(
table_id=table.id,
name="calc",
field_type=FieldType.formula,
config={"formula_expr": f"={source_field.id} * 2"},
)
# Create a record with the source field value
record = await bitable_service.create_record(table_id=table.id, values={source_field.id: 42})
# Force delete
deleted = await bitable_service.delete_field(source_field.id, force=True)
assert deleted is True
# Record should no longer have the source field key
rec = await bitable_service.get_record(record.id)
assert rec is not None
assert source_field.id not in rec.values
# Formula field should have error in config
formula = await bitable_service.get_field(formula_field.id)
assert formula is not None
assert "error" in formula.config
# ---------------------------------------------------------------------------
# View-filtered record listing
# ---------------------------------------------------------------------------
async def test_list_records_filtered_by_number_gt(bitable_service) -> None:
"""View filter with gt op on number field correctly filters (CAST NUMERIC)."""
table = await bitable_service.create_table(name="T")
num_field = await bitable_service.create_field(
table_id=table.id, name="amount", field_type=FieldType.number, owner=FieldOwner.agent
)
# Create records with various amounts
for amt in [10, 50, 100, 200]:
await bitable_service.create_record(table_id=table.id, values={num_field.id: amt})
# Filter: amount > 50
records, _ = await bitable_service.list_records_filtered(
table.id,
filters=[{"field_id": num_field.id, "op": "gt", "value": 50}],
)
amounts = [r.values[num_field.id] for r in records]
assert all(a > 50 for a in amounts)
assert len(records) == 2 # 100 and 200
async def test_list_records_filtered_by_text_eq(bitable_service) -> None:
"""View filter with eq op on text field."""
table = await bitable_service.create_table(name="T")
text_field = await bitable_service.create_field(
table_id=table.id, name="status", field_type=FieldType.text, owner=FieldOwner.agent
)
for status in ["open", "closed", "open", "pending"]:
await bitable_service.create_record(table_id=table.id, values={text_field.id: status})
records, _ = await bitable_service.list_records_filtered(
table.id,
filters=[{"field_id": text_field.id, "op": "eq", "value": "open"}],
)
assert len(records) == 2
assert all(r.values[text_field.id] == "open" for r in records)
async def test_list_records_filtered_with_sort(bitable_service) -> None:
"""View sort by number field descending."""
table = await bitable_service.create_table(name="T")
num_field = await bitable_service.create_field(
table_id=table.id, name="score", field_type=FieldType.number, owner=FieldOwner.agent
)
for score in [30, 10, 50, 20]:
await bitable_service.create_record(table_id=table.id, values={num_field.id: score})
records, _ = await bitable_service.list_records_filtered(
table.id,
sorts=[{"field_id": num_field.id, "direction": "desc"}],
)
# Records should be sorted by score descending (as text, but single/double digit sorts OK)
assert len(records) == 4
async def test_list_records_filtered_cursor_pagination(bitable_service) -> None:
"""Cursor pagination with filters."""
table = await bitable_service.create_table(name="T")
text_field = await bitable_service.create_field(
table_id=table.id, name="name", field_type=FieldType.text, owner=FieldOwner.agent
)
for i in range(5):
await bitable_service.create_record(table_id=table.id, values={text_field.id: f"item_{i}"})
# First page
records, next_cursor = await bitable_service.list_records_filtered(table.id, limit=2)
assert len(records) == 2
assert next_cursor is not None
# Second page
records2, next_cursor2 = await bitable_service.list_records_filtered(
table.id, cursor=next_cursor, limit=2
)
assert len(records2) == 2
assert next_cursor2 is not None
# Third page
records3, next_cursor3 = await bitable_service.list_records_filtered(
table.id, cursor=next_cursor2, limit=2
)
assert len(records3) == 1
assert next_cursor3 is None
# All records unique
all_ids = {r.id for r in [records, records2, records3] for r in r}
assert len(all_ids) == 5