228 lines
7.6 KiB
Python
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}"
|