297 lines
12 KiB
Python
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
|