247 lines
8.7 KiB
Python
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
|