"""Tests for BitableRepository — U3/DR-1 + DR-4 fixes. DR-1: field_id UUID validation before SQL interpolation (pure unit test). DR-4: atomic delete_view_if_not_last (requires PostgreSQL). """ from __future__ import annotations import pytest from agentkit.bitable.repository import _validate_field_id # ── DR-1: field_id UUID validation ────────────────────────── class TestValidateFieldId: """DR-1: field_id 校验 — 拒绝非 UUID 格式进入 SQL 结构插值。""" def test_valid_uuid_passes(self): valid = "550e8400-e29b-41d4-a716-446655440000" assert _validate_field_id(valid) == valid def test_valid_uppercase_uuid_passes(self): valid = "550E8400-E29B-41D4-A716-446655440000" assert _validate_field_id(valid) == valid @pytest.mark.parametrize( "bad_field_id,label", [ ("not-a-uuid", "arbitrary string"), ("", "empty string"), ("550e8400-e29b-41d4-a716", "too short"), ("550e8400-e29b-41d4-a716-446655440000-extra", "too long"), ("550e8400-e29b-41d4-a716-44665544000g", "non-hex char"), ("'; DROP TABLE bitable.bitable_views; --", "SQL injection attempt"), ("values->>'field' = :fv0", "SQL fragment"), ], ) def test_invalid_field_id_raises(self, bad_field_id: str, label: str): with pytest.raises(ValueError, match="Invalid field_id format"): _validate_field_id(bad_field_id, context=label) def test_non_string_raises(self): with pytest.raises(ValueError, match="Invalid field_id format"): _validate_field_id(None) # type: ignore[arg-type] with pytest.raises(ValueError, match="Invalid field_id format"): _validate_field_id(12345) # type: ignore[arg-type] # ── DR-4: atomic delete_view_if_not_last ──────────────────── # These tests require PostgreSQL (SELECT FOR UPDATE behavior) pytestmark_postgres = pytest.mark.postgres @pytest.mark.postgres class TestDeleteViewIfNotLastAtomic: """DR-4: 原子条件 delete_view — SELECT FOR UPDATE 事务防止 TOCTOU。 并发场景不变量:"table 至少 1 view" 在并发删除最后两个 view 时保持。 """ async def test_delete_view_if_not_last_succeeds_when_multiple_siblings( self, bitable_service, make_table ): """多 view 时删除成功。""" from agentkit.bitable.repository import BitableRepository table = await make_table(name="T1") v1 = await bitable_service.create_view(table.id, name="v1") await bitable_service.create_view(table.id, name="v2") repo = BitableRepository(bitable_service._repo._db) deleted = await repo.delete_view_if_not_last(v1.id, table.id) assert deleted is True # v1 gone, v2 still present assert await bitable_service.get_view(v1.id) is None assert (await bitable_service.list_views(table.id)) is not None async def test_delete_view_if_not_last_refuses_last_view(self, bitable_service, make_table): """最后一个 view 时拒绝删除(返回 False,不抛异常 — service 层负责抛 LastViewDeletionError)。""" from agentkit.bitable.repository import BitableRepository table = await make_table(name="T2") only_view = await bitable_service.create_view(table.id, name="only") repo = BitableRepository(bitable_service._repo._db) deleted = await repo.delete_view_if_not_last(only_view.id, table.id) assert deleted is False # View still exists assert await bitable_service.get_view(only_view.id) is not None async def test_delete_view_if_not_last_wrong_table_returns_false( self, bitable_service, make_table ): """view_id 不属于给定 table_id 时返回 False(不误删其他 table 的 view)。""" from agentkit.bitable.repository import BitableRepository table_a = await make_table(name="TA") table_b = await make_table(name="TB") va = await bitable_service.create_view(table_a.id, name="va") # table_b 有自己的 view await bitable_service.create_view(table_b.id, name="vb1") await bitable_service.create_view(table_b.id, name="vb2") repo = BitableRepository(bitable_service._repo._db) # 用 table_b 的 id 但 view_id 是 table_a 的 — 不应删除 deleted = await repo.delete_view_if_not_last(va.id, table_b.id) assert deleted is False # va 仍然存在 assert await bitable_service.get_view(va.id) is not None