mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
1270 lines
34 KiB
Python
1270 lines
34 KiB
Python
"""
|
|
Unit testing for the Stock API
|
|
"""
|
|
|
|
import os
|
|
import io
|
|
import tablib
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
import django.http
|
|
from django.urls import reverse
|
|
from rest_framework import status
|
|
|
|
from InvenTree.status_codes import StockStatus
|
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
|
|
|
from common.models import InvenTreeSetting
|
|
import company.models
|
|
import part.models
|
|
from stock.models import StockItem, StockLocation
|
|
|
|
|
|
class StockAPITestCase(InvenTreeAPITestCase):
|
|
|
|
fixtures = [
|
|
'category',
|
|
'part',
|
|
'bom',
|
|
'company',
|
|
'location',
|
|
'supplier_part',
|
|
'stock',
|
|
'stock_tests',
|
|
]
|
|
|
|
roles = [
|
|
'stock.change',
|
|
'stock.add',
|
|
'stock_location.change',
|
|
'stock_location.add',
|
|
'stock.delete',
|
|
]
|
|
|
|
def setUp(self):
|
|
|
|
super().setUp()
|
|
|
|
|
|
class StockLocationTest(StockAPITestCase):
|
|
"""
|
|
Series of API tests for the StockLocation API
|
|
"""
|
|
list_url = reverse('api-location-list')
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
# Add some stock locations
|
|
StockLocation.objects.create(name='top', description='top category')
|
|
|
|
def test_list(self):
|
|
# Check that we can request the StockLocation list
|
|
response = self.client.get(self.list_url, format='json')
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertGreaterEqual(len(response.data), 1)
|
|
|
|
def test_add(self):
|
|
# 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):
|
|
|
|
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):
|
|
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(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)
|
|
|
|
|
|
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):
|
|
|
|
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):
|
|
|
|
def get_url(self):
|
|
return reverse('api-stock-test-result-list')
|
|
|
|
def test_list(self):
|
|
|
|
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):
|
|
# 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'])
|
|
|
|
|
|
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 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):
|
|
|
|
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):
|
|
|
|
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)
|