InvenTree/InvenTree/stock/test_api.py
Oliver 906ae218aa
[Plugin] Allow custom plugins for running validation routines (#3776)
* Adds new plugin mixin for performing custom validation steps

* Adds simple test plugin for custom validation

* Run part name and IPN validators checks through loaded plugins

* Expose more validation functions to plugins:

- SalesOrder reference
- PurchaseOrder reference
- BuildOrder reference

* Remove custom validation of reference fields

- For now, this is too complex to consider given the current incrementing-reference implementation
- Might revisit this at a later stage.

* Custom validation of serial numbers:

- Replace "checkIfSerialNumberExists" method with "validate_serial_number"
- Pass serial number through to custom plugins
- General code / docstring improvements

* Update unit tests

* Update InvenTree/stock/tests.py

Co-authored-by: Matthias Mair <code@mjmair.com>

* Adds global setting to specify whether serial numbers must be unique globally

- Default is false to preserve behaviour

* Improved error message when attempting to create stock item with invalid serial numbers

* Add more detail to existing serial error message

* Add unit testing for serial number uniqueness

* Allow plugins to convert a serial number to an integer (for optimized sorting)

* Add placeholder plugin methods for incrementing and decrementing serial numbers

* Typo fix

* Add improved method for determining the "latest" serial number

* Remove calls to getLatestSerialNumber

* Update validate_serial_number method

- Add option to disable checking for duplicates
- Don't pass optional StockItem through to plugins

* Refactor serial number extraction methods

- Expose the "incrementing" portion to external plugins

* Bug fixes

* Update unit tests

* Fix for get_latest_serial_number

* Ensure custom serial integer values are clipped

* Adds a plugin for validating and generating hexadecimal serial numbers

* Update unit tests

* Add stub methods for batch code functionality

* remove "hex serials" plugin

- Was simply breaking unit tests

* Allow custom plugins to generate and validate batch codes

- Perform batch code validation when StockItem is saved
- Improve display of error message in modal forms

* Fix unit tests for stock app

* Log message if plugin has a duplicate slug

* Unit test fix

Co-authored-by: Matthias Mair <code@mjmair.com>
2022-10-18 23:50:07 +11:00

1378 lines
40 KiB
Python

"""Unit testing for the Stock API."""
import io
import os
from datetime import datetime, timedelta
import django.http
from django.urls import reverse
import tablib
from rest_framework import status
import company.models
import part.models
from common.models import InvenTreeSetting
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
from stock.models import StockItem, StockItemTestResult, StockLocation
class StockAPITestCase(InvenTreeAPITestCase):
"""Mixin for stock api tests."""
fixtures = [
'category',
'part',
'bom',
'company',
'location',
'supplier_part',
'stock',
'stock_tests',
]
roles = [
'stock.change',
'stock.add',
'stock_location.change',
'stock_location.add',
'stock.delete',
]
class StockLocationTest(StockAPITestCase):
"""Series of API tests for the StockLocation API."""
list_url = reverse('api-location-list')
def setUp(self):
"""Setup for all tests."""
super().setUp()
# 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}, 8, 'Cascade with no parent, depth=0'),
({'cascade': False, 'depth': 10}, 8, 'Cascade with no parent, depth=0'),
({'parent': 'null', 'cascade': True, 'depth': 0}, 7, 'Cascade with null parent, depth=0'),
({'parent': 'null', 'cascade': True, 'depth': 10}, 8, 'Cascade with null parent and bigger depth'),
({'parent': 'null', 'cascade': False, 'depth': 10}, 3, 'No cascade even with depth specified with null parent'),
({'parent': 1, 'cascade': False, 'depth': 0}, 2, 'Dont cascade with depth=0 and parent'),
({'parent': 1, 'cascade': True, 'depth': 0}, 2, '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'),
({'parent': 1, 'cascade': True, 'depth': 'abcdefg'}, 2, 'Cascade with invalid depth and parent'),
({'parent': 42}, 8, 'Should return everything if parent_pk is not vaild'),
({'parent': 'null', 'exclude_tree': 1, 'cascade': True}, 5, 'Should return everything except tree with pk=1'),
({'parent': 'null', 'exclude_tree': 42, 'cascade': True}, 8, 'Should return everything because exclude_tree=42 is no valid pk'),
]
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'
]
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'
}
response = self.client.post(self.list_url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
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_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: 27,
StockStatus.DESTROYED: 1,
StockStatus.LOST: 1,
StockStatus.DAMAGED: 0,
StockStatus.REJECTED: 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_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',
'customer',
'location',
'parent',
'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)
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.client.post(
self.list_url,
data={
'part': 4,
'quantity': 10
}
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['location'], 2)
# What if we explicitly set the location to a different value?
response = self.client.post(
self.list_url,
data={
'part': 4,
'quantity': 20,
'location': 1,
}
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['location'], 1)
# And finally, what if we set the location explicitly to None?
response = self.client.post(
self.list_url,
data={
'part': 4,
'quantity': 20,
'location': '',
}
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
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_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.client.post(self.list_url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
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.client.post(self.list_url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
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.client.post(self.list_url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Expected expiry date is 10 days in the future
expiry = datetime.now().date() + timedelta(10)
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.0000')
self.assertEqual(data['purchase_price_currency'], 'AUD')
self.assertEqual(data['purchase_price_string'], 'A$123.0000')
# Update just the amount
data = self.patch(
url,
{
'purchase_price': 456
},
expected_code=200
).data
self.assertEqual(data['purchase_price'], '456.0000')
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)
self.assertEqual(data['purchase_price_string'], '-')
# 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',
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)
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 funtion 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()
response = self.client.post(
url,
data={
'test': 'A test',
'result': True,
},
format='json'
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# This one should pass!
response = self.client.post(
url,
data={
'test': 'A test',
'stock_item': 105,
'result': True,
},
format='json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_post(self):
"""Test creation of a new test result."""
url = self.get_url()
response = self.client.get(url)
n = len(response.data)
data = {
'stock_item': 105,
'test': 'Checked Steam Valve',
'result': False,
'value': '150kPa',
'notes': 'I guess there was just too much pressure?',
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
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)
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': 'Checked Steam Valve',
'result': False,
'value': '150kPa',
'notes': 'I guess there was just too much pressure?',
"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')
# Create some objects (via the API)
for _ii in range(50):
response = self.post(
url,
{
'stock_item': 1,
'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)
# 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,
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')
def setUp(self):
"""Setup for all tests."""
super().setUp()
self.part = part.models.Part.objects.get(pk=25)
self.loc = StockLocation.objects.get(pk=1)
self.sp_1 = company.models.SupplierPart.objects.get(pk=100)
self.sp_2 = company.models.SupplierPart.objects.get(pk=101)
self.item_1 = StockItem.objects.create(
part=self.part,
supplier_part=self.sp_1,
quantity=100,
)
self.item_2 = StockItem.objects.create(
part=self.part,
supplier_part=self.sp_2,
quantity=100,
)
self.item_3 = StockItem.objects.create(
part=self.part,
supplier_part=self.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)