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

221 lines
8.8 KiB
Python

"""Tests for relation field CRUD service methods (U4: R5, R7, R8).
These tests require PostgreSQL (skipped when unavailable). They verify:
- create_relation_field creates fields with correct config
- add/remove_relation_link manages junction table entries (many_to_many)
- add/remove_relation_link manages record JSONB (one_to_one/one_to_many)
- bidirectional relations create reverse fields
- delete_relation_field cascades cleanup
"""
from __future__ import annotations
import pytest
from agentkit.bitable.models import FieldType, RelationType
@pytest.mark.asyncio
async def test_create_relation_field_one_to_many(bitable_service) -> None:
"""create_relation_field creates a relation field with correct config."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.one_to_many,
)
assert field.field_type == FieldType.relation
assert field.config["relation_type"] == "one_to_many"
assert field.config["target_table_id"] == table_b.id
assert field.config["is_bidirectional"] is False
@pytest.mark.asyncio
async def test_create_relation_field_bidirectional(bitable_service) -> None:
"""Bidirectional relation creates a reverse field on the target table."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.many_to_many,
is_bidirectional=True,
reverse_field_name="Reverse A",
)
assert field.config["is_bidirectional"] is True
assert "reverse_field_id" in field.config
reverse_field = await bitable_service.get_field(field.config["reverse_field_id"])
assert reverse_field is not None
assert reverse_field.name == "Reverse A"
assert reverse_field.config["target_table_id"] == table_a.id
@pytest.mark.asyncio
async def test_add_relation_link_one_to_many(bitable_service) -> None:
"""one_to_many relation stores links in record JSONB."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.one_to_many,
)
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b1 = await bitable_service.create_record(table_b.id, {})
rec_b2 = await bitable_service.create_record(table_b.id, {})
# Add links
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b1.id) is True
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b2.id) is True
# Verify
related = await bitable_service.list_related_records(field.id, rec_a.id)
assert set(related) == {rec_b1.id, rec_b2.id}
# Idempotent: adding again returns False
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b1.id) is False
@pytest.mark.asyncio
async def test_remove_relation_link_one_to_many(bitable_service) -> None:
"""remove_relation_link removes from record JSONB."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.one_to_many,
)
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b = await bitable_service.create_record(table_b.id, {})
await bitable_service.add_relation_link(field.id, rec_a.id, rec_b.id)
assert await bitable_service.remove_relation_link(field.id, rec_a.id, rec_b.id) is True
assert await bitable_service.list_related_records(field.id, rec_a.id) == []
@pytest.mark.asyncio
async def test_add_relation_link_many_to_many(bitable_service) -> None:
"""many_to_many relation uses junction table."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Tags",
target_table_id=table_b.id,
relation_type=RelationType.many_to_many,
)
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b1 = await bitable_service.create_record(table_b.id, {})
rec_b2 = await bitable_service.create_record(table_b.id, {})
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b1.id) is True
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b2.id) is True
related = await bitable_service.list_related_records(field.id, rec_a.id)
assert set(related) == {rec_b1.id, rec_b2.id}
# Reverse lookup
reverse = await bitable_service.list_related_records(field.id, rec_b1.id, reverse=True)
assert rec_a.id in reverse
@pytest.mark.asyncio
async def test_delete_relation_field_cascades(bitable_service) -> None:
"""delete_relation_field removes junction entries and record values."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.many_to_many,
)
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b = await bitable_service.create_record(table_b.id, {})
await bitable_service.add_relation_link(field.id, rec_a.id, rec_b.id)
assert await bitable_service.delete_relation_field(field.id) is True
assert await bitable_service.get_field(field.id) is None
@pytest.mark.asyncio
async def test_add_relation_link_non_relation_field_raises(bitable_service) -> None:
"""add_relation_link on a non-relation field raises ValueError."""
table_a = await bitable_service.create_table(name="TableA")
field = await bitable_service.create_field(
table_id=table_a.id, name="text_field", field_type=FieldType.text
)
with pytest.raises(ValueError, match="not a relation field"):
await bitable_service.add_relation_link(field.id, "rec1", "rec2")
@pytest.mark.asyncio
async def test_bidirectional_relation_link_cleanup(bitable_service) -> None:
"""remove_relation_link cleans up the reverse field on the target record.
For bidirectional relations, add_relation_link updates BOTH sides:
- Forward: source.values[forward_field] = [target]
- Reverse: target.values[reverse_field] = [source]
remove_relation_link must mirror this — _cleanup_reverse_link removes
source from target.values[reverse_field]. Without this, the reverse
field retains stale references (asymmetric association).
"""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.one_to_many,
is_bidirectional=True,
reverse_field_name="Reverse A",
)
reverse_field_id = field.config["reverse_field_id"]
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b = await bitable_service.create_record(table_b.id, {})
# Add link: forward (A→B) and reverse (B→A) should both be populated
await bitable_service.add_relation_link(field.id, rec_a.id, rec_b.id)
rec_a_after = await bitable_service.get_record(rec_a.id)
rec_b_after = await bitable_service.get_record(rec_b.id)
assert rec_a_after.values.get(field.id) == [rec_b.id]
assert rec_b_after.values.get(reverse_field_id) == [rec_a.id]
# Remove link: both sides should be cleaned up
assert await bitable_service.remove_relation_link(field.id, rec_a.id, rec_b.id) is True
rec_a_final = await bitable_service.get_record(rec_a.id)
rec_b_final = await bitable_service.get_record(rec_b.id)
# Forward field: rec_b removed from list
forward_val = rec_a_final.values.get(field.id)
assert forward_val == [] or forward_val is None
# Reverse field: rec_a removed from list (this is the key assertion —
# without _cleanup_reverse_link, rec_a would still be here)
reverse_val = rec_b_final.values.get(reverse_field_id)
assert reverse_val == [] or reverse_val is None, (
f"Reverse field not cleaned up, still contains: {reverse_val}"
)