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

228 lines
7.6 KiB
Python

"""AE1 acceptance test: cross-table rollup recalculation end-to-end.
Validates the core v2 scenario from the plan:
Orders.amount changes -> Customers.total_amount (rollup SUM) recalculates.
This test requires PostgreSQL (marked with @pytest.mark.postgres).
It exercises the full stack: BitableService -> repository -> recalc_worker
-> FormulaEngine -> cross-table relation resolution -> rollup aggregation.
No mocks — real PG, real async recalc queue, real cross-table deps.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from agentkit.bitable.models import FieldOwner, FieldType
pytestmark = [pytest.mark.postgres, pytest.mark.asyncio]
@pytest_asyncio.fixture
async def ae1_setup(bitable_service):
"""Set up Customers + Orders tables with a 1:N relation and rollup field.
Also wires a RecalcWorker into the service so rollup/formula fields
actually recalc when records change. Returns a dict with IDs.
"""
svc = bitable_service
# Wire a recalc worker (bitable_service fixture doesn't include one)
from agentkit.bitable.recalc_worker import RecalcWorker
worker = RecalcWorker(svc._db, svc)
svc.set_recalc_worker(worker)
# 1. Create Customers table
customers_table = await svc.create_table(name="Customers")
cust_id_field = await svc._repo.create_field(
table_id=customers_table.id,
name="cust_id",
field_type=FieldType.text,
config={},
owner=FieldOwner.user,
)
cust_name_field = await svc._repo.create_field(
table_id=customers_table.id,
name="name",
field_type=FieldType.text,
config={},
owner=FieldOwner.user,
)
await svc._repo.update_table(customers_table.id, primary_key_field_id=cust_id_field.id)
# 2. Create Orders table
orders_table = await svc.create_table(name="Orders")
ord_id_field = await svc._repo.create_field(
table_id=orders_table.id,
name="ord_id",
field_type=FieldType.text,
config={},
owner=FieldOwner.user,
)
ord_amount_field = await svc._repo.create_field(
table_id=orders_table.id,
name="amount",
field_type=FieldType.number,
config={},
owner=FieldOwner.user,
)
await svc._repo.update_table(orders_table.id, primary_key_field_id=ord_id_field.id)
# 3. Create 1:N relation field on Orders -> Customers
relation_field = await svc.create_relation_field(
table_id=orders_table.id,
name="customer",
target_table_id=customers_table.id,
relation_type="one_to_many",
)
# 4. Create rollup field on Customers: SUM(Orders.amount via relation)
rollup_field = await svc.create_rollup_field(
table_id=customers_table.id,
name="total_amount",
relation_field_id=relation_field.id,
target_field_id=ord_amount_field.id,
aggregation="sum",
)
return {
"customers_table": customers_table,
"orders_table": orders_table,
"cust_id_field": cust_id_field,
"cust_name_field": cust_name_field,
"ord_id_field": ord_id_field,
"ord_amount_field": ord_amount_field,
"relation_field": relation_field,
"rollup_field": rollup_field,
}
async def test_ae1_rollup_sum_recalc_on_insert(bitable_service, ae1_setup) -> None:
"""AE1: Inserting Orders updates Customers.total_amount via rollup SUM."""
svc = bitable_service
cust_fid = ae1_setup["cust_id_field"].id
ord_fid = ae1_setup["ord_id_field"].id
amt_fid = ae1_setup["ord_amount_field"].id
rel_fid = ae1_setup["relation_field"].id
rollup_fid = ae1_setup["rollup_field"].id
# Create a customer
cust = await svc.create_record(
table_id=ae1_setup["customers_table"].id,
values={cust_fid: "c1"},
)
# Create 3 orders linked to the customer
for i in range(3):
order = await svc.create_record(
table_id=ae1_setup["orders_table"].id,
values={ord_fid: f"o{i}", amt_fid: (i + 1) * 100},
)
await svc.add_relation_link(
relation_field_id=rel_fid,
source_record_id=order.id,
target_record_id=cust.id,
)
# Process recalc queue — claim and process all pending tasks
worker = svc._recalc_worker
if worker is None:
pytest.skip("recalc_worker not configured")
tasks = await worker._repo.claim_recalc_tasks(limit=50)
for task in tasks:
await worker.process_task(task)
# Read the customer record — total_amount should be 100+200+300 = 600
updated_cust = await svc._repo.get_record(cust.id)
assert updated_cust is not None
total = updated_cust.values.get(rollup_fid)
assert total == 600, f"Expected total_amount=600, got {total}"
async def test_ae1_rollup_updates_on_order_change(bitable_service, ae1_setup) -> None:
"""AE1: Updating an order's amount triggers rollup recalc on customer."""
svc = bitable_service
cust_fid = ae1_setup["cust_id_field"].id
ord_fid = ae1_setup["ord_id_field"].id
amt_fid = ae1_setup["ord_amount_field"].id
rel_fid = ae1_setup["relation_field"].id
rollup_fid = ae1_setup["rollup_field"].id
cust = await svc.create_record(
table_id=ae1_setup["customers_table"].id,
values={cust_fid: "c1"},
)
order = await svc.create_record(
table_id=ae1_setup["orders_table"].id,
values={ord_fid: "o1", amt_fid: 100},
)
await svc.add_relation_link(rel_fid, order.id, cust.id)
# Drain recalc queue
worker = svc._recalc_worker
if worker is None:
pytest.skip("recalc_worker not configured")
tasks = await worker._repo.claim_recalc_tasks(limit=50)
for task in tasks:
await worker.process_task(task)
# Verify initial sum
rec = await svc._repo.get_record(cust.id)
assert rec.values.get(rollup_fid) == 100
# Update the order amount
await svc.update_record_values(order.id, {amt_fid: 500})
# Drain again
tasks = await worker._repo.claim_recalc_tasks(limit=50)
for task in tasks:
await worker.process_task(task)
# Customer's total should now be 500
rec = await svc._repo.get_record(cust.id)
assert rec.values.get(rollup_fid) == 500, f"Expected 500 after update, got {rec.values.get(rollup_fid)}"
async def test_ae1_rollup_avg_aggregation(bitable_service, ae1_setup) -> None:
"""AE1: Rollup AVG aggregation across related records."""
svc = bitable_service
cust_fid = ae1_setup["cust_id_field"].id
ord_fid = ae1_setup["ord_id_field"].id
amt_fid = ae1_setup["ord_amount_field"].id
rel_fid = ae1_setup["relation_field"].id
# Change aggregation to avg — recreate rollup field
avg_rollup = await svc.create_rollup_field(
table_id=ae1_setup["customers_table"].id,
name="avg_amount",
relation_field_id=rel_fid,
target_field_id=amt_fid,
aggregation="avg",
)
cust = await svc.create_record(
table_id=ae1_setup["customers_table"].id,
values={cust_fid: "c1"},
)
for amt in [100, 200, 300]:
order = await svc.create_record(
table_id=ae1_setup["orders_table"].id,
values={ord_fid: f"o{amt}", amt_fid: amt},
)
await svc.add_relation_link(rel_fid, order.id, cust.id)
# Drain recalc queue
worker = svc._recalc_worker
if worker is None:
pytest.skip("recalc_worker not configured")
tasks = await worker._repo.claim_recalc_tasks(limit=50)
for task in tasks:
await worker.process_task(task)
rec = await svc._repo.get_record(cust.id)
avg = rec.values.get(avg_rollup.id)
assert avg == 200, f"Expected avg=200, got {avg}"