"""Tests for bitable DB initialization, schema, and constraints (U1). Requires PostgreSQL — marked ``postgres``. Skips automatically when ``DATABASE_URL`` / ``AGENTKIT_DATABASE_URL`` is unset (see conftest.py). """ from __future__ import annotations import pytest pytestmark = pytest.mark.postgres # --------------------------------------------------------------------------- # init_bitable_db / BitableDB.init # --------------------------------------------------------------------------- async def test_init_creates_schema_and_all_tables(bitable_db) -> None: """init creates the bitable schema and all 6 tables.""" from sqlalchemy import text async with bitable_db.engine.begin() as conn: # Schema exists result = await conn.execute( text( "SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'bitable'" ) ) assert result.fetchone() is not None # All 6 tables present result = await conn.execute( text( "SELECT table_name FROM information_schema.tables " "WHERE table_schema = 'bitable' ORDER BY table_name" ) ) tables = {row[0] for row in result.fetchall()} assert tables == { "bitable_fields", "bitable_meta", "bitable_records", "bitable_recalc_queue", "bitable_tables", "bitable_views", } async def test_init_is_idempotent(bitable_db) -> None: """Calling init() twice does not raise and keeps schema intact.""" # bitable_db fixture already called init(); call again await bitable_db.init() await bitable_db.init() # third time also fine from sqlalchemy import text async with bitable_db.engine.begin() as conn: result = await conn.execute(text("SELECT COUNT(*) FROM bitable.bitable_meta")) assert result.fetchone()[0] >= 1 async def test_schema_version_recorded_in_meta(bitable_db) -> None: """bitable_meta stores the current schema version.""" 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 # --------------------------------------------------------------------------- # Constraints # --------------------------------------------------------------------------- async def test_recalc_queue_unique_record_field(bitable_db) -> None: """Recalc queue enforces (record_id, field_id) uniqueness — dedup.""" from agentkit.bitable.models import FieldType from agentkit.bitable.repository import BitableRepository repo = BitableRepository(bitable_db) table = await repo.create_table(name="T") field = await repo.create_field(table_id=table.id, name="f", field_type=FieldType.text) record = await repo.create_record(table_id=table.id) # First enqueue succeeds task1 = await repo.enqueue_recalc(table.id, record.id, field.id) assert task1 is not None # Second enqueue is a no-op (ON CONFLICT DO NOTHING) — returns None task2 = await repo.enqueue_recalc(table.id, record.id, field.id) assert task2 is None async def test_recalc_queue_status_index_exists(bitable_db) -> None: """The (status, queued_at) index exists for worker consumption.""" from sqlalchemy import text async with bitable_db.engine.begin() as conn: result = await conn.execute( text( "SELECT indexname FROM pg_indexes " "WHERE schemaname = 'bitable' AND tablename = 'bitable_recalc_queue'" ) ) indexes = {row[0] for row in result.fetchall()} assert "ix_recalc_status_queued" in indexes assert "uq_recalc_record_field" in indexes async def test_records_values_gin_index_exists(bitable_db) -> None: """GIN index on records.values exists for JSONB key lookups.""" from sqlalchemy import text async with bitable_db.engine.begin() as conn: result = await conn.execute( text( "SELECT indexname FROM pg_indexes " "WHERE schemaname = 'bitable' AND tablename = 'bitable_records'" ) ) indexes = {row[0] for row in result.fetchall()} assert "ix_bitable_records_values_gin" in indexes # --------------------------------------------------------------------------- # Repository CRUD smoke (verifies schema is usable end-to-end) # --------------------------------------------------------------------------- async def test_repository_crud_round_trip(bitable_db) -> None: """Repository can create/get/list/delete across all entities.""" from agentkit.bitable.models import FieldOwner, FieldType, ViewType from agentkit.bitable.repository import BitableRepository repo = BitableRepository(bitable_db) # Table table = await repo.create_table(name="Orders", description="desc") assert table.name == "Orders" fetched = await repo.get_table(table.id) assert fetched is not None and fetched.id == table.id # Field field = await repo.create_field( table_id=table.id, name="Amount", field_type=FieldType.number, owner=FieldOwner.agent, ) fields = await repo.list_fields(table.id) assert len(fields) == 1 assert fields[0].id == field.id # Record record = await repo.create_record(table_id=table.id, values={field.id: 42}) fetched_rec = await repo.get_record(record.id) assert fetched_rec is not None assert fetched_rec.values[field.id] == 42 # Cursor pagination rec2 = await repo.create_record(table_id=table.id, values={field.id: 99}) records, next_cursor = await repo.list_records(table.id, limit=1) assert len(records) == 1 assert next_cursor is not None records2, next_cursor2 = await repo.list_records(table.id, cursor=next_cursor, limit=1) assert len(records2) == 1 # The second page should be the other record assert {records[0].id, records2[0].id} == {record.id, rec2.id} # View view = await repo.create_view(table_id=table.id, name="All", view_type=ViewType.grid) views = await repo.list_views(table.id) assert len(views) == 1 and views[0].id == view.id # Delete cascades deleted = await repo.delete_table(table.id) assert deleted is True assert await repo.get_table(table.id) is None assert await repo.get_field(field.id) is None assert await repo.get_record(record.id) is None assert (await repo.list_views(table.id)) == [] # --------------------------------------------------------------------------- # Crash recovery # --------------------------------------------------------------------------- async def test_reset_stale_recalc_tasks(bitable_db) -> None: """reset_stale_recalc_tasks flips 'calculating' back to 'pending'.""" from agentkit.bitable.models import FieldType, RecalcStatus from agentkit.bitable.repository import BitableRepository repo = BitableRepository(bitable_db) table = await repo.create_table(name="T") field = await repo.create_field(table_id=table.id, name="f", field_type=FieldType.text) record = await repo.create_record(table_id=table.id) task = await repo.enqueue_recalc(table.id, record.id, field.id) assert task is not None # Simulate a worker crash mid-calculation await repo.update_recalc_status(task.id, RecalcStatus.calculating) reset_count = await repo.reset_stale_recalc_tasks() assert reset_count == 1 pending = await repo.get_pending_recalc_tasks() assert any(t.id == task.id for t in pending) # --------------------------------------------------------------------------- # Degradation (no PG) # --------------------------------------------------------------------------- async def test_bitable_db_without_url_raises() -> None: """BitableDB with no URL raises RuntimeError on init (not silently None).""" # Clear env vars for this test to ensure no URL resolution import os saved = ( os.environ.pop("DATABASE_URL", None), os.environ.pop("AGENTKIT_DATABASE_URL", None), ) try: from agentkit.bitable.db import BitableDB db = BitableDB(database_url=None) # _database_url is None because no arg and no env assert db.database_url is None with pytest.raises(RuntimeError, match="No database URL"): await db.init() finally: for key, val in zip(("DATABASE_URL", "AGENTKIT_DATABASE_URL"), saved): if val is not None: os.environ[key] = val