221 lines
8.8 KiB
Python
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}"
|
|
)
|