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