530 lines
20 KiB
Python
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
|