"""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}" )