fischer-agentkit/tests/unit/bitable/test_db.py

530 lines
20 KiB
Python

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