"""End-to-end smoke tests for the procurement domain (PR 1). Covers: * Category list + seed integrity (4 starter rows from migration 222) * Supplier create / list / search / status filter * Contract create / version-history write on update * PO create with line items / structured FX roll-up to CNY * Migration-review surface returns rows whose legacy fields are unresolved; clear endpoint flips them off Auth: writes go through ``admin_headers``; reads also exercise ``admin_headers`` (no separate procurement-admin role yet — gated on global_admin in PR 1, tightened in a later PR). """ from __future__ import annotations from decimal import Decimal import pytest from httpx import AsyncClient pytestmark = pytest.mark.asyncio class TestProcurementCategories: async def test_seed_categories_present( self, client: AsyncClient, admin_headers: dict, ): resp = await client.get( "/api/v1/procurement/categories", headers=admin_headers, ) assert resp.status_code == 200, resp.text codes = {row["code"] for row in resp.json()["data"]} # Migration 222 seeds these four. assert {"software", "hardware", "cloud", "service"} <= codes async def test_create_category_with_custom_code( self, client: AsyncClient, admin_headers: dict, ): resp = await client.post( "/api/v1/procurement/categories", headers=admin_headers, json={ "code": "training", "name_zh": "培训采购", "name_en": "Training", "receipt_semantics": "service_acceptance", "default_reminder_days": 30, }, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["code"] == "training" assert data["receipt_semantics"] == "service_acceptance" async def test_invalid_receipt_semantics_rejected( self, client: AsyncClient, admin_headers: dict, ): resp = await client.post( "/api/v1/procurement/categories", headers=admin_headers, json={ "code": "bogus", "name_zh": "假", "name_en": "Bogus", "receipt_semantics": "magic", }, ) assert resp.status_code == 422 or resp.status_code == 400 # Service raises ValidationError → app exception handler # returns 400. 422 only if Pydantic refuses first. class TestProcurementSuppliers: async def test_create_and_list_supplier( self, client: AsyncClient, admin_headers: dict, ): resp = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={ "name": "Acme SaaS Ltd", "country": "US", "tax_id": "12-3456789", "default_currency": "USD", "payment_terms": "NET30", "contacts": [ {"name": "Bob", "email": "bob@acme.com", "is_primary": True}, ], }, ) assert resp.status_code == 200, resp.text created = resp.json()["data"] assert created["supplier_code"].startswith("SUP-") assert created["status"] == "active" # List shows the new row. list_resp = await client.get( "/api/v1/procurement/suppliers?keyword=Acme", headers=admin_headers, ) assert list_resp.status_code == 200 names = [r["name"] for r in list_resp.json()["data"]] assert "Acme SaaS Ltd" in names async def test_status_filter_excludes_other_statuses( self, client: AsyncClient, admin_headers: dict, ): # Create one of each status await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": "ActiveCo", "status": "active"}, ) await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": "BlacklistedCo", "status": "blacklisted"}, ) active = await client.get( "/api/v1/procurement/suppliers?status=active", headers=admin_headers, ) names = {r["name"] for r in active.json()["data"]} assert "ActiveCo" in names assert "BlacklistedCo" not in names class TestSupplierSensitiveMasking: """Vendor 银行账号 / SWIFT / 税号 are masked for non finance/admin readers and only disclosable via the audited, rate-limited reveal endpoint.""" _PAYLOAD = { "name": "Sensitive Vendor Co", "country": "US", "tax_id": "98-7654321", "bank_name": "Big Bank", "bank_account": "1234567890123456", "swift_code": "BOFAUS3N", } async def _create(self, client, admin_headers): resp = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json=self._PAYLOAD, ) assert resp.status_code == 200, resp.text return resp.json()["data"]["id"] async def test_finance_reads_plaintext( self, client: AsyncClient, admin_headers: dict, finance_headers: dict, ): sid = await self._create(client, admin_headers) resp = await client.get( f"/api/v1/procurement/suppliers/{sid}", headers=finance_headers, ) data = resp.json()["data"] assert data["bank_account"] == "1234567890123456" assert data["swift_code"] == "BOFAUS3N" assert data["tax_id"] == "98-7654321" assert data["sensitive_masked"] is False async def test_non_finance_reader_gets_masked( self, client: AsyncClient, admin_headers: dict, auditor_headers: dict, ): sid = await self._create(client, admin_headers) # list lst = await client.get( "/api/v1/procurement/suppliers?keyword=Sensitive", headers=auditor_headers, ) row = next(r for r in lst.json()["data"] if r["id"] == sid) assert row["sensitive_masked"] is True assert row["bank_account"] != "1234567890123456" assert "1234567890123456" not in (row["bank_account"] or "") # detail det = await client.get( f"/api/v1/procurement/suppliers/{sid}", headers=auditor_headers, ) ddata = det.json()["data"] assert ddata["sensitive_masked"] is True assert ddata["swift_code"] != "BOFAUS3N" async def test_reveal_forbidden_for_non_finance( self, client: AsyncClient, admin_headers: dict, auditor_headers: dict, ): """Round-6 P1 fix: the reveal endpoint's route audience (require_software_read = also asset_admin / auditor / management) is broader than who may see vendor banking/tax PLAINTEXT. A non-finance role (auditor here) must be FORBIDDEN from revealing — previously it got 200 + cleartext, an enumeration hole for every vendor's bank account / tax id / SWIFT.""" sid = await self._create(client, admin_headers) resp = await client.post( f"/api/v1/procurement/suppliers/{sid}/reveal", headers=auditor_headers, ) assert resp.status_code == 403, resp.text async def test_reveal_returns_plaintext_for_admin( self, client: AsyncClient, admin_headers: dict, ): """global_admin (in the plaintext audience) still gets cleartext.""" sid = await self._create(client, admin_headers) resp = await client.post( f"/api/v1/procurement/suppliers/{sid}/reveal", headers=admin_headers, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["bank_account"] == "1234567890123456" assert data["swift_code"] == "BOFAUS3N" assert data["tax_id"] == "98-7654321" class TestProcurementContracts: async def test_create_contract_with_supplier( self, client: AsyncClient, admin_headers: dict, ): sup = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": "Atlassian Inc", "country": "AU"}, ) sid = sup.json()["data"]["id"] ct = await client.post( "/api/v1/procurement/contracts", headers=admin_headers, json={ "title": "Atlassian Cloud Premium 2026", "supplier_id": sid, "contract_type": "master", "total_amount": "120000.00", "currency": "USD", "effective_date": "2026-01-01", "expiry_date": "2026-12-31", "auto_renew": False, "renew_notice_days": 60, "status": "active", }, ) assert ct.status_code == 200, ct.text data = ct.json()["data"] assert data["contract_number"].startswith("CT-") assert data["status"] == "active" async def test_update_writes_version_history( self, client: AsyncClient, admin_headers: dict, ): sup = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": "Zoom Video Communications"}, ) sid = sup.json()["data"]["id"] ct = await client.post( "/api/v1/procurement/contracts", headers=admin_headers, json={ "title": "Zoom Pro", "supplier_id": sid, "total_amount": "50000.00", "currency": "USD", "expiry_date": "2026-08-31", "status": "active", }, ) cid = ct.json()["data"]["id"] # Update total_amount → should write a version row. upd = await client.patch( f"/api/v1/procurement/contracts/{cid}", headers=admin_headers, json={ "total_amount": "55000.00", "change_summary": "price bump per renewal addendum", }, ) assert upd.status_code == 200, upd.text detail = await client.get( f"/api/v1/procurement/contracts/{cid}", headers=admin_headers, ) assert detail.status_code == 200 versions = detail.json()["data"]["versions"] assert len(versions) >= 1 v = versions[0] assert "total_amount" in v["changed_fields"] assert v["change_summary"] == "price bump per renewal addendum" class TestProcurementOrders: async def test_create_po_with_items_rolls_up_total_cny( self, client: AsyncClient, admin_headers: dict, ): sup = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": "Lenovo Direct"}, ) sid = sup.json()["data"]["id"] po = await client.post( "/api/v1/procurement/orders", headers=admin_headers, json={ "order_type": "purchase", "supplier_id": sid, "department": "IT", "currency": "CNY", "total_amount": "30000.00", "items": [ {"description": "ThinkPad X1 Carbon", "quantity": "3", "unit_price": "10000.00", "line_total": "30000.00"}, ], "notes": "Round-1 onboarding kit", }, ) assert po.status_code == 200, po.text data = po.json()["data"] assert data["order_number"].startswith("PO-") assert data["currency"] == "CNY" # CNY → fx_rate_to_cny = 1.0, total_cny == total_amount assert Decimal(data["total_cny"]) == Decimal("30000.00") async def test_list_orders_filters_by_status( self, client: AsyncClient, admin_headers: dict, ): sup = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": "FilterCo"}, ) sid = sup.json()["data"]["id"] # Two orders, different statuses. await client.post( "/api/v1/procurement/orders", headers=admin_headers, json={ "order_type": "purchase", "supplier_id": sid, "status": "draft", "items": [ {"description": "thing-A", "quantity": "1"}, ], }, ) await client.post( "/api/v1/procurement/orders", headers=admin_headers, json={ "order_type": "purchase", "supplier_id": sid, "status": "submitted", "items": [ {"description": "thing-B", "quantity": "1"}, ], }, ) list_resp = await client.get( f"/api/v1/procurement/orders?supplier_id={sid}&status=submitted", headers=admin_headers, ) assert list_resp.status_code == 200 rows = list_resp.json()["data"] # Should only contain the submitted one. assert all(r["status"] == "submitted" for r in rows) assert len(rows) >= 1 class TestProcurementOverview: async def test_overview_returns_starter_shape( self, client: AsyncClient, admin_headers: dict, ): resp = await client.get( "/api/v1/procurement/overview", headers=admin_headers, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] # Every top-level key must be present even with an empty DB — # the FE relies on the shape, never on existence checks. assert "contracts" in data assert "suppliers" in data assert "orders" in data assert isinstance(data["recent_orders"], list) assert isinstance(data["recent_contracts"], list) assert data["orders"]["total"] >= 0 assert data["suppliers"]["active"] >= 0 async def test_overview_counts_after_create( self, client: AsyncClient, admin_headers: dict, ): # Create one active supplier + one PO; verify counters tick up. before = (await client.get( "/api/v1/procurement/overview", headers=admin_headers, )).json()["data"] sup = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": "OverviewCo", "status": "active"}, ) sid = sup.json()["data"]["id"] await client.post( "/api/v1/procurement/orders", headers=admin_headers, json={ "order_type": "purchase", "supplier_id": sid, "currency": "CNY", "items": [{"description": "thing-X", "quantity": "1"}], }, ) after = (await client.get( "/api/v1/procurement/overview", headers=admin_headers, )).json()["data"] assert after["suppliers"]["active"] == before["suppliers"]["active"] + 1 assert after["orders"]["total"] == before["orders"]["total"] + 1 # The new PO should appear in the recent_orders strip. assert any(o["supplier_name"] == "OverviewCo" for o in after["recent_orders"]) class TestProcurementBudgets: async def test_budget_create_then_list_and_balance( self, client: AsyncClient, admin_headers: dict, ): resp = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json={ "year": 2026, "department": "Engineering-Test", "cost_center": "ENG-001", "currency": "CNY", "total_amount": "100000", }, ) assert resp.status_code == 200, resp.text bid = resp.json()["data"]["id"] list_resp = await client.get( "/api/v1/procurement/budgets?year=2026&department=Engineering-Test", headers=admin_headers, ) assert list_resp.status_code == 200 rows = list_resp.json()["data"] assert any(r["id"] == bid for r in rows) # Fresh budget = no lock yet → remaining == total. match = next(r for r in rows if r["id"] == bid) assert float(match["remaining"]) == 100000.0 assert match["pct_used"] == 0.0 assert match["is_over_80"] is False async def test_budget_detail_scope_blocks_asset_admin( self, client: AsyncClient, admin_headers: dict, asset_admin_headers: dict, finance_headers: dict, ): """Budget detail is finance-governance data: the region-scoped asset_admin role passes the coarse require_software_read() gate but must NOT be able to read arbitrary budgets (cross-role over-read). It gets 404 (existence-oracle-safe); finance + admin get 200. """ bres = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json={ "year": 2026, "department": "DEPT-SCOPE-TEST", "currency": "CNY", "total_amount": "50000", }, ) assert bres.status_code == 200, bres.text bid = bres.json()["data"]["id"] # asset_admin: passes the gate but is not finance-tier nor owner. denied = await client.get( f"/api/v1/procurement/budgets/{bid}", headers=asset_admin_headers, ) assert denied.status_code == 404, denied.text # finance: legitimate budget reader. ok_finance = await client.get( f"/api/v1/procurement/budgets/{bid}", headers=finance_headers, ) assert ok_finance.status_code == 200, ok_finance.text assert ok_finance.json()["data"]["id"] == bid # global_admin: always allowed. ok_admin = await client.get( f"/api/v1/procurement/budgets/{bid}", headers=admin_headers, ) assert ok_admin.status_code == 200, ok_admin.text async def test_budget_list_scope_blocks_asset_admin( self, client: AsyncClient, admin_headers: dict, asset_admin_headers: dict, finance_headers: dict, ): """The LIST endpoint is an even better enumeration vector than detail (every department's budget, owner identity, balances — no id needed). It must carry the same finance-tier gate the detail endpoint got in #1249; the region-scoped asset_admin must be refused.""" seed = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json={ "year": 2026, "department": "DEPT-LIST-SCOPE", "currency": "CNY", "total_amount": "12345", }, ) assert seed.status_code == 200, seed.text denied = await client.get( "/api/v1/procurement/budgets?year=2026", headers=asset_admin_headers, ) assert denied.status_code == 403, denied.text ok_finance = await client.get( "/api/v1/procurement/budgets?year=2026", headers=finance_headers, ) assert ok_finance.status_code == 200, ok_finance.text async def test_po_creation_locks_budget( self, client: AsyncClient, admin_headers: dict, ): # Seed a budget. bres = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json={ "year": 2026, "department": "DEPT-LOCK-TEST", "currency": "CNY", "total_amount": "10000", }, ) assert bres.status_code == 200, bres.text bid = bres.json()["data"]["id"] # Create a supplier for the PO. sres = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": "BudgetTestSupplier"}, ) sid = sres.json()["data"]["id"] # PO that matches the (year, dept, currency) tuple → lock fires. po_res = await client.post( "/api/v1/procurement/orders", headers=admin_headers, json={ "order_type": "purchase", "supplier_id": sid, "department": "DEPT-LOCK-TEST", "currency": "CNY", "total_amount": "3500", "requested_at": "2026-06-15T00:00:00Z", "items": [{"description": "thing", "quantity": "1"}], }, ) assert po_res.status_code == 200, po_res.text # Refetch budget — remaining should be 10000 - 3500 = 6500. bget = await client.get( f"/api/v1/procurement/budgets/{bid}", headers=admin_headers, ) bal = bget.json()["data"]["balance"] assert float(bal["locked"]) == 3500.0 assert float(bal["remaining"]) == 6500.0 assert bal["pct_used"] == 35.0 # Activity log shows a single lock event. cons = bget.json()["data"]["consumptions"] assert len(cons) == 1 assert cons[0]["event"] == "lock" assert float(cons[0]["amount"]) == 3500.0 async def test_po_cancel_releases_budget( self, client: AsyncClient, admin_headers: dict, ): bres = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json={ "year": 2026, "department": "DEPT-RELEASE-TEST", "currency": "CNY", "total_amount": "5000", }, ) bid = bres.json()["data"]["id"] sres = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": "ReleaseTestSupplier"}, ) sid = sres.json()["data"]["id"] po_res = await client.post( "/api/v1/procurement/orders", headers=admin_headers, json={ "order_type": "purchase", "supplier_id": sid, "department": "DEPT-RELEASE-TEST", "currency": "CNY", "total_amount": "1200", "requested_at": "2026-06-15T00:00:00Z", "items": [{"description": "thing", "quantity": "1"}], }, ) po_id = po_res.json()["data"]["id"] # After create: locked 1200. bget = await client.get( f"/api/v1/procurement/budgets/{bid}", headers=admin_headers, ) assert float(bget.json()["data"]["balance"]["locked"]) == 1200.0 # Cancel the PO → release event. cancel = await client.patch( f"/api/v1/procurement/orders/{po_id}", headers=admin_headers, json={"status": "cancelled"}, ) assert cancel.status_code == 200, cancel.text # Locked back to 0. bget = await client.get( f"/api/v1/procurement/budgets/{bid}", headers=admin_headers, ) bal = bget.json()["data"]["balance"] assert float(bal["locked"]) == 0.0 assert float(bal["remaining"]) == 5000.0 cons = bget.json()["data"]["consumptions"] events = sorted(c["event"] for c in cons) assert events == ["lock", "release"] async def _budget_and_po( self, client: AsyncClient, admin_headers: dict, *, dept: str, total: str, po_amount: str, ) -> tuple[str, str]: bres = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json={"year": 2026, "department": dept, "currency": "CNY", "total_amount": total}, ) assert bres.status_code == 200, bres.text bid = bres.json()["data"]["id"] sres = await client.post( "/api/v1/procurement/suppliers", headers=admin_headers, json={"name": f"S-{dept}"}, ) sid = sres.json()["data"]["id"] po_res = await client.post( "/api/v1/procurement/orders", headers=admin_headers, json={ "order_type": "purchase", "supplier_id": sid, "department": dept, "currency": "CNY", "total_amount": po_amount, "requested_at": "2026-06-15T00:00:00Z", "items": [{"description": "thing", "quantity": "1"}], }, ) assert po_res.status_code == 200, po_res.text return bid, po_res.json()["data"]["id"] async def _locked(self, client, admin_headers, bid) -> float: bget = await client.get( f"/api/v1/procurement/budgets/{bid}", headers=admin_headers, ) return float(bget.json()["data"]["balance"]["locked"]) async def test_po_amount_edit_resyncs_budget_lock( self, client: AsyncClient, admin_headers: dict, ): # 3500 locked → edit to 5000 → pool must track the new amount. bid, po_id = await self._budget_and_po( client, admin_headers, dept="DEPT-EDIT-UP", total="10000", po_amount="3500", ) assert await self._locked(client, admin_headers, bid) == 3500.0 up = await client.patch( f"/api/v1/procurement/orders/{po_id}", headers=admin_headers, json={"total_amount": "5000"}, ) assert up.status_code == 200, up.text assert await self._locked(client, admin_headers, bid) == 5000.0 # And editing down re-syncs the other direction. down = await client.patch( f"/api/v1/procurement/orders/{po_id}", headers=admin_headers, json={"total_amount": "1000"}, ) assert down.status_code == 200, down.text assert await self._locked(client, admin_headers, bid) == 1000.0 async def test_po_reopen_relocks_budget( self, client: AsyncClient, admin_headers: dict, ): bid, po_id = await self._budget_and_po( client, admin_headers, dept="DEPT-REOPEN", total="5000", po_amount="1200", ) assert await self._locked(client, admin_headers, bid) == 1200.0 # Cancel → released to 0. await client.patch( f"/api/v1/procurement/orders/{po_id}", headers=admin_headers, json={"status": "cancelled"}, ) assert await self._locked(client, admin_headers, bid) == 0.0 # Reopen (admin flips back to submitted) → re-locked to 1200. reopen = await client.patch( f"/api/v1/procurement/orders/{po_id}", headers=admin_headers, json={"status": "submitted"}, ) assert reopen.status_code == 200, reopen.text assert await self._locked(client, admin_headers, bid) == 1200.0 async def test_create_budget_requires_year( self, client: AsyncClient, admin_headers: dict, ): resp = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json={"department": "NO-YEAR", "currency": "CNY", "total_amount": "1000"}, ) assert resp.status_code == 422, resp.text async def test_create_budget_rejects_duplicate_scope( self, client: AsyncClient, admin_headers: dict, ): body = {"year": 2027, "department": "DUP-SCOPE", "currency": "CNY", "total_amount": "1000"} first = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json=body, ) assert first.status_code == 200, first.text dup = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json=body, ) assert dup.status_code == 422, dup.text class TestProcurementReadGateAndValidation: """Regression tests for Round-57 audit fixes. * Reads must use ``require_software_read`` (global_admin + asset_admin + finance + auditor + management) — the v2 rework had regressed the gate to ``require_admin_read`` and locked finance + asset_admin out of their own procurement pages (the legacy ``asset_*`` endpoints always admitted them per the /help permission matrix row 3). * ``/budgets/me`` is self-scoped to any authenticated user (a budget owner may be a plain employee department head with no admin role). * Order money / quantity fields reject negative values. """ async def test_finance_can_read_procurement( self, client: AsyncClient, finance_headers: dict, ): resp = await client.get( "/api/v1/procurement/categories", headers=finance_headers, ) assert resp.status_code == 200, resp.text async def test_asset_admin_can_read_procurement( self, client: AsyncClient, asset_admin_headers: dict, ): resp = await client.get( "/api/v1/procurement/suppliers", headers=asset_admin_headers, ) assert resp.status_code == 200, resp.text async def test_plain_employee_still_blocked_on_admin_reads( self, client: AsyncClient, employee_headers: dict, ): resp = await client.get( "/api/v1/procurement/categories", headers=employee_headers, ) assert resp.status_code == 403, resp.text async def test_finance_cannot_write_procurement( self, client: AsyncClient, finance_headers: dict, ): resp = await client.post( "/api/v1/procurement/categories", headers=finance_headers, json={"code": "FIN-NOWRITE", "name_zh": "x", "name_en": "x"}, ) assert resp.status_code == 403, resp.text async def test_budgets_me_self_scoped_for_employee_owner( self, client: AsyncClient, admin_headers: dict, employee_headers: dict, employee_user, ): # Admin creates a budget owned by the plain employee. emp, _ = employee_user created = await client.post( "/api/v1/procurement/budgets", headers=admin_headers, json={"year": 2028, "department": "SELF-SCOPE-DEPT", "currency": "CNY", "total_amount": "5000", "owner_person_id": str(emp.id)}, ) assert created.status_code == 200, created.text # The employee (no admin role) can read their own budgets. mine = await client.get( "/api/v1/procurement/budgets/me", headers=employee_headers, ) assert mine.status_code == 200, mine.text rows = mine.json()["data"] assert any(r["department"] == "SELF-SCOPE-DEPT" for r in rows) async def test_negative_order_total_rejected( self, client: AsyncClient, admin_headers: dict, ): resp = await client.post( "/api/v1/procurement/orders", headers=admin_headers, json={"order_type": "purchase", "currency": "CNY", "total_amount": "-100"}, ) assert resp.status_code == 422, resp.text async def test_negative_line_total_rejected( self, client: AsyncClient, admin_headers: dict, ): resp = await client.post( "/api/v1/procurement/orders", headers=admin_headers, json={"order_type": "purchase", "currency": "CNY", "items": [{"description": "x", "quantity": "1", "line_total": "-5"}]}, ) assert resp.status_code == 422, resp.text