v0.1.14 - b2b portal
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
"""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
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Unit tests for the backend-owned ordering pricing engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.session import Base
|
||||
from app.models.ordering import (
|
||||
CatalogueProduct,
|
||||
CustomerPriceAssignment,
|
||||
CustomerProductPrice,
|
||||
PriceList,
|
||||
PriceListItem,
|
||||
PriceTier,
|
||||
)
|
||||
from app.services.ordering_pricing import resolve_price
|
||||
|
||||
CUSTOMER_ID = 1
|
||||
TENANT = "test-tenant"
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||
|
||||
|
||||
def _product(db: Session, **overrides) -> CatalogueProduct:
|
||||
values = {
|
||||
"tenant_id": TENANT,
|
||||
"name": "Maize 1T",
|
||||
"sku": "MAIZE-1T",
|
||||
"category": "grains",
|
||||
"base_price": 100.0,
|
||||
"min_order_quantity": 1.0,
|
||||
"requires_quote": False,
|
||||
}
|
||||
values.update(overrides)
|
||||
product = CatalogueProduct(**values)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
return product
|
||||
|
||||
|
||||
def test_base_price_with_no_rules():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=2)
|
||||
assert res.unit_price == 100.0
|
||||
assert res.price_source == "base"
|
||||
assert res.requires_quote is False
|
||||
assert res.line_total(2) == 200.0
|
||||
|
||||
|
||||
def test_customer_discount_applies_to_base():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, discount_percent=10.0))
|
||||
db.flush()
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.unit_price == 90.0
|
||||
assert res.price_source == "base"
|
||||
assert res.discount_percent == 10.0
|
||||
|
||||
|
||||
def test_price_list_overrides_base():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
|
||||
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id, discount_percent=10.0))
|
||||
db.flush()
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
# Price list wins over base+discount.
|
||||
assert res.unit_price == 80.0
|
||||
assert res.price_source == "price_list"
|
||||
|
||||
|
||||
def test_customer_fixed_price_wins_over_price_list():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
|
||||
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id))
|
||||
db.add(
|
||||
CustomerProductPrice(
|
||||
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=72.5, rule_type="contract"
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.unit_price == 72.5
|
||||
assert res.price_source == "contract"
|
||||
|
||||
|
||||
def test_quantity_tier_wins_when_threshold_met():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
cpp = CustomerProductPrice(
|
||||
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=90.0, rule_type="fixed"
|
||||
)
|
||||
db.add(cpp)
|
||||
db.flush()
|
||||
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=10, unit_price=85.0))
|
||||
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=50, unit_price=80.0))
|
||||
db.flush()
|
||||
|
||||
# Below first tier → flat fixed price.
|
||||
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=5).unit_price == 90.0
|
||||
# Meets first tier.
|
||||
mid = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=10)
|
||||
assert mid.unit_price == 85.0
|
||||
assert mid.price_source == "tiered"
|
||||
# Meets highest tier.
|
||||
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=60).unit_price == 80.0
|
||||
|
||||
|
||||
def test_product_requires_quote():
|
||||
db = _session()
|
||||
product = _product(db, requires_quote=True)
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.requires_quote is True
|
||||
assert res.unit_price is None
|
||||
assert res.price_source == "quote"
|
||||
assert res.line_total(5) is None
|
||||
|
||||
|
||||
def test_customer_quote_rule_forces_quote():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
db.add(
|
||||
CustomerProductPrice(
|
||||
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=None, rule_type="quote"
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.requires_quote is True
|
||||
assert res.price_source == "quote"
|
||||
|
||||
|
||||
def test_no_price_falls_back_to_quote():
|
||||
db = _session()
|
||||
product = _product(db, base_price=None)
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.requires_quote is True
|
||||
assert res.price_source == "quote"
|
||||
Reference in New Issue
Block a user