"""End-to-end ordering flow test exercising the acceptance criteria. Drives the real FastAPI app via TestClient against an in-memory database, using directly-issued auth tokens (bypassing the password-based login endpoints). """ from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.core.access import INTERNAL_USER_SUBJECT from app.core.config import settings from app.core.security import issue_token from app.db.session import Base, get_db from app.main import app from app.models.access import User from app.models.client_access import ClientUser from app.seed_access import seed_access from app.services import ordering_service as svc ORDERING_TENANT = svc.ORDERING_TENANT @pytest.fixture() def client(): engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) def override_get_db(): db = TestingSession() try: yield db finally: db.close() app.dependency_overrides[get_db] = override_get_db with TestClient(app) as test_client: test_client._session_factory = TestingSession # type: ignore[attr-defined] yield test_client app.dependency_overrides.clear() def _admin_headers() -> dict[str, str]: token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"}) return {"Authorization": f"Bearer {token}"} def _customer_headers(db_session_factory, *, account_id: int) -> dict[str, str]: db = db_session_factory() user = db.scalar(select(ClientUser).where(ClientUser.client_account_id == account_id)) payload = { "name": user.full_name, "email": user.email, "role": "client", "tenant_id": user.tenant_id, "client_role": user.role, "user_id": user.id, "client_account_id": user.client_account_id, } db.close() token = issue_token(payload) return {"Authorization": f"Bearer {token}"} def test_full_b2b_ordering_flow(client): admin = _admin_headers() # 1. Admin creates a customer. r = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Acme Farms", "client_code": "ACME"}) assert r.status_code == 201, r.text customer = r.json() customer_id = customer["id"] # 2. Admin creates a user for that customer. r = client.post( f"/api/ordering-admin/customers/{customer_id}/users", headers=admin, json={"full_name": "Buyer Bob", "email": "bob@acme.test", "role": "buyer"}, ) assert r.status_code == 201, r.text # 3. Admin creates products. r = client.post( "/api/ordering-admin/products", headers=admin, json={"name": "Maize 1T", "sku": "MAIZE-1T", "category": "grains", "base_price": 100.0, "min_order_quantity": 1}, ) assert r.status_code == 201, r.text product = r.json() product_id = product["id"] r = client.post( "/api/ordering-admin/products", headers=admin, json={"name": "Hidden Blend", "sku": "HIDDEN-1", "category": "custom_blends", "base_price": 50.0}, ) hidden_product_id = r.json()["id"] # 4. Admin assigns a customer-specific price (contract @ 92.5). r = client.put( f"/api/ordering-admin/customers/{customer_id}/product-prices", headers=admin, json={"product_id": product_id, "unit_price": 92.5, "rule_type": "contract"}, ) assert r.status_code == 200, r.text # 4b. Admin hides the second product from this customer. r = client.put( f"/api/ordering-admin/customers/{customer_id}/visibility", headers=admin, json={"product_id": hidden_product_id, "visible": False}, ) assert r.status_code == 200, r.text # 5. Customer logs in (token) and sees only their available product + price. cust = _customer_headers(client._session_factory, account_id=customer_id) r = client.get("/api/ordering/catalogue", headers=cust) assert r.status_code == 200, r.text catalogue = r.json() skus = {p["sku"] for p in catalogue} assert skus == {"MAIZE-1T"} # hidden product is not visible maize = catalogue[0] assert maize["price"]["unit_price"] == 92.5 assert maize["price"]["price_source"] == "contract" # 6. Customer creates a draft order. r = client.post( "/api/ordering/orders", headers=cust, json={"lines": [{"product_id": product_id, "quantity": 3}], "fulfilment_method": "delivery"}, ) assert r.status_code == 201, r.text draft = r.json() assert draft["status"] == "draft" assert draft["subtotal_ex_gst"] == 277.5 # 3 * 92.5 order_id = draft["id"] # Minimum-order-quantity validation. r = client.post( "/api/ordering/orders", headers=cust, json={"lines": [{"product_id": product_id, "quantity": 0.5}]}, ) # quantity below MOQ of 1 → caught by schema (gt=0 ok) then MOQ check 422. assert r.status_code == 422 # 7. Customer submits the order — price is frozen on the line. r = client.post(f"/api/ordering/orders/{order_id}/submit", headers=cust, json={"purchase_order_number": "PO-77"}) assert r.status_code == 200, r.text submitted = r.json() assert submitted["status"] == "submitted" assert submitted["order_number"].startswith("ORD-") assert submitted["lines"][0]["unit_price"] == 92.5 assert submitted["purchase_order_number"] == "PO-77" # Customer can no longer edit a submitted order. r = client.patch(f"/api/ordering/orders/{order_id}", headers=cust, json={"delivery_notes": "late edit"}) assert r.status_code == 409 # 8. Admin sees the submitted order and manages it. r = client.get("/api/ordering-admin/orders", headers=admin) assert r.status_code == 200, r.text admin_orders = r.json() assert any(o["id"] == order_id for o in admin_orders) assert admin_orders[0]["customer_name"] == "Acme Farms" # Status progression with lifecycle enforcement. r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "confirmed"}) assert r.status_code == 200, r.text # Illegal jump is rejected. r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "completed"}) assert r.status_code == 409 # 9. Xero submission (stub) succeeds and records an invoice id. r = client.post(f"/api/ordering-admin/orders/{order_id}/send-to-xero", headers=admin) assert r.status_code == 200, r.text xero = r.json() assert xero["xero_result"]["status"] == "success" assert xero["xero_result"]["stubbed"] is True assert xero["xero_invoice_id"].startswith("STUB-INV-") assert xero["raw_status"] == "sent_to_xero" def test_customer_isolation_between_companies(client): admin = _admin_headers() # Two customers, each with a user. a = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Alpha", "client_code": "ALPHA"}).json() b = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Beta", "client_code": "BETA"}).json() client.post(f"/api/ordering-admin/customers/{a['id']}/users", headers=admin, json={"full_name": "A1", "email": "a1@alpha.test", "role": "owner"}) client.post(f"/api/ordering-admin/customers/{b['id']}/users", headers=admin, json={"full_name": "B1", "email": "b1@beta.test", "role": "owner"}) product = client.post("/api/ordering-admin/products", headers=admin, json={"name": "Barley 1T", "sku": "BAR-1T", "category": "grains", "base_price": 80.0}).json() headers_a = _customer_headers(client._session_factory, account_id=a["id"]) headers_b = _customer_headers(client._session_factory, account_id=b["id"]) # Alpha creates and submits an order. order = client.post("/api/ordering/orders", headers=headers_a, json={"lines": [{"product_id": product["id"], "quantity": 2}]}).json() # Beta must not be able to read Alpha's order. r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_b) assert r.status_code == 404 # Alpha can. r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_a) assert r.status_code == 200 def test_internal_staff_can_manage_orders(client): # Internal Hunter Stock Feeds staff (not the legacy /admin login) manage the # ordering portal via the client/internal session + manage_ordering perm. db = client._session_factory() seed_access(db) db.commit() admin_user = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one() user_id = admin_user.id db.close() token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user_id}) headers = {"Authorization": f"Bearer {token}"} # Can list customers and create catalogue products through the admin API. assert client.get("/api/ordering-admin/customers", headers=headers).status_code == 200 r = client.post( "/api/ordering-admin/products", headers=headers, json={"name": "Staff Wheat", "sku": "WHEAT-STAFF", "category": "grains", "base_price": 30.0}, ) assert r.status_code == 201, r.text def test_disabled_customer_cannot_order(client): admin = _admin_headers() cust = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Gamma", "client_code": "GAMMA"}).json() client.post(f"/api/ordering-admin/customers/{cust['id']}/users", headers=admin, json={"full_name": "G1", "email": "g1@gamma.test", "role": "buyer"}) client.post("/api/ordering-admin/products", headers=admin, json={"name": "Oats 1T", "sku": "OATS-1T", "category": "grains", "base_price": 70.0}) # Disable the customer account. r = client.patch(f"/api/ordering-admin/customers/{cust['id']}", headers=admin, json={"status": "disabled"}) assert r.status_code == 200 headers = _customer_headers(client._session_factory, account_id=cust["id"]) r = client.get("/api/ordering/catalogue", headers=headers) assert r.status_code == 403