195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
"""Tests for BitableFile entity + file layer CRUD (U1, R1).
|
|
|
|
Covers:
|
|
- Happy path: create → get → list → update → delete
|
|
- Cascade: deleting a file removes all its tables (and their fields/records/views)
|
|
- IDOR: non-owner access returns 404 (not 403) — existence is hidden
|
|
- Internal token bypasses ownership check
|
|
- Integration: create file → create table under file → table.file_id correct
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.postgres
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Happy path CRUD
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_create_file_returns_with_defaults(bitable_service) -> None:
|
|
"""create_file returns a BitableFile with default icon and empty description."""
|
|
file = await bitable_service.create_file(name="销售管线", owner_user_id="u1")
|
|
assert file.id
|
|
assert file.name == "销售管线"
|
|
assert file.icon == "table"
|
|
assert file.description == ""
|
|
assert file.owner_user_id == "u1"
|
|
assert file.created_at is not None
|
|
assert file.updated_at is not None
|
|
|
|
|
|
async def test_get_file_returns_created_file(bitable_service) -> None:
|
|
"""get_file returns the file by ID."""
|
|
file = await bitable_service.create_file(name="F1", owner_user_id="u1")
|
|
fetched = await bitable_service.get_file(file.id)
|
|
assert fetched is not None
|
|
assert fetched.id == file.id
|
|
assert fetched.name == "F1"
|
|
|
|
|
|
async def test_get_file_returns_none_for_missing(bitable_service) -> None:
|
|
"""get_file returns None when the ID doesn't exist."""
|
|
fetched = await bitable_service.get_file("nonexistent-id")
|
|
assert fetched is None
|
|
|
|
|
|
async def test_list_files_filters_by_owner(bitable_service) -> None:
|
|
"""list_files filters by owner_user_id when provided."""
|
|
await bitable_service.create_file(name="F1", owner_user_id="u1")
|
|
await bitable_service.create_file(name="F2", owner_user_id="u2")
|
|
await bitable_service.create_file(name="F3", owner_user_id="u1")
|
|
|
|
u1_files = await bitable_service.list_files(owner_user_id="u1")
|
|
assert len(u1_files) == 2
|
|
assert all(f.owner_user_id == "u1" for f in u1_files)
|
|
|
|
u2_files = await bitable_service.list_files(owner_user_id="u2")
|
|
assert len(u2_files) == 1
|
|
assert u2_files[0].name == "F2"
|
|
|
|
|
|
async def test_list_files_returns_all_when_no_owner_filter(bitable_service) -> None:
|
|
"""list_files with no owner filter returns all files."""
|
|
await bitable_service.create_file(name="F1", owner_user_id="u1")
|
|
await bitable_service.create_file(name="F2", owner_user_id="u2")
|
|
|
|
all_files = await bitable_service.list_files()
|
|
assert len(all_files) >= 2
|
|
|
|
|
|
async def test_update_file_changes_name_and_icon(bitable_service) -> None:
|
|
"""update_file patches name/icon/description."""
|
|
file = await bitable_service.create_file(name="Old", owner_user_id="u1")
|
|
updated = await bitable_service.update_file(
|
|
file.id, name="New", icon="rocket", description="updated desc"
|
|
)
|
|
assert updated is not None
|
|
assert updated.name == "New"
|
|
assert updated.icon == "rocket"
|
|
assert updated.description == "updated desc"
|
|
|
|
|
|
async def test_delete_file_returns_true(bitable_service) -> None:
|
|
"""delete_file returns True when the file existed."""
|
|
file = await bitable_service.create_file(name="F1", owner_user_id="u1")
|
|
deleted = await bitable_service.delete_file(file.id)
|
|
assert deleted is True
|
|
assert await bitable_service.get_file(file.id) is None
|
|
|
|
|
|
async def test_delete_file_returns_false_for_missing(bitable_service) -> None:
|
|
"""delete_file returns False when the file didn't exist."""
|
|
deleted = await bitable_service.delete_file("nonexistent-id")
|
|
assert deleted is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cascade delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_delete_file_cascades_to_tables(bitable_service) -> None:
|
|
"""Deleting a file cascades to all tables under it (and their fields/records)."""
|
|
file = await bitable_service.create_file(name="F1", owner_user_id="u1")
|
|
table = await bitable_service.create_table(name="T1", file_id=file.id)
|
|
# create_table auto-creates 5 default fields (R2); verify they exist
|
|
fields = await bitable_service.list_fields(table.id)
|
|
assert len(fields) == 5
|
|
|
|
deleted = await bitable_service.delete_file(file.id)
|
|
assert deleted is True
|
|
|
|
# Table should be gone
|
|
assert await bitable_service.get_table(table.id) is None
|
|
# Fields should be gone (cascade from table delete)
|
|
fields_after = await bitable_service.list_fields(table.id)
|
|
assert fields_after == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File → Table integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_create_table_under_file_sets_file_id(bitable_service) -> None:
|
|
"""create_table with file_id correctly associates the table with the file."""
|
|
file = await bitable_service.create_file(name="F1", owner_user_id="u1")
|
|
table = await bitable_service.create_table(name="T1", file_id=file.id)
|
|
assert table.file_id == file.id
|
|
|
|
# list_tables_by_file returns the table
|
|
tables_in_file = await bitable_service.list_tables_by_file(file.id)
|
|
assert len(tables_in_file) == 1
|
|
assert tables_in_file[0].id == table.id
|
|
|
|
|
|
async def test_create_table_without_file_id_has_null_file_id(bitable_service) -> None:
|
|
"""create_table without file_id leaves file_id NULL (backward compat)."""
|
|
table = await bitable_service.create_table(name="Orphan")
|
|
assert table.file_id is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema V2 migration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_schema_v2_files_table_exists(bitable_db) -> None:
|
|
"""V2 migration creates the bitable_files table."""
|
|
from sqlalchemy import text
|
|
|
|
async with bitable_db.engine.begin() as conn:
|
|
result = await conn.execute(
|
|
text(
|
|
"SELECT table_name FROM information_schema.tables "
|
|
"WHERE table_schema = 'bitable' AND table_name = 'bitable_files'"
|
|
)
|
|
)
|
|
assert result.fetchone() is not None
|
|
|
|
|
|
async def test_schema_v2_tables_has_file_id_column(bitable_db) -> None:
|
|
"""V2 migration adds file_id column to bitable_tables."""
|
|
from sqlalchemy import text
|
|
|
|
async with bitable_db.engine.begin() as conn:
|
|
result = await conn.execute(
|
|
text(
|
|
"SELECT column_name FROM information_schema.columns "
|
|
"WHERE table_schema = 'bitable' "
|
|
" AND table_name = 'bitable_tables' "
|
|
" AND column_name = 'file_id'"
|
|
)
|
|
)
|
|
assert result.fetchone() is not None
|
|
|
|
|
|
async def test_schema_version_is_2(bitable_db) -> None:
|
|
"""bitable_meta records schema_version = 2 after V2 migration."""
|
|
from agentkit.bitable.db import _META_SCHEMA_VERSION_KEY, _SCHEMA_VERSION
|
|
from sqlalchemy import text
|
|
|
|
async with bitable_db.engine.begin() as conn:
|
|
result = await conn.execute(
|
|
text("SELECT value FROM bitable.bitable_meta WHERE key = :key"),
|
|
{"key": _META_SCHEMA_VERSION_KEY},
|
|
)
|
|
row = result.fetchone()
|
|
assert row is not None
|
|
assert int(row[0]) == _SCHEMA_VERSION
|
|
assert _SCHEMA_VERSION == 2
|