From fcb1e4f23e11a6989bcb3853ce4536c3f742a68a Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 19 Sep 2026 11:54:07 +1000 Subject: [PATCH] PO status fix (#12885) * Fix for PurchaseOrder status code view - Order dependent, due to new viewset * Add regression tests for status code API endpoints --- src/backend/InvenTree/build/test_api.py | 15 +++++ src/backend/InvenTree/order/api.py | 4 +- src/backend/InvenTree/order/test_api.py | 77 +++++++++++++++++++++++++ src/backend/InvenTree/stock/test_api.py | 30 ++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 9068f2ce40..e9b0171260 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -1426,6 +1426,21 @@ class BuildListTest(BuildAPITest): assert_fnc=lambda x: x.data['results'][0], ) + def test_status_codes_endpoint(self): + """The 'build/status/' endpoint must resolve to the status-codes view. + + Regression test: ensures the literal 'status/' path is not shadowed by the + 'build//' detail route it sits alongside in the same urlconf. + """ + response = self.get(reverse('api-build-status-codes'), expected_code=200) + + self.assertIn('status_class', response.data) + self.assertIn('values', response.data) + self.assertIn('PENDING', response.data['values']) + self.assertEqual( + response.data['values']['PENDING']['key'], BuildStatus.PENDING.value + ) + class BuildOutputCreateTest(BuildAPITest): """Unit test for creating build output via API.""" diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index b193665206..7285419569 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -2695,8 +2695,6 @@ class OrderCalendarExport(ICalFeed): order_api_urls = [ - # Purchase Order, Line Item, and Extra Line API endpoints via ViewSet router - path('', include(order_router.urls)), # Purchase order status code information (requires custom kwargs) path( 'po/status/', @@ -2704,6 +2702,8 @@ order_api_urls = [ {StatusView.MODEL_REF: PurchaseOrderStatus}, name='api-po-status-codes', ), + # Purchase Order, Line Item, and Extra Line API endpoints via ViewSet router + path('', include(order_router.urls)), # API endpoints for sales orders path( 'so/', diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index a6103ce393..fbec1bb160 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -999,6 +999,27 @@ class PurchaseOrderTest(OrderTest): self.assertIn('status_text', result) self.assertIsNotNone(result['status_text']) + def test_status_codes_endpoint(self): + """The 'po/status/' endpoint must resolve to the status-codes view. + + Regression test: PurchaseOrder is served by a ViewSet router, whose + generated detail route ('po//') uses DRF's default, permissive pk + lookup regex. That regex is happy to match the literal segment 'status' + as a pk, so if the router is registered ahead of the 'po/status/' path in + the urlconf, this endpoint gets swallowed by + PurchaseOrderViewSet.retrieve(pk='status') instead of reaching StatusView - + returning a 404 (no such PurchaseOrder) rather than the status code data. + """ + response = self.get(reverse('api-po-status-codes'), expected_code=200) + + # A genuine StatusView response - not a PurchaseOrder-detail-shaped 404 + self.assertIn('status_class', response.data) + self.assertIn('values', response.data) + self.assertIn('PENDING', response.data['values']) + self.assertEqual( + response.data['values']['PENDING']['key'], PurchaseOrderStatus.PENDING.value + ) + class PurchaseOrderLineItemTest(OrderTest): """Unit tests for PurchaseOrderLineItems.""" @@ -2489,6 +2510,25 @@ class SalesOrderTest(OrderTest): self.assertIn('status_text', result) self.assertIsNotNone(result['status_text']) + def test_status_codes_endpoint(self): + """The 'so/status/' endpoint must resolve to the status-codes view. + + SalesOrder is not (yet) served by a ViewSet router - its detail route uses + Django's '' path converter, which is not vulnerable to the + router-based bug affecting PurchaseOrder (see PurchaseOrderTest for + details). This is a coverage test guarding against a future regression, + e.g. if SalesOrder is migrated to a router-based viewset without also + restricting the pk lookup pattern. + """ + response = self.get(reverse('api-so-status-codes'), expected_code=200) + + self.assertIn('status_class', response.data) + self.assertIn('values', response.data) + self.assertIn('PENDING', response.data['values']) + self.assertEqual( + response.data['values']['PENDING']['key'], SalesOrderStatus.PENDING.value + ) + class SalesOrderLineItemTest(OrderTest): """Tests for the SalesOrderLineItem API.""" @@ -3888,6 +3928,23 @@ class ReturnOrderTests(InvenTreeAPITestCase): reverse('api-return-order-detail', kwargs={'pk': 1}), ['customer_detail'] ) + def test_status_codes_endpoint(self): + """The 'ro/status/' endpoint must resolve to the status-codes view. + + ReturnOrder is not (yet) served by a ViewSet router - its detail route uses + Django's '' path converter, which is not vulnerable to the + router-based bug affecting PurchaseOrder (see PurchaseOrderTest for + details). This is a coverage test guarding against a future regression. + """ + response = self.get(reverse('api-return-order-status-codes'), expected_code=200) + + self.assertIn('status_class', response.data) + self.assertIn('values', response.data) + self.assertIn('PENDING', response.data['values']) + self.assertEqual( + response.data['values']['PENDING']['key'], ReturnOrderStatus.PENDING.value + ) + class ReturnOrderLineItemTests(InvenTreeAPITestCase): """Unit tests for ReturnOrderLineItem API endpoints.""" @@ -4051,6 +4108,26 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase): self.assertEqual(models.ReturnOrderExtraLine.objects.count(), n - 2) + def test_status_codes_endpoint(self): + """The 'ro-line/status/' endpoint must resolve to the status-codes view. + + ReturnOrderLineItem is not (yet) served by a ViewSet router - its detail + route uses Django's '' path converter, which is not vulnerable to + the router-based bug affecting PurchaseOrder (see PurchaseOrderTest for + details). This is a coverage test guarding against a future regression. + """ + response = self.get( + reverse('api-return-order-line-status-codes'), expected_code=200 + ) + + self.assertIn('status_class', response.data) + self.assertIn('values', response.data) + self.assertIn('PENDING', response.data['values']) + self.assertEqual( + response.data['values']['PENDING']['key'], + ReturnOrderLineStatus.PENDING.value, + ) + class ExtraLineTotalPriceTest(InvenTreeAPITestCase): """Unit tests for the 'total_price' field on ExtraLine API endpoints. diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 6aecf8c85e..7eac0ba685 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2417,6 +2417,19 @@ class StockItemTest(StockAPITestCase): item.refresh_from_db() self.assertEqual(item.batch, 'NEW-BATCH-CODE') + def test_status_codes_endpoint(self): + """The 'stock/status/' endpoint must resolve to the status-codes view. + + Regression test: ensures the literal 'status/' path is not shadowed by the + 'stock//' detail route it sits alongside in the same urlconf. + """ + response = self.get(reverse('api-stock-status-codes'), expected_code=200) + + self.assertIn('status_class', response.data) + self.assertIn('values', response.data) + self.assertIn('OK', response.data['values']) + self.assertEqual(response.data['values']['OK']['key'], StockStatus.OK.value) + class StockItemDisassembleTest(StockAPITestCase): """Series of API tests for the StockItem disassembly endpoint.""" @@ -4051,6 +4064,23 @@ class StockTrackingTest(StockAPITestCase): assert_fnc=lambda x: x.data['results'][0], ) + def test_status_codes_endpoint(self): + """The 'track/status/' endpoint must resolve to the status-codes view. + + Regression test: ensures the literal 'status/' path is not shadowed by the + 'track//' detail route it sits alongside in the same urlconf. + """ + response = self.get( + reverse('api-stock-tracking-status-codes'), expected_code=200 + ) + + self.assertIn('status_class', response.data) + self.assertIn('values', response.data) + self.assertIn('CREATED', response.data['values']) + self.assertEqual( + response.data['values']['CREATED']['key'], StockHistoryCode.CREATED.value + ) + class StockAssignTest(StockAPITestCase): """Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""