2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00
InvenTree/InvenTree/stock/test_api.py
Oliver cd0d35047d
Order responsible requirement (#6866)
* Add BUILDORDER_REQUIRE_RESPONSIBLE setting

- If set, build orders must specify a responsible owner

* Add responsible required setting to other order models:

- PurchaseOrder
- SalesOrder
- ReturnOrder

* Add unit test

* Adjust unit tests

* Settings updates:

- Only check settings for global and user settings
- Plugin settings are not defined at run-time

* typo fix

* More spelling fixes

* Specify responsible owner pk
2024-03-27 15:25:56 +11:00

1932 lines
65 KiB
Python

"""Unit testing for the Stock API."""
import io
import os
from datetime import datetime, timedelta
from enum import IntEnum
import django.http
from django.core.exceptions import ValidationError
from django.urls import reverse
import tablib
from djmoney.money import Money
from rest_framework import status
import build.models
import company.models
import part.models
from common.models import InvenTreeSetting
from InvenTree.status_codes import StockHistoryCode, StockStatus
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part, PartTestTemplate
from stock.models import (
StockItem,
StockItemTestResult,
StockLocation,
StockLocationType,
)
class StockAPITestCase(InvenTreeAPITestCase):
"""Mixin for stock api tests."""
fixtures = [
'category',
'part',
'test_templates',
'bom',
'company',
'location',
'supplier_part',
'stock',
'stock_tests',
]
roles = [
'stock.change',
'stock.add',
'stock_location.change',
'stock_location.add',
'stock_location.delete',
'stock.delete',
]
class StockLocationTest(StockAPITestCase):
"""Series of API tests for the StockLocation API."""
list_url = reverse('api-location-list')
@classmethod
def setUpTestData(cls):
"""Setup for all tests."""
super().setUpTestData()
# Add some stock locations
StockLocation.objects.create(name='top', description='top category')
def test_list(self):
"""Test the StockLocationList API endpoint."""
test_cases = [
({}, 8, 'no parameters'),
({'parent': 1, 'cascade': False}, 2, 'Filter by parent, no cascading'),
({'parent': 1, 'cascade': True}, 2, 'Filter by parent, cascading'),
({'cascade': True, 'depth': 0}, 7, 'Cascade with no parent, depth=0'),
({'cascade': False, 'depth': 10}, 3, 'Cascade with no parent, depth=10'),
(
{'parent': 1, 'cascade': False, 'depth': 0},
1,
'Dont cascade with depth=0 and parent',
),
(
{'parent': 1, 'cascade': False, 'depth': 1},
2,
'Dont cascade even with depth=1 specified with parent',
),
(
{'parent': 1, 'cascade': True, 'depth': 1},
2,
'Cascade with depth=1 with parent',
),
(
{'exclude_tree': 1, 'cascade': True},
8,
'Should return everything except tree with pk=1',
),
]
for params, res_len, description in test_cases:
response = self.get(self.list_url, params, expected_code=200)
self.assertEqual(len(response.data), res_len, description)
# Check that the required fields are present
fields = [
'pk',
'name',
'description',
'level',
'parent',
'items',
'pathstring',
'owner',
'url',
'icon',
'location_type',
'location_type_detail',
]
response = self.get(self.list_url, expected_code=200)
for result in response.data:
for f in fields:
self.assertIn(
f, result, f'"{f}" is missing in result of StockLocation list'
)
def test_add(self):
"""Test adding StockLocation."""
# Check that we can add a new StockLocation
data = {
'parent': 1,
'name': 'Location',
'description': 'Another location for stock',
}
self.post(self.list_url, data, expected_code=201)
def test_stock_location_delete(self):
"""Test stock location deletion with different parameters."""
class Target(IntEnum):
move_sub_locations_to_parent_move_stockitems_to_parent = (0,)
move_sub_locations_to_parent_delete_stockitems = (1,)
delete_sub_locations_move_stockitems_to_parent = (2,)
delete_sub_locations_delete_stockitems = (3,)
# First, construct a set of template / variant parts
part = Part.objects.create(
name='Part for stock item creation',
description='Part for stock item creation',
category=None,
is_template=False,
)
for i in range(4):
delete_sub_locations: bool = False
delete_stock_items: bool = False
if i in (
Target.move_sub_locations_to_parent_delete_stockitems,
Target.delete_sub_locations_delete_stockitems,
):
delete_stock_items = True
if i in (
Target.delete_sub_locations_move_stockitems_to_parent,
Target.delete_sub_locations_delete_stockitems,
):
delete_sub_locations = True
# Create a parent stock location
parent_stock_location = StockLocation.objects.create(
name='Parent stock location',
description='This is the parent stock location where the sub categories and stock items are moved to',
parent=None,
)
stocklocation_count_before = StockLocation.objects.count()
stock_location_count_before = StockItem.objects.count()
# Create a stock location to be deleted
stock_location_to_delete = StockLocation.objects.create(
name='Stock location to delete',
description='This is the stock location to be deleted',
parent=parent_stock_location,
)
url = reverse(
'api-location-detail', kwargs={'pk': stock_location_to_delete.id}
)
stock_items = []
# Create stock items in the location to be deleted
for jj in range(3):
stock_items.append(
StockItem.objects.create(
batch=f'Batch xyz {jj}',
location=stock_location_to_delete,
part=part,
)
)
child_stock_locations = []
child_stock_locations_items = []
# Create sub location under the stock location to be deleted
for ii in range(3):
child = StockLocation.objects.create(
name=f'Sub-location {ii}',
description='A sub-location of the deleted stock location',
parent=stock_location_to_delete,
)
child_stock_locations.append(child)
# Create stock items in the sub locations
for jj in range(3):
child_stock_locations_items.append(
StockItem.objects.create(
batch=f'B xyz {jj}', part=part, location=child
)
)
# Delete the created stock location
params = {}
if delete_stock_items:
params['delete_stock_items'] = '1'
if delete_sub_locations:
params['delete_sub_locations'] = '1'
response = self.delete(url, params, expected_code=204)
self.assertEqual(response.status_code, 204)
if delete_stock_items:
if i == Target.delete_sub_locations_delete_stockitems:
# Check if all sub-categories deleted
self.assertEqual(
StockItem.objects.count(), stock_location_count_before
)
elif i == Target.move_sub_locations_to_parent_delete_stockitems:
# Check if all stock locations deleted
self.assertEqual(
StockItem.objects.count(),
stock_location_count_before + len(child_stock_locations_items),
)
else:
# Stock locations moved to the parent location
for stock_item in stock_items:
stock_item.refresh_from_db()
self.assertEqual(stock_item.location, parent_stock_location)
if delete_sub_locations:
for child_stock_location_item in child_stock_locations_items:
child_stock_location_item.refresh_from_db()
self.assertEqual(
child_stock_location_item.location, parent_stock_location
)
if delete_sub_locations:
# Check if all sub-locations are deleted
self.assertEqual(
StockLocation.objects.count(), stocklocation_count_before
)
else:
# Check if all sub-locations moved to the parent
for child in child_stock_locations:
child.refresh_from_db()
self.assertEqual(child.parent, parent_stock_location)
def test_stock_location_structural(self):
"""Test the effectiveness of structural stock locations.
Make sure:
- Stock items cannot be created in structural locations
- Stock items cannot be located to structural locations
- Check that stock location change to structural fails if items located into it
"""
# Create our structural stock location
structural_location = StockLocation.objects.create(
name='Structural stock location',
description='This is the structural stock location',
parent=None,
structural=True,
)
stock_item_count_before = StockItem.objects.count()
# Make sure that we get an error if we try to create a stock item in the structural location
with self.assertRaises(ValidationError):
item = StockItem.objects.create(
batch='Stock item which shall not be created',
location=structural_location,
)
# Ensure that the stock item really did not get created in the structural location
self.assertEqual(stock_item_count_before, StockItem.objects.count())
# Create a non-structural location for test stock location change
non_structural_location = StockLocation.objects.create(
name='Non-structural category',
description='This is a non-structural category',
parent=None,
structural=False,
)
# Construct a part for stock item creation
part = Part.objects.create(
name='Part for stock item creation',
description='Part for stock item creation',
category=None,
is_template=False,
)
# Create the test stock item located to a non-structural category
item = StockItem.objects.create(
batch='BBB', location=non_structural_location, part=part
)
# Try to relocate it to a structural location
item.location = structural_location
with self.assertRaises(ValidationError):
item.save()
# Ensure that the item did not get saved to the DB
item.refresh_from_db()
self.assertEqual(item.location.pk, non_structural_location.pk)
# Try to change the non-structural location to structural while items located into it
non_structural_location.structural = True
with self.assertRaises(ValidationError):
non_structural_location.full_clean()
def test_stock_location_icon(self):
"""Test stock location icon inheritance from StockLocationType."""
parent_location = StockLocation.objects.create(name='Parent location')
location_type = StockLocationType.objects.create(
name='Box', description='This is a very cool type of box', icon='fas fa-box'
)
location = StockLocation.objects.create(
name='Test location',
custom_icon='fas fa-microscope',
location_type=location_type,
parent=parent_location,
)
res = self.get(
self.list_url, {'parent': str(parent_location.pk)}, expected_code=200
).json()
self.assertEqual(
res[0]['icon'],
'fas fa-microscope',
'Custom icon from location should be returned',
)
location.custom_icon = ''
location.save()
res = self.get(
self.list_url, {'parent': str(parent_location.pk)}, expected_code=200
).json()
self.assertEqual(
res[0]['icon'],
'fas fa-box',
'Custom icon is None, therefore it should inherit the location type icon',
)
location_type.icon = ''
location_type.save()
res = self.get(
self.list_url, {'parent': str(parent_location.pk)}, expected_code=200
).json()
self.assertEqual(
res[0]['icon'],
'',
'Custom icon and location type icon is None, None should be returned',
)
def test_stock_location_list_filter(self):
"""Test stock location list filters."""
parent_location = StockLocation.objects.create(name='Parent location')
location_type = StockLocationType.objects.create(
name='Box', description='This is a very cool type of box', icon='fas fa-box'
)
location_type2 = StockLocationType.objects.create(
name='Shelf',
description='This is a very cool type of shelf',
icon='fas fa-shapes',
)
StockLocation.objects.create(
name='Test location w. type',
location_type=location_type,
parent=parent_location,
)
StockLocation.objects.create(
name='Test location w. type 2',
parent=parent_location,
location_type=location_type2,
)
StockLocation.objects.create(
name='Test location wo type', parent=parent_location
)
res = self.get(
self.list_url,
{'parent': str(parent_location.pk), 'has_location_type': '1'},
expected_code=200,
).json()
self.assertEqual(len(res), 2)
self.assertEqual(res[0]['name'], 'Test location w. type')
self.assertEqual(res[1]['name'], 'Test location w. type 2')
res = self.get(
self.list_url,
{'parent': str(parent_location.pk), 'location_type': str(location_type.pk)},
expected_code=200,
).json()
self.assertEqual(len(res), 1)
self.assertEqual(res[0]['name'], 'Test location w. type')
res = self.get(
self.list_url,
{'parent': str(parent_location.pk), 'has_location_type': '0'},
expected_code=200,
).json()
self.assertEqual(len(res), 1)
self.assertEqual(res[0]['name'], 'Test location wo type')
res = self.get(
self.list_url,
{
'parent': str(parent_location.pk),
'has_location_type': '0',
'cascade': False,
},
expected_code=200,
).json()
self.assertEqual(len(res), 1)
def test_stock_location_tree(self):
"""Test the StockLocationTree API endpoint."""
# Create a number of new locations
loc = None
for idx in range(50):
loc = StockLocation.objects.create(
name=f'Location {idx}', description=f'Test location {idx}', parent=loc
)
StockLocation.objects.rebuild()
with self.assertNumQueriesLessThan(12):
response = self.get(reverse('api-location-tree'), expected_code=200)
self.assertEqual(len(response.data), StockLocation.objects.count())
for item in response.data:
location = StockLocation.objects.get(pk=item['pk'])
parent = location.parent.pk if location.parent else None
sublocations = location.get_descendants(include_self=False).count()
self.assertEqual(item['name'], location.name)
self.assertEqual(item['parent'], parent)
self.assertEqual(item['sublocations'], sublocations)
class StockLocationTypeTest(StockAPITestCase):
"""Tests for the StockLocationType API endpoints."""
list_url = reverse('api-location-type-list')
def test_list(self):
"""Test that the list endpoint works as expected."""
location_types = [
StockLocationType.objects.create(
name='Type 1', description='Type 1 desc', icon='fas fa-box'
),
StockLocationType.objects.create(
name='Type 2', description='Type 2 desc', icon='fas fa-box'
),
StockLocationType.objects.create(
name='Type 3', description='Type 3 desc', icon='fas fa-box'
),
]
StockLocation.objects.create(name='Loc 1', location_type=location_types[0])
StockLocation.objects.create(name='Loc 2', location_type=location_types[0])
StockLocation.objects.create(name='Loc 3', location_type=location_types[1])
res = self.get(self.list_url, expected_code=200).json()
self.assertEqual(len(res), 3)
self.assertCountEqual([r['location_count'] for r in res], [2, 1, 0])
def test_delete(self):
"""Test that we can delete a location type via API."""
location_type = StockLocationType.objects.create(
name='Type 1', description='Type 1 desc', icon='fas fa-box'
)
self.delete(
reverse('api-location-type-detail', kwargs={'pk': location_type.pk}),
expected_code=204,
)
self.assertEqual(StockLocationType.objects.count(), 0)
def test_create(self):
"""Test that we can create a location type via API."""
self.post(
self.list_url,
{'name': 'Test Type 1', 'description': 'Test desc 1', 'icon': 'fas fa-box'},
expected_code=201,
)
self.assertIsNotNone(
StockLocationType.objects.filter(name='Test Type 1').first()
)
def test_update(self):
"""Test that we can update a location type via API."""
location_type = StockLocationType.objects.create(
name='Type 1', description='Type 1 desc', icon='fas fa-box'
)
res = self.patch(
reverse('api-location-type-detail', kwargs={'pk': location_type.pk}),
{'icon': 'fas fa-shapes'},
expected_code=200,
).json()
self.assertEqual(res['icon'], 'fas fa-shapes')
class StockItemListTest(StockAPITestCase):
"""Tests for the StockItem API LIST endpoint."""
list_url = reverse('api-stock-list')
def get_stock(self, **kwargs):
"""Filter stock and return JSON object."""
response = self.client.get(self.list_url, format='json', data=kwargs)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Return JSON-ified data
return response.data
def test_top_level_filtering(self):
"""Test filtering against "top level" stock location."""
# No filters, should return *all* items
response = self.get(self.list_url, {}, expected_code=200)
self.assertEqual(len(response.data), StockItem.objects.count())
# Filter with "cascade=False" (but no location specified)
# Should not result in any actual filtering
response = self.get(self.list_url, {'cascade': False}, expected_code=200)
self.assertEqual(len(response.data), StockItem.objects.count())
# Filter with "cascade=False" for the top-level location
response = self.get(
self.list_url, {'location': 'null', 'cascade': False}, expected_code=200
)
self.assertTrue(len(response.data) < StockItem.objects.count())
for result in response.data:
self.assertIsNone(result['location'])
# Filter with "cascade=True"
response = self.get(
self.list_url, {'location': 'null', 'cascade': True}, expected_code=200
)
self.assertEqual(len(response.data), StockItem.objects.count())
def test_get_stock_list(self):
"""List *all* StockItem objects."""
response = self.get_stock()
self.assertEqual(len(response), 29)
def test_filter_by_part(self):
"""Filter StockItem by Part reference."""
response = self.get_stock(part=25)
self.assertEqual(len(response), 17)
response = self.get_stock(part=10004)
self.assertEqual(len(response), 12)
def test_filter_by_ipn(self):
"""Filter StockItem by IPN reference."""
response = self.get_stock(IPN='R.CH')
self.assertEqual(len(response), 3)
def test_filter_by_location(self):
"""Filter StockItem by StockLocation reference."""
response = self.get_stock(location=5)
self.assertEqual(len(response), 1)
response = self.get_stock(location=1, cascade=0)
self.assertEqual(len(response), 7)
response = self.get_stock(location=1, cascade=1)
self.assertEqual(len(response), 9)
response = self.get_stock(location=7)
self.assertEqual(len(response), 18)
def test_filter_by_depleted(self):
"""Filter StockItem by depleted status."""
response = self.get_stock(depleted=1)
self.assertEqual(len(response), 1)
response = self.get_stock(depleted=0)
self.assertEqual(len(response), 28)
def test_filter_by_in_stock(self):
"""Filter StockItem by 'in stock' status."""
response = self.get_stock(in_stock=1)
self.assertEqual(len(response), 26)
response = self.get_stock(in_stock=0)
self.assertEqual(len(response), 3)
def test_filter_by_status(self):
"""Filter StockItem by 'status' field."""
codes = {
StockStatus.OK.value: 27,
StockStatus.DESTROYED.value: 1,
StockStatus.LOST.value: 1,
StockStatus.DAMAGED.value: 0,
StockStatus.REJECTED.value: 0,
}
for code in codes.keys():
num = codes[code]
response = self.get_stock(status=code)
self.assertEqual(len(response), num)
def test_filter_by_batch(self):
"""Filter StockItem by batch code."""
response = self.get_stock(batch='B123')
self.assertEqual(len(response), 1)
def test_filter_by_company(self):
"""Test that we can filter stock items by company."""
for cmp in company.models.Company.objects.all():
self.get_stock(company=cmp.pk)
def test_filter_by_serialized(self):
"""Filter StockItem by serialized status."""
response = self.get_stock(serialized=1)
self.assertEqual(len(response), 12)
for item in response:
self.assertIsNotNone(item['serial'])
response = self.get_stock(serialized=0)
self.assertEqual(len(response), 17)
for item in response:
self.assertIsNone(item['serial'])
def test_filter_by_has_batch(self):
"""Test the 'has_batch' filter, which tests if the stock item has been assigned a batch code."""
with_batch = self.get_stock(has_batch=1)
without_batch = self.get_stock(has_batch=0)
n_stock_items = StockItem.objects.all().count()
# Total sum should equal the total count of stock items
self.assertEqual(n_stock_items, len(with_batch) + len(without_batch))
for item in with_batch:
self.assertFalse(item['batch'] in [None, ''])
for item in without_batch:
self.assertTrue(item['batch'] in [None, ''])
def test_filter_by_tracked(self):
"""Test the 'tracked' filter.
This checks if the stock item has either a batch code *or* a serial number
"""
tracked = self.get_stock(tracked=True)
untracked = self.get_stock(tracked=False)
n_stock_items = StockItem.objects.all().count()
self.assertEqual(n_stock_items, len(tracked) + len(untracked))
blank = [None, '']
for item in tracked:
self.assertTrue(item['batch'] not in blank or item['serial'] not in blank)
for item in untracked:
self.assertTrue(item['batch'] in blank and item['serial'] in blank)
def test_filter_by_expired(self):
"""Filter StockItem by expiry status."""
# First, we can assume that the 'stock expiry' feature is disabled
response = self.get_stock(expired=1)
self.assertEqual(len(response), 29)
self.user.is_staff = True
self.user.save()
# Now, ensure that the expiry date feature is enabled!
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
response = self.get_stock(expired=1)
self.assertEqual(len(response), 1)
for item in response:
self.assertTrue(item['expired'])
response = self.get_stock(expired=0)
self.assertEqual(len(response), 28)
for item in response:
self.assertFalse(item['expired'])
# Mark some other stock items as expired
today = datetime.now().date()
for pk in [510, 511, 512]:
item = StockItem.objects.get(pk=pk)
item.expiry_date = today - timedelta(days=pk)
item.save()
response = self.get_stock(expired=1)
self.assertEqual(len(response), 4)
response = self.get_stock(expired=0)
self.assertEqual(len(response), 25)
def test_paginate(self):
"""Test that we can paginate results correctly."""
for n in [1, 5, 10]:
response = self.get_stock(limit=n)
self.assertIn('count', response)
self.assertIn('results', response)
self.assertEqual(len(response['results']), n)
def export_data(self, filters=None):
"""Helper to test exports."""
if not filters:
filters = {}
filters['export'] = 'csv'
response = self.client.get(self.list_url, data=filters)
self.assertEqual(response.status_code, 200)
self.assertTrue(
isinstance(response, django.http.response.StreamingHttpResponse)
)
file_object = io.StringIO(response.getvalue().decode('utf-8'))
dataset = tablib.Dataset().load(file_object, 'csv', headers=True)
return dataset
def test_export(self):
"""Test exporting of Stock data via the API."""
dataset = self.export_data({})
# Check that *all* stock item objects have been exported
self.assertEqual(len(dataset), StockItem.objects.count())
# Expected headers
headers = [
'Part ID',
'Customer ID',
'Location ID',
'Location Name',
'Parent ID',
'Quantity',
'Status',
]
for h in headers:
self.assertIn(h, dataset.headers)
excluded_headers = ['metadata']
for h in excluded_headers:
self.assertNotIn(h, dataset.headers)
# Now, add a filter to the results
dataset = self.export_data({'location': 1})
self.assertEqual(len(dataset), 9)
dataset = self.export_data({'part': 25})
self.assertEqual(len(dataset), 17)
def test_filter_by_allocated(self):
"""Test that we can filter by "allocated" status.
Rules:
- Only return stock items which are 'allocated'
- Either to a build order or sales order
- Test that the results are "distinct" (no duplicated results)
- Ref: https://github.com/inventree/InvenTree/pull/5916
"""
# Create a build order to allocate to
assembly = part.models.Part.objects.create(
name='F Assembly', description='Assembly for filter test', assembly=True
)
component = part.models.Part.objects.create(
name='F Component', description='Component for filter test', component=True
)
bom_item = part.models.BomItem.objects.create(
part=assembly, sub_part=component, quantity=10
)
# Create two build orders
bo_1 = build.models.Build.objects.create(part=assembly, quantity=10)
bo_2 = build.models.Build.objects.create(part=assembly, quantity=20)
# Test that two distinct build line items are created automatically
self.assertEqual(bo_1.build_lines.count(), 1)
self.assertEqual(bo_2.build_lines.count(), 1)
self.assertEqual(
build.models.BuildLine.objects.filter(bom_item=bom_item).count(), 2
)
build_line_1 = bo_1.build_lines.first()
build_line_2 = bo_2.build_lines.first()
# Allocate stock
location = StockLocation.objects.first()
stock_1 = StockItem.objects.create(
part=component, quantity=100, location=location
)
stock_2 = StockItem.objects.create(
part=component, quantity=100, location=location
)
stock_3 = StockItem.objects.create(
part=component, quantity=100, location=location
)
# Allocate stock_1 to two build orders
build.models.BuildItem.objects.create(
stock_item=stock_1, build_line=build_line_1, quantity=5
)
build.models.BuildItem.objects.create(
stock_item=stock_1, build_line=build_line_2, quantity=5
)
# Allocate stock_2 to 1 build orders
build.models.BuildItem.objects.create(
stock_item=stock_2, build_line=build_line_1, quantity=5
)
url = reverse('api-stock-list')
# 3 items when just filtering by part
response = self.get(
url, {'part': component.pk, 'in_stock': True}, expected_code=200
)
self.assertEqual(len(response.data), 3)
# 1 item when filtering by "not allocated"
response = self.get(
url,
{'part': component.pk, 'in_stock': True, 'allocated': False},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], stock_3.pk)
# 2 items when filtering by "allocated"
response = self.get(
url,
{'part': component.pk, 'in_stock': True, 'allocated': True},
expected_code=200,
)
self.assertEqual(len(response.data), 2)
ids = [item['pk'] for item in response.data]
self.assertIn(stock_1.pk, ids)
self.assertIn(stock_2.pk, ids)
def test_query_count(self):
"""Test that the number of queries required to fetch stock items is reasonable."""
def get_stock(data, expected_status=200):
"""Helper function to fetch stock items."""
response = self.client.get(self.list_url, data=data)
self.assertEqual(response.status_code, expected_status)
return response.data
# Create a bunch of StockItem objects
prt = Part.objects.first()
StockItem.objects.bulk_create([
StockItem(part=prt, quantity=1, level=0, tree_id=0, lft=0, rght=0)
for _ in range(100)
])
# List *all* stock items
with self.assertNumQueriesLessThan(25):
get_stock({})
# List all stock items, with part detail
with self.assertNumQueriesLessThan(20):
get_stock({'part_detail': True})
# List all stock items, with supplier_part detail
with self.assertNumQueriesLessThan(20):
get_stock({'supplier_part_detail': True})
# List all stock items, with 'location' and 'tests' detail
with self.assertNumQueriesLessThan(20):
get_stock({'location_detail': True, 'tests': True})
class StockItemTest(StockAPITestCase):
"""Series of API tests for the StockItem API."""
list_url = reverse('api-stock-list')
def setUp(self):
"""Setup for all tests."""
super().setUp()
# Create some stock locations
top = StockLocation.objects.create(name='A', description='top')
StockLocation.objects.create(name='B', description='location b', parent=top)
StockLocation.objects.create(name='C', description='location c', parent=top)
def test_create_default_location(self):
"""Test the default location functionality, if a 'location' is not specified in the creation request."""
# The part 'R_4K7_0603' (pk=4) has a default location specified
response = self.post(
self.list_url, data={'part': 4, 'quantity': 10}, expected_code=201
)
self.assertEqual(response.data['location'], 2)
# What if we explicitly set the location to a different value?
response = self.post(
self.list_url,
data={'part': 4, 'quantity': 20, 'location': 1},
expected_code=201,
)
self.assertEqual(response.data['location'], 1)
# And finally, what if we set the location explicitly to None?
response = self.post(
self.list_url,
data={'part': 4, 'quantity': 20, 'location': ''},
expected_code=201,
)
self.assertEqual(response.data['location'], None)
def test_stock_item_create(self):
"""Test creation of a StockItem via the API."""
# POST with an empty part reference
response = self.client.post(self.list_url, data={'quantity': 10, 'location': 1})
self.assertContains(
response,
'Valid part must be supplied',
status_code=status.HTTP_400_BAD_REQUEST,
)
# POST with an invalid part reference
response = self.client.post(
self.list_url, data={'quantity': 10, 'location': 1, 'part': 10000000}
)
self.assertContains(
response,
'Valid part must be supplied',
status_code=status.HTTP_400_BAD_REQUEST,
)
# POST without quantity
response = self.post(
self.list_url, {'part': 1, 'location': 1}, expected_code=400
)
self.assertIn('Quantity is required', str(response.data))
# POST with quantity and part and location
response = self.post(
self.list_url,
data={'part': 1, 'location': 1, 'quantity': 10},
expected_code=201,
)
def test_stock_item_create_withsupplierpart(self):
"""Test creation of a StockItem via the API, including SupplierPart data."""
# POST with non-existent supplier part
response = self.post(
self.list_url,
data={'part': 1, 'location': 1, 'quantity': 4, 'supplier_part': 1000991},
expected_code=400,
)
self.assertIn('The given supplier part does not exist', str(response.data))
# POST with valid supplier part, no pack size defined
# Get current count of number of parts
part_4 = part.models.Part.objects.get(pk=4)
current_count = part_4.available_stock
response = self.post(
self.list_url,
data={
'part': 4,
'location': 1,
'quantity': 3,
'supplier_part': 5,
'purchase_price': 123.45,
'purchase_price_currency': 'USD',
},
expected_code=201,
)
# Reload part, count stock again
part_4 = part.models.Part.objects.get(pk=4)
self.assertEqual(part_4.available_stock, current_count + 3)
stock_4 = StockItem.objects.get(pk=response.data['pk'])
self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD'))
# POST with valid supplier part, no pack size defined
# Send use_pack_size along, make sure this doesn't break stuff
# Get current count of number of parts
part_4 = part.models.Part.objects.get(pk=4)
current_count = part_4.available_stock
response = self.post(
self.list_url,
data={
'part': 4,
'location': 1,
'quantity': 12,
'supplier_part': 5,
'use_pack_size': True,
'purchase_price': 123.45,
'purchase_price_currency': 'USD',
},
expected_code=201,
)
# Reload part, count stock again
part_4 = part.models.Part.objects.get(pk=4)
self.assertEqual(part_4.available_stock, current_count + 12)
stock_4 = StockItem.objects.get(pk=response.data['pk'])
self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD'))
# POST with valid supplier part, WITH pack size defined - but ignore
# Supplier part 6 is a 100-pack, otherwise same as SP 5
current_count = part_4.available_stock
response = self.post(
self.list_url,
data={
'part': 4,
'location': 1,
'quantity': 3,
'supplier_part': 6,
'use_pack_size': False,
'purchase_price': 123.45,
'purchase_price_currency': 'USD',
},
expected_code=201,
)
# Reload part, count stock again
part_4 = part.models.Part.objects.get(pk=4)
self.assertEqual(part_4.available_stock, current_count + 3)
stock_4 = StockItem.objects.get(pk=response.data['pk'])
self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD'))
# POST with valid supplier part, WITH pack size defined and used
# Supplier part 6 is a 100-pack, otherwise same as SP 5
current_count = part_4.available_stock
response = self.post(
self.list_url,
data={
'part': 4,
'location': 1,
'quantity': 3,
'supplier_part': 6,
'use_pack_size': True,
'purchase_price': 123.45,
'purchase_price_currency': 'USD',
},
expected_code=201,
)
# Reload part, count stock again
part_4 = part.models.Part.objects.get(pk=4)
self.assertEqual(part_4.available_stock, current_count + 3 * 100)
stock_4 = StockItem.objects.get(pk=response.data['pk'])
self.assertEqual(stock_4.purchase_price, Money('1.234500', 'USD'))
def test_creation_with_serials(self):
"""Test that serialized stock items can be created via the API."""
trackable_part = part.models.Part.objects.create(
name='My part',
description='A trackable part',
trackable=True,
default_location=StockLocation.objects.get(pk=1),
)
self.assertEqual(trackable_part.stock_entries().count(), 0)
self.assertEqual(trackable_part.get_stock_count(), 0)
# This should fail, incorrect serial number count
self.post(
self.list_url,
data={'part': trackable_part.pk, 'quantity': 10, 'serial_numbers': '1-20'},
expected_code=400,
)
response = self.post(
self.list_url,
data={'part': trackable_part.pk, 'quantity': 10, 'serial_numbers': '1-10'},
expected_code=201,
)
data = response.data
self.assertEqual(data['quantity'], 10)
sn = data['serial_numbers']
# Check that each serial number was created
for i in range(1, 11):
self.assertTrue(str(i) in sn)
# Check the unique stock item has been created
item = StockItem.objects.get(part=trackable_part, serial=str(i))
# Item location should have been set automatically
self.assertIsNotNone(item.location)
self.assertEqual(str(i), item.serial)
# There now should be 10 unique stock entries for this part
self.assertEqual(trackable_part.stock_entries().count(), 10)
self.assertEqual(trackable_part.get_stock_count(), 10)
def test_default_expiry(self):
"""Test that the "default_expiry" functionality works via the API.
- If an expiry_date is specified, use that
- Otherwise, check if the referenced part has a default_expiry defined
- If so, use that!
- Otherwise, no expiry
Notes:
- Part <25> has a default_expiry of 10 days
"""
# First test - create a new StockItem without an expiry date
data = {'part': 4, 'quantity': 10}
response = self.post(self.list_url, data, expected_code=201)
self.assertIsNone(response.data['expiry_date'])
# Second test - create a new StockItem with an explicit expiry date
data['expiry_date'] = '2022-12-12'
response = self.post(self.list_url, data, expected_code=201)
self.assertIsNotNone(response.data['expiry_date'])
self.assertEqual(response.data['expiry_date'], '2022-12-12')
# Third test - create a new StockItem for a Part which has a default expiry time
data = {'part': 25, 'quantity': 10}
response = self.post(self.list_url, data, expected_code=201)
# Expected expiry date is 10 days in the future
expiry = datetime.now().date() + timedelta(10)
self.assertEqual(response.data['expiry_date'], expiry.isoformat())
# Test result when sending a blank value
data['expiry_date'] = None
response = self.post(self.list_url, data, expected_code=201)
self.assertEqual(response.data['expiry_date'], expiry.isoformat())
def test_purchase_price(self):
"""Test that we can correctly read and adjust purchase price information via the API."""
url = reverse('api-stock-detail', kwargs={'pk': 1})
data = self.get(url, expected_code=200).data
# Check fixture values
self.assertEqual(data['purchase_price'], '123.000000')
self.assertEqual(data['purchase_price_currency'], 'AUD')
# Update just the amount
data = self.patch(url, {'purchase_price': 456}, expected_code=200).data
self.assertEqual(data['purchase_price'], '456.000000')
self.assertEqual(data['purchase_price_currency'], 'AUD')
# Update the currency
data = self.patch(
url, {'purchase_price_currency': 'NZD'}, expected_code=200
).data
self.assertEqual(data['purchase_price_currency'], 'NZD')
# Clear the price field
data = self.patch(url, {'purchase_price': None}, expected_code=200).data
self.assertEqual(data['purchase_price'], None)
# Invalid currency code
data = self.patch(url, {'purchase_price_currency': 'xyz'}, expected_code=400)
data = self.get(url).data
self.assertEqual(data['purchase_price_currency'], 'NZD')
def test_install(self):
"""Test that stock item can be installed into antoher item, via the API."""
# Select the "parent" stock item
parent_part = part.models.Part.objects.get(pk=100)
item = StockItem.objects.create(
part=parent_part, serial='12345688-1230', quantity=1
)
sub_part = part.models.Part.objects.get(pk=50)
sub_item = StockItem.objects.create(part=sub_part, serial='xyz-123', quantity=1)
n_entries = sub_item.tracking_info.count()
self.assertIsNone(sub_item.belongs_to)
url = reverse('api-stock-item-install', kwargs={'pk': item.pk})
# Try to install an item that is *not* in the BOM for this part!
response = self.post(
url,
{
'stock_item': 520,
'note': 'This should fail, as Item #522 is not in the BOM',
},
expected_code=400,
)
self.assertIn(
'Selected part is not in the Bill of Materials', str(response.data)
)
# Now, try to install an item which *is* in the BOM for the parent part
response = self.post(
url,
{'stock_item': sub_item.pk, 'note': 'This time, it should be good!'},
expected_code=201,
)
sub_item.refresh_from_db()
self.assertEqual(sub_item.belongs_to, item)
self.assertEqual(n_entries + 1, sub_item.tracking_info.count())
# Try to install again - this time, should fail because the StockItem is not available!
response = self.post(
url,
{'stock_item': sub_item.pk, 'note': 'Expectation: failure!'},
expected_code=400,
)
self.assertIn('Stock item is unavailable', str(response.data))
# Now, try to uninstall via the API
url = reverse('api-stock-item-uninstall', kwargs={'pk': sub_item.pk})
self.post(url, {'location': 1}, expected_code=201)
sub_item.refresh_from_db()
self.assertIsNone(sub_item.belongs_to)
self.assertEqual(sub_item.location.pk, 1)
def test_return_from_customer(self):
"""Test that we can return a StockItem from a customer, via the API."""
# Assign item to customer
item = StockItem.objects.get(pk=521)
customer = company.models.Company.objects.get(pk=4)
item.customer = customer
item.save()
n_entries = item.tracking_info_count
url = reverse('api-stock-item-return', kwargs={'pk': item.pk})
# Empty POST will fail
response = self.post(url, {}, expected_code=400)
self.assertIn('This field is required', str(response.data['location']))
response = self.post(
url,
{'location': '1', 'notes': 'Returned from this customer for testing'},
expected_code=201,
)
item.refresh_from_db()
# A new stock tracking entry should have been created
self.assertEqual(n_entries + 1, item.tracking_info_count)
# The item is now in stock
self.assertIsNone(item.customer)
def test_convert_to_variant(self):
"""Test that we can convert a StockItem to a variant part via the API."""
category = part.models.PartCategory.objects.get(pk=3)
# First, construct a set of template / variant parts
master_part = part.models.Part.objects.create(
name='Master',
description='Master part which has variants',
category=category,
is_template=True,
)
variants = []
# Construct a set of variant parts
for color in ['Red', 'Green', 'Blue', 'Yellow', 'Pink', 'Black']:
variants.append(
part.models.Part.objects.create(
name=f'{color} Variant',
description='Variant part with a specific color',
variant_of=master_part,
category=category,
)
)
stock_item = StockItem.objects.create(part=master_part, quantity=1000)
url = reverse('api-stock-item-convert', kwargs={'pk': stock_item.pk})
# Attempt to convert to a part which does not exist
response = self.post(url, {'part': 999999}, expected_code=400)
self.assertIn('object does not exist', str(response.data['part']))
# Attempt to convert to a part which is not a valid option
response = self.post(url, {'part': 1}, expected_code=400)
self.assertIn('Selected part is not a valid option', str(response.data['part']))
for variant in variants:
response = self.post(url, {'part': variant.pk}, expected_code=201)
stock_item.refresh_from_db()
self.assertEqual(stock_item.part, variant)
def test_set_status(self):
"""Test API endpoint for setting StockItem status."""
url = reverse('api-stock-change-status')
prt = Part.objects.first()
# Create a bunch of items
items = [StockItem.objects.create(part=prt, quantity=10) for _ in range(10)]
for item in items:
item.refresh_from_db()
self.assertEqual(item.status, StockStatus.OK.value)
data = {
'items': [item.pk for item in items],
'status': StockStatus.DAMAGED.value,
}
self.post(url, data, expected_code=201)
# Check that the item has been updated correctly
for item in items:
item.refresh_from_db()
self.assertEqual(item.status, StockStatus.DAMAGED.value)
self.assertEqual(item.tracking_info.count(), 1)
# Same test, but with one item unchanged
items[0].status = StockStatus.ATTENTION.value
items[0].save()
data['status'] = StockStatus.ATTENTION.value
self.post(url, data, expected_code=201)
for item in items:
item.refresh_from_db()
self.assertEqual(item.status, StockStatus.ATTENTION.value)
self.assertEqual(item.tracking_info.count(), 2)
tracking = item.tracking_info.last()
self.assertEqual(tracking.tracking_type, StockHistoryCode.EDITED.value)
class StocktakeTest(StockAPITestCase):
"""Series of tests for the Stocktake API."""
def test_action(self):
"""Test each stocktake action endpoint, for validation."""
for endpoint in ['api-stock-count', 'api-stock-add', 'api-stock-remove']:
url = reverse(endpoint)
data = {}
# POST with a valid action
response = self.post(url, data)
self.assertIn('This field is required', str(response.data['items']))
data['items'] = [{'no': 'aa'}]
# POST without a PK
response = self.post(url, data, expected_code=400)
self.assertIn('This field is required', str(response.data))
# POST with an invalid PK
data['items'] = [{'pk': 10}]
response = self.post(url, data, expected_code=400)
self.assertContains(
response,
'object does not exist',
status_code=status.HTTP_400_BAD_REQUEST,
)
# POST with missing quantity value
data['items'] = [{'pk': 1234}]
response = self.post(url, data, expected_code=400)
self.assertContains(
response,
'This field is required',
status_code=status.HTTP_400_BAD_REQUEST,
)
# POST with an invalid quantity value
data['items'] = [{'pk': 1234, 'quantity': '10x0d'}]
response = self.post(url, data)
self.assertContains(
response,
'A valid number is required',
status_code=status.HTTP_400_BAD_REQUEST,
)
data['items'] = [{'pk': 1234, 'quantity': '-1.234'}]
response = self.post(url, data)
self.assertContains(
response,
'Ensure this value is greater than or equal to 0',
status_code=status.HTTP_400_BAD_REQUEST,
)
def test_transfer(self):
"""Test stock transfers."""
data = {
'items': [{'pk': 1234, 'quantity': 10}],
'location': 1,
'notes': 'Moving to a new location',
}
url = reverse('api-stock-transfer')
# This should succeed
response = self.post(url, data, expected_code=201)
# Now try one which will fail due to a bad location
data['location'] = 'not a location'
response = self.post(url, data, expected_code=400)
self.assertContains(
response,
'Incorrect type. Expected pk value',
status_code=status.HTTP_400_BAD_REQUEST,
)
class StockItemDeletionTest(StockAPITestCase):
"""Tests for stock item deletion via the API."""
def test_delete(self):
"""Test stock item deletion."""
n = StockItem.objects.count()
# Create and then delete a bunch of stock items
for idx in range(10):
# Create new StockItem via the API
response = self.post(
reverse('api-stock-list'),
{'part': 1, 'location': 1, 'quantity': idx},
expected_code=201,
)
pk = response.data['pk']
self.assertEqual(StockItem.objects.count(), n + 1)
# Request deletion via the API
self.delete(
reverse('api-stock-detail', kwargs={'pk': pk}), expected_code=204
)
self.assertEqual(StockItem.objects.count(), n)
class StockTestResultTest(StockAPITestCase):
"""Tests for StockTestResult APIs."""
def get_url(self):
"""Helper function to get test-result api url."""
return reverse('api-stock-test-result-list')
def test_list(self):
"""Test list endpoint."""
url = self.get_url()
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertGreaterEqual(len(response.data), 4)
response = self.client.get(url, data={'stock_item': 105})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertGreaterEqual(len(response.data), 4)
def test_post_fail(self):
"""Test failing posts."""
# Attempt to post a new test result without specifying required data
url = self.get_url()
self.post(url, data={'test': 'A test', 'result': True}, expected_code=400)
# This one should pass!
self.post(
url,
data={'test': 'A test', 'stock_item': 105, 'result': True},
expected_code=201,
)
def test_post(self):
"""Test creation of a new test result."""
url = self.get_url()
response = self.client.get(url)
n = len(response.data)
# Test upload using test name (legacy method)
# Note that a new test template will be created
data = {
'stock_item': 105,
'test': 'Checked Steam Valve',
'result': False,
'value': '150kPa',
'notes': 'I guess there was just too much pressure?',
}
response = self.post(url, data, expected_code=201)
# Check that a new test template has been created
test_template = PartTestTemplate.objects.get(key='checkedsteamvalve')
response = self.client.get(url)
self.assertEqual(len(response.data), n + 1)
# And read out again
response = self.client.get(url, data={'test': 'Checked Steam Valve'})
self.assertEqual(len(response.data), 1)
test = response.data[0]
self.assertEqual(test['value'], '150kPa')
self.assertEqual(test['user'], self.user.pk)
# Test upload using template reference (new method)
data = {
'stock_item': 105,
'template': test_template.pk,
'result': True,
'value': '75kPa',
}
response = self.post(url, data, expected_code=201)
# Check that a new test template has been created
self.assertEqual(test_template.test_results.all().count(), 2)
# List test results against the template
response = self.client.get(url, data={'template': test_template.pk})
self.assertEqual(len(response.data), 2)
for item in response.data:
self.assertEqual(item['template'], test_template.pk)
def test_post_bitmap(self):
"""2021-08-25.
For some (unknown) reason, prior to fix https://github.com/inventree/InvenTree/pull/2018
uploading a bitmap image would result in a failure.
This test has been added to ensure that there is no regression.
As a bonus this also tests the file-upload component
"""
here = os.path.dirname(__file__)
image_file = os.path.join(here, 'fixtures', 'test_image.bmp')
with open(image_file, 'rb') as bitmap:
data = {
'stock_item': 105,
'test': 'Temperature Test',
'result': False,
'value': '550C',
'notes': 'I guess there was just too much heat?',
'attachment': bitmap,
}
response = self.client.post(self.get_url(), data)
self.assertEqual(response.status_code, 201)
# Check that an attachment has been uploaded
self.assertIsNotNone(response.data['attachment'])
def test_bulk_delete(self):
"""Test that the BulkDelete endpoint works for this model."""
n = StockItemTestResult.objects.count()
tests = []
url = reverse('api-stock-test-result-list')
stock_item = StockItem.objects.get(pk=1)
# Ensure the part is marked as "trackable"
p = stock_item.part
p.trackable = True
p.save()
# Create some objects (via the API)
for _ii in range(50):
response = self.post(
url,
{
'stock_item': stock_item.pk,
'test': f'Some test {_ii}',
'result': True,
'value': 'Test result value',
},
# expected_code=201,
)
tests.append(response.data['pk'])
self.assertEqual(StockItemTestResult.objects.count(), n + 50)
# Filter test results by part
response = self.get(url, {'part': p.pk}, expected_code=200)
self.assertEqual(len(response.data), 50)
# Attempt a delete without providing items
self.delete(url, {}, expected_code=400)
# Now, let's delete all the newly created items with a single API request
# However, we will provide incorrect filters
response = self.delete(
url, {'items': tests, 'filters': {'stock_item': 10}}, expected_code=204
)
self.assertEqual(StockItemTestResult.objects.count(), n + 50)
# Try again, but with the correct filters this time
response = self.delete(
url, {'items': tests, 'filters': {'stock_item': 1}}, expected_code=204
)
self.assertEqual(StockItemTestResult.objects.count(), n)
class StockAssignTest(StockAPITestCase):
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""
URL = reverse('api-stock-assign')
def test_invalid(self):
"""Test invalid assign."""
# Test with empty data
response = self.post(self.URL, data={}, expected_code=400)
self.assertIn('This field is required', str(response.data['items']))
self.assertIn('This field is required', str(response.data['customer']))
# Test with an invalid customer
response = self.post(self.URL, data={'customer': 999}, expected_code=400)
self.assertIn('object does not exist', str(response.data['customer']))
# Test with a company which is *not* a customer
response = self.post(self.URL, data={'customer': 3}, expected_code=400)
self.assertIn('company is not a customer', str(response.data['customer']))
# Test with an empty items list
response = self.post(
self.URL, data={'items': [], 'customer': 4}, expected_code=400
)
self.assertIn('A list of stock items must be provided', str(response.data))
stock_item = StockItem.objects.create(
part=part.models.Part.objects.get(pk=1),
status=StockStatus.DESTROYED.value,
quantity=5,
)
response = self.post(
self.URL,
data={'items': [{'item': stock_item.pk}], 'customer': 4},
expected_code=400,
)
self.assertIn('Item must be in stock', str(response.data['items'][0]))
def test_valid(self):
"""Test valid assign."""
stock_items = []
for i in range(5):
stock_item = StockItem.objects.create(
part=part.models.Part.objects.get(pk=25), quantity=i + 5
)
stock_items.append({'item': stock_item.pk})
customer = company.models.Company.objects.get(pk=4)
self.assertEqual(customer.assigned_stock.count(), 0)
response = self.post(
self.URL, data={'items': stock_items, 'customer': 4}, expected_code=201
)
self.assertEqual(response.data['customer'], 4)
# 5 stock items should now have been assigned to this customer
self.assertEqual(customer.assigned_stock.count(), 5)
class StockMergeTest(StockAPITestCase):
"""Unit tests for merging stock items via the API."""
URL = reverse('api-stock-merge')
@classmethod
def setUpTestData(cls):
"""Setup for all tests."""
super().setUpTestData()
cls.part = part.models.Part.objects.get(pk=25)
cls.loc = StockLocation.objects.get(pk=1)
cls.sp_1 = company.models.SupplierPart.objects.get(pk=100)
cls.sp_2 = company.models.SupplierPart.objects.get(pk=101)
cls.item_1 = StockItem.objects.create(
part=cls.part, supplier_part=cls.sp_1, quantity=100
)
cls.item_2 = StockItem.objects.create(
part=cls.part, supplier_part=cls.sp_2, quantity=100
)
cls.item_3 = StockItem.objects.create(
part=cls.part, supplier_part=cls.sp_2, quantity=50
)
def test_missing_data(self):
"""Test responses which are missing required data."""
# Post completely empty
data = self.post(self.URL, {}, expected_code=400).data
self.assertIn('This field is required', str(data['items']))
self.assertIn('This field is required', str(data['location']))
# Post with a location and empty items list
data = self.post(self.URL, {'items': [], 'location': 1}, expected_code=400).data
self.assertIn('At least two stock items', str(data))
def test_invalid_data(self):
"""Test responses which have invalid data."""
# Serialized stock items should be rejected
data = self.post(
self.URL,
{'items': [{'item': 501}, {'item': 502}], 'location': 1},
expected_code=400,
).data
self.assertIn('Serialized stock cannot be merged', str(data))
# Prevent item duplication
data = self.post(
self.URL,
{'items': [{'item': 11}, {'item': 11}], 'location': 1},
expected_code=400,
).data
self.assertIn('Duplicate stock items', str(data))
# Check for mismatching stock items
data = self.post(
self.URL,
{'items': [{'item': 1234}, {'item': 11}], 'location': 1},
expected_code=400,
).data
self.assertIn('Stock items must refer to the same part', str(data))
# Check for mismatching supplier parts
payload = {
'items': [{'item': self.item_1.pk}, {'item': self.item_2.pk}],
'location': 1,
}
data = self.post(self.URL, payload, expected_code=400).data
self.assertIn('Stock items must refer to the same supplier part', str(data))
def test_valid_merge(self):
"""Test valid merging of stock items."""
# Check initial conditions
n = StockItem.objects.filter(part=self.part).count()
self.assertEqual(self.item_1.quantity, 100)
payload = {
'items': [
{'item': self.item_1.pk},
{'item': self.item_2.pk},
{'item': self.item_3.pk},
],
'location': 1,
'allow_mismatched_suppliers': True,
}
self.post(self.URL, payload, expected_code=201)
self.item_1.refresh_from_db()
# Stock quantity should have been increased!
self.assertEqual(self.item_1.quantity, 250)
# Total number of stock items has been reduced!
self.assertEqual(StockItem.objects.filter(part=self.part).count(), n - 2)
class StockMetadataAPITest(InvenTreeAPITestCase):
"""Unit tests for the various metadata endpoints of API."""
fixtures = [
'category',
'part',
'test_templates',
'bom',
'company',
'location',
'supplier_part',
'stock',
'stock_tests',
]
roles = ['stock.change', 'stock_location.change']
def metatester(self, apikey, model):
"""Generic tester."""
modeldata = model.objects.first()
# Useless test unless a model object is found
self.assertIsNotNone(modeldata)
url = reverse(apikey, kwargs={'pk': modeldata.pk})
# Metadata is initially null
self.assertIsNone(modeldata.metadata)
numstr = f'12{len(apikey)}'
self.patch(
url,
{'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}},
expected_code=200,
)
# Refresh
modeldata.refresh_from_db()
self.assertEqual(
modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}'
)
def test_metadata(self):
"""Test all endpoints."""
for apikey, model in {
'api-location-metadata': StockLocation,
'api-stock-test-result-metadata': StockItemTestResult,
'api-stock-item-metadata': StockItem,
}.items():
self.metatester(apikey, model)