From 268b638a04646c6ee661143f8c22ce2de618a800 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 3 Jul 2026 02:10:30 +0000 Subject: [PATCH] Additional unit tests --- src/backend/InvenTree/build/test_api.py | 71 +++++++++++++++++++++ src/backend/InvenTree/build/test_build.py | 32 ++++++++++ src/backend/InvenTree/part/test_bom_item.py | 46 +++++++++++++ 3 files changed, 149 insertions(+) diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 4e815bcfed..a4a35c1a5e 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -1887,6 +1887,77 @@ class BuildLineTests(BuildAPITest): self.assertSetEqual(false_ids, expected_false) self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines}) + def test_filter_consumable_via_part(self): + """Filter BuildLine objects by 'consumable' status, accounting for the underlying part. + + A BuildLine should be treated as 'consumable' if either the BOM line + itself is marked as consumable, or the underlying part is marked as consumable. + """ + assembly = Part.objects.create( + name='Consumable Filter Assembly', + description='Assembly for consumable filter tests', + assembly=True, + ) + + plain = Part.objects.create( + name='Consumable Filter Plain Component', + description='A regular component', + component=True, + ) + + consumable_part = Part.objects.create( + name='Consumable Filter Consumable Part', + description='A part marked as consumable', + component=True, + consumable=True, + ) + + consumable_line_part = Part.objects.create( + name='Consumable Filter Consumable BOM Line', + description='A part which is consumable only via its BOM line', + component=True, + ) + + bom_item_plain = BomItem.objects.create( + part=assembly, sub_part=plain, quantity=1 + ) + bom_item_part_consumable = BomItem.objects.create( + part=assembly, sub_part=consumable_part, quantity=1 + ) + bom_item_line_consumable = BomItem.objects.create( + part=assembly, sub_part=consumable_line_part, quantity=1, consumable=True + ) + + build = Build.objects.create( + part=assembly, + reference='BO-9997', + quantity=1, + title='Consumable Filter Build', + ) + + url = reverse('api-build-line-list') + + response = self.get(url, data={'build': build.pk, 'consumable': True}) + returned_bom_items = {item['bom_item'] for item in response.data} + + self.assertIn(bom_item_part_consumable.pk, returned_bom_items) + self.assertIn(bom_item_line_consumable.pk, returned_bom_items) + self.assertNotIn(bom_item_plain.pk, returned_bom_items) + + response = self.get(url, data={'build': build.pk, 'consumable': False}) + returned_bom_items = {item['bom_item'] for item in response.data} + + self.assertIn(bom_item_plain.pk, returned_bom_items) + self.assertNotIn(bom_item_part_consumable.pk, returned_bom_items) + self.assertNotIn(bom_item_line_consumable.pk, returned_bom_items) + + # Check that the serialized 'consumable' field reflects the combined status + response = self.get( + url, data={'build': build.pk, 'bom_item': bom_item_part_consumable.pk} + ) + self.assertEqual(len(response.data), 1) + self.assertTrue(response.data[0]['consumable']) + def test_output_options(self): """Test output options for the BuildLine endpoint.""" self.run_output_test( diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index eaaa5867c4..f0ce20a72c 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -998,6 +998,38 @@ class AutoAllocationTests(BuildTestBase): self.assertEqual(self.build.allocated_stock.count(), N - 8) + def test_consumable_via_part(self): + """A BOM line should be treated as consumable if the underlying part is consumable. + + Even though 'bom_item_1' itself is not marked as consumable, marking + 'sub_part_1' as consumable should have the same effect as marking the + BOM line itself as consumable. + """ + self.sub_part_1.consumable = True + self.sub_part_1.save() + + self.assertFalse(self.bom_item_1.consumable) + self.assertTrue(self.bom_item_1.is_consumable) + + # The BuildLine should be treated as fully allocated, without any stock allocated + self.assertEqual(self.line_1.allocated_quantity(), 0) + self.assertTrue(self.line_1.is_fully_allocated()) + + # The build should not consider this line when checking for unallocated lines + unallocated_lines = self.build.unallocated_lines(tracked=False) + self.assertNotIn(self.line_1, unallocated_lines) + + # Auto-allocation should skip this line, even though stock exists + self.build.auto_allocate_stock( + interchangeable=True, substitutes=True, optional_items=True + ) + + self.assertEqual(self.line_1.allocated_quantity(), 0) + self.assertEqual(BuildItem.objects.filter(build_line=self.line_1).count(), 0) + + # The other (non-consumable) line should still be allocated as normal + self.assertEqual(self.line_2.allocated_quantity(), 30) + class ExternalBuildTest(InvenTreeAPITestCase): """Unit tests for external build order functionality.""" diff --git a/src/backend/InvenTree/part/test_bom_item.py b/src/backend/InvenTree/part/test_bom_item.py index 13bebc5e3c..a6dda90ad1 100644 --- a/src/backend/InvenTree/part/test_bom_item.py +++ b/src/backend/InvenTree/part/test_bom_item.py @@ -277,6 +277,52 @@ class BomItemTest(TestCase): self.assertEqual(assembly.can_build, 20) + # Mark 'c4' as consumable at the *part* level (not the BOM line itself) + c4.consumable = True + c4.save() + + bom_item_c4 = BomItem.objects.create(part=assembly, sub_part=c4, quantity=200) + + # The raw BomItem field is unset, but the part is marked as consumable + self.assertFalse(bom_item_c4.consumable) + self.assertTrue(bom_item_c4.is_consumable) + + # A BomItem which is consumable via its part does not alter the can_build calculation + self.assertEqual(assembly.can_build, 20) + + def test_consumable_filter(self): + """Tests for the BomItem.consumable_filter() helper method.""" + assembly = Part.objects.create( + name='Another assembly', description='Made with parts', assembly=True + ) + + c1 = Part.objects.create(name='D1', description='Not consumable') + c2 = Part.objects.create(name='D2', description='Consumable BOM line') + c3 = Part.objects.create( + name='D3', description='Consumable part', consumable=True + ) + + bom_item_1 = BomItem.objects.create(part=assembly, sub_part=c1, quantity=1) + bom_item_2 = BomItem.objects.create( + part=assembly, sub_part=c2, quantity=1, consumable=True + ) + bom_item_3 = BomItem.objects.create(part=assembly, sub_part=c3, quantity=1) + + consumable_items = set( + BomItem.objects.filter(BomItem.consumable_filter(consumable=True)) + ) + non_consumable_items = set( + BomItem.objects.filter(BomItem.consumable_filter(consumable=False)) + ) + + self.assertIn(bom_item_2, consumable_items) + self.assertIn(bom_item_3, consumable_items) + self.assertNotIn(bom_item_1, consumable_items) + + self.assertIn(bom_item_1, non_consumable_items) + self.assertNotIn(bom_item_2, non_consumable_items) + self.assertNotIn(bom_item_3, non_consumable_items) + def test_metadata(self): """Unit tests for the metadata field.""" for model in [BomItem]: