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