114 lines
4.6 KiB
Python
114 lines
4.6 KiB
Python
"""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
|