"""Tests for bitable DB initialization, schema, and constraints (U1). PG-dependent tests skip via the ``bitable_db`` fixture when PostgreSQL is unavailable. The no-URL degradation test runs in all environments. """ from __future__ import annotations import pytest # --------------------------------------------------------------------------- # init_bitable_db / BitableDB.init # --------------------------------------------------------------------------- async def test_init_creates_schema_and_all_tables(bitable_db) -> None: """init creates the bitable schema and all 11 tables (V3 adds 4 new 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 11 tables present (V2 adds bitable_files; V3 adds 4 new tables) 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_automation_logs", "bitable_automations", "bitable_cross_table_deps", "bitable_fields", "bitable_files", "bitable_meta", "bitable_records", "bitable_recalc_queue", "bitable_relation_links", "bitable_tables", "bitable_views", } async def test_v3_migration_adds_is_cross_table_column(bitable_db) -> None: """V3 migration adds is_cross_table boolean column on recalc_queue.""" from sqlalchemy import text async with bitable_db.engine.begin() as conn: result = await conn.execute( text( "SELECT column_name, data_type FROM information_schema.columns " "WHERE table_schema = 'bitable' AND table_name = 'bitable_recalc_queue' " "AND column_name = 'is_cross_table'" ) ) row = result.fetchone() assert row is not None assert row[1] == "boolean" async def test_v3_migration_creates_relation_links_indexes(bitable_db) -> None: """V3 creates the two covering indexes on bitable_relation_links.""" 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_relation_links'" ) ) indexes = {row[0] for row in result.fetchall()} assert "ix_relation_links_field_source" in indexes assert "ix_relation_links_field_target" in indexes async def test_v3_migration_creates_cross_table_deps_unique_constraint(bitable_db) -> None: """V3 enforces (source_field_id, target_field_id) uniqueness on cross_table_deps.""" from sqlalchemy import text async with bitable_db.engine.begin() as conn: result = await conn.execute( text( "SELECT conname FROM pg_constraint " "WHERE conrelid = 'bitable.bitable_cross_table_deps'::regclass " "AND conname = 'uq_cross_table_dep_source_target'" ) ) assert result.fetchone() is not None async def test_v3_migration_creates_automation_webhook_token_partial_index(bitable_db) -> None: """V3 creates a partial index on webhook_token (WHERE NOT NULL).""" from sqlalchemy import text async with bitable_db.engine.begin() as conn: result = await conn.execute( text( "SELECT indexdef FROM pg_indexes " "WHERE schemaname = 'bitable' AND indexname = 'ix_automations_webhook_token'" ) ) defn = result.fetchone() assert defn is not None # Partial index must include WHERE webhook_token IS NOT NULL assert "webhook_token IS NOT NULL" in defn[0] 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 # --------------------------------------------------------------------------- # V3 repository CRUD: relation_links / cross_table_deps / automations / logs # --------------------------------------------------------------------------- async def test_relation_links_crud(bitable_db) -> None: """RelationLink CRUD: add, list forward, list reverse, remove.""" from agentkit.bitable.repository import BitableRepository repo = BitableRepository(bitable_db) table_a = await repo.create_table(name="Students") table_b = await repo.create_table(name="Courses") # Relation field lives on table_a (Students). rel_field = await repo.create_field( table_id=table_a.id, name="Enrolled Courses", field_type="lookup" ) rec_a1 = await repo.create_record(table_id=table_a.id) rec_a2 = await repo.create_record(table_id=table_a.id) rec_b1 = await repo.create_record(table_id=table_b.id) rec_b2 = await repo.create_record(table_id=table_b.id) # Add links: a1 → b1, a1 → b2, a2 → b1 await repo.add_relation_link(rel_field.id, rec_a1.id, rec_b1.id) await repo.add_relation_link(rel_field.id, rec_a1.id, rec_b2.id) await repo.add_relation_link(rel_field.id, rec_a2.id, rec_b1.id) # Forward: a1 → [b1, b2] forward = await repo.list_relation_links(rel_field.id, rec_a1.id) assert set(forward) == {rec_b1.id, rec_b2.id} # Reverse: which sources point at b1? → [a1, a2] reverse = await repo.list_reverse_relation_links(rel_field.id, rec_b1.id) assert set(reverse) == {rec_a1.id, rec_a2.id} # Remove a1 → b1 removed = await repo.remove_relation_link(rel_field.id, rec_a1.id, rec_b1.id) assert removed is True forward_after = await repo.list_relation_links(rel_field.id, rec_a1.id) assert forward_after == [rec_b2.id] # remove_all for a source record cascades await repo.add_relation_link(rel_field.id, rec_a1.id, rec_b1.id) count = await repo.remove_all_relation_links(rel_field.id, source_record_id=rec_a1.id) assert count >= 1 assert await repo.list_relation_links(rel_field.id, rec_a1.id) == [] # remove_all for the whole field count = await repo.remove_all_relation_links(rel_field.id) assert count >= 1 # a2→b1 still there async def test_cross_table_deps_crud(bitable_db) -> None: """CrossTableDep CRUD: add (dedup), find dependents, remove by source.""" from agentkit.bitable.models import CrossTableDepType from agentkit.bitable.repository import BitableRepository repo = BitableRepository(bitable_db) table_a = await repo.create_table(name="Orders") table_b = await repo.create_table(name="Customers") source_field = await repo.create_field( table_id=table_b.id, name="total_amount", field_type="rollup" ) target_field = await repo.create_field(table_id=table_a.id, name="amount", field_type="number") # Add dep edge: source_field (in B) depends on target_field (in A) dep = await repo.add_cross_table_dep( source_table_id=table_b.id, source_field_id=source_field.id, target_table_id=table_a.id, target_field_id=target_field.id, dep_type=CrossTableDepType.rollup, ) assert dep is not None # Duplicate insert is a no-op dup = await repo.add_cross_table_dep( source_table_id=table_b.id, source_field_id=source_field.id, target_table_id=table_a.id, target_field_id=target_field.id, dep_type=CrossTableDepType.rollup, ) assert dup is None # Find dependents of table_a / target_field deps = await repo.find_cross_table_dependents(table_a.id, target_field.id) assert len(deps) == 1 assert deps[0].source_field_id == source_field.id assert deps[0].dep_type == CrossTableDepType.rollup # Remove by source removed = await repo.remove_cross_table_deps_for_source(source_field.id) assert removed == 1 assert await repo.find_cross_table_dependents(table_a.id, target_field.id) == [] async def test_automations_crud(bitable_db) -> None: """AutomationRule CRUD: create, get, list, update, delete (cascade logs).""" from agentkit.bitable.repository import BitableRepository repo = BitableRepository(bitable_db) table = await repo.create_table(name="Tasks") rule = await repo.create_automation( table_id=table.id, name="Notify on done", trigger_config={"type": "record_updated", "field_ids": ["f_status"]}, action_config={"type": "send_webhook", "url": "https://example.com/hook"}, ) assert rule.enabled is True assert rule.webhook_token is None fetched = await repo.get_automation(rule.id) assert fetched is not None and fetched.id == rule.id rules = await repo.list_automations(table.id) assert len(rules) == 1 # list_automations_for_trigger matches on JSONB trigger_config->>'type' matching = await repo.list_automations_for_trigger(table.id, "record_updated") assert len(matching) == 1 non_matching = await repo.list_automations_for_trigger(table.id, "record_created") assert non_matching == [] # update (disable) updated = await repo.update_automation(rule.id, enabled=False) assert updated is not None and updated.enabled is False # webhook token lookup rule_with_token = await repo.create_automation( table_id=table.id, name="Inbound webhook", trigger_config={"type": "record_created"}, action_config={"type": "create_record"}, webhook_token="tok_secret_123", ) found = await repo.get_automation_by_webhook_token("tok_secret_123") assert found is not None and found.id == rule_with_token.id # delete cascades logs (no logs here, but exercise the path) deleted = await repo.delete_automation(rule.id) assert deleted is True assert await repo.get_automation(rule.id) is None async def test_automation_logs_crud(bitable_db) -> None: """AutomationLog CRUD: create, update, list.""" from agentkit.bitable.models import AutomationStatus from agentkit.bitable.repository import BitableRepository repo = BitableRepository(bitable_db) table = await repo.create_table(name="Tasks") rule = await repo.create_automation( table_id=table.id, name="Auto", trigger_config={"type": "record_created"}, action_config={"type": "send_message"}, ) log = await repo.create_automation_log( automation_id=rule.id, trigger_record_id="rec_1", status=AutomationStatus.retrying, attempt=1, ) assert log.status == AutomationStatus.retrying await repo.update_automation_log( log.id, AutomationStatus.success, error_message=None ) logs = await repo.list_automation_logs(rule.id) assert len(logs) == 1 assert logs[0].status == AutomationStatus.success assert logs[0].completed_at is not None async def test_enqueue_recalc_with_is_cross_table(bitable_db) -> None: """enqueue_recalc sets is_cross_table flag when requested (U6).""" from sqlalchemy import text 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) record1 = await repo.create_record(table_id=table.id) record2 = await repo.create_record(table_id=table.id) # Same-table recalc — is_cross_table defaults to False task = await repo.enqueue_recalc(table.id, record1.id, field.id) assert task is not None assert task.is_cross_table is False # Cross-table recalc — explicit flag set on a different record cross_task = await repo.enqueue_recalc( table.id, record2.id, field.id, is_cross_table=True ) assert cross_task is not None assert cross_task.is_cross_table is True # Verify the DB column actually stores the flag async with bitable_db.engine.begin() as conn: result = await conn.execute( text( "SELECT is_cross_table FROM bitable.bitable_recalc_queue " "WHERE id = :id" ), {"id": cross_task.id}, ) row = result.fetchone() assert row is not None and row[0] is True