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

247 lines
8.7 KiB
Python

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