wrolpi/modules/inventory/test/test_inventory.py
Roland 58de8c7c5b
Upgrading Sanic and yt-dlp. Major changes occurred because Sanic changed how multiprocessing works.
Python 3.11 is now required, old versions were causing unpredictability in tests.  (Sanic does not yet
support 3.12)

Sanic has been upgraded to 23.6.0, which is the latest version that avoids this bug:
 https://github.com/sanic-org/sanic/issues/2921

New strategy for multiprocessing is to create all multiprocessing tools in one process, then fork
to other processes.  The previous strategy was to declare multiprocessing tools at the top of every
file, or wherever they were needed at import/creation.  Now all multiprocessing tools are attached to the
app.shared_ctx.  This means `api_app` is imported in many, many places.

This forced a change in how the DownloadManager works.  Previously, it would continually run
download workers which would pull downloads from a multiprocessing.Queue.  Now, a single worker checks
for new downloads and sends a Sanic signal.

Flags have been reworked to use the `api_app`.  I removed the `which` flag functionality because the
`which` are called at import and needed their own multiprocessing.Event.
2024-04-30 10:55:25 -06:00

330 lines
13 KiB
Python

from decimal import Decimal
from itertools import zip_longest
from typing import List, Iterable
import pytest
import sqlalchemy
import yaml
from pint import Quantity
from wrolpi.common import Base
from wrolpi.db import get_db_session
from wrolpi.api_utils import api_app
from wrolpi.test.common import PytestCase
from .. import init
from ..common import sum_by_key, get_inventory_by_category, get_inventory_by_subcategory, get_inventory_by_name, \
compact_unit, cleanup_quantity, save_inventories_file, import_inventories_file, get_inventories_config
from ..errors import NoInventories, InventoriesVersionMismatch
from ..inventory import get_inventories, save_inventory, update_inventory, \
delete_inventory, get_categories, get_unit_registry
from ..models import Item, Inventory
UNIT_REGISTRY = get_unit_registry()
TEST_ITEMS_COLUMNS = (
'inventory_id',
'brand',
'name',
'item_size',
'unit',
'count',
'category',
'subcategory',
'deleted_at',
'serving',
'expiration_date',
'purchase_date',
)
TEST_ITEMS_TUPLES = [
(1, 'Wheaters', 'Red Wheat', 500, 'oz', 1, 'grains', 'wheat'),
(1, 'Wheaters', 'Red Wheat', 55, 'pounds', 2, 'grains', 'wheat'),
(1, 'Ricey', 'White Rice', 8, 'pounds', 1, 'grains', 'rice'),
(1, 'Chewy', 'Chicken Breast', 16, 'oz', 8, 'meats', 'canned'),
(1, 'Chewy', 'Beef', 16, 'oz', 12, 'meats', 'canned'),
(1, 'Vibrant', 'Peaches', 24, 'oz', 2, 'fruits', 'canned'),
(1, 'Vibrant', 'Pineapple', Decimal('22.3'), 'oz', 1, 'fruits', 'canned'),
# These are in the same category, but have conflicting unit types. (gallon vs pound)
(1, 'White', 'Vinegar', 1, 'gallon', 1, 'cooking ingredients', 'vinegar'),
(1, 'Salty', 'Salt', 1, 'pound', 1, 'cooking ingredients', 'salt'),
# This item is deleted, and should always be ignored.
(1, 'deleted', 'deleted', Decimal('1'), 'oz', 1, 'fruits', 'canned', '2020-01-01'),
]
TEST_ITEMS = [dict(zip(TEST_ITEMS_COLUMNS, i)) for i in TEST_ITEMS_TUPLES]
def extract_items(lst: List[Base], keys: Iterable[str]) -> List[Base]:
extracted = []
for i in lst:
extracted.append({j: getattr(i, j) for j in keys})
return extracted
class TestInventory(PytestCase):
@staticmethod
def prepare() -> None:
"""
This exists rather than setUp because the DB is wrapped.
"""
init(force=True)
with get_db_session(commit=True) as session:
for item in TEST_ITEMS:
item = Item(**item)
session.add(item)
def test_get_categories(self, test_session):
self.prepare()
categories = get_categories()
self.assertGreater(len(categories), 0)
def test_get_inventories(self, test_session):
self.prepare()
inventories = get_inventories()
dates = extract_items(inventories, {'created_at', 'deleted_at', 'viewed_at'})
self.assertItemsTruthyOrFalsey(dates, [{'created_at': True, 'deleted_at': False, 'viewed_at': False}])
self.assertEqual(inventories[0].id, 1)
self.assertEqual(inventories[0].name, 'Food Storage')
def test_inventory1(self, test_session):
self.prepare()
# Insert a new Inventory.
inventory = {
'name': 'New Inventory',
'viewed_at': 'asdf', # This should be ignored.
}
save_inventory(inventory)
with get_db_session() as session:
i1, i2 = session.query(Inventory).order_by(Inventory.name).all()
self.assertDictContains(i2, {'name': 'New Inventory', 'viewed_at': None})
# Inventories cannot share a name.
self.assertRaises(sqlalchemy.exc.IntegrityError, save_inventory, inventory)
# Insert a second inventory.
inventory['name'] = 'Super Inventory'
save_inventory(inventory)
# Cannot update the name to a name that is already used.
with get_db_session() as session:
i = session.query(Inventory).filter_by(name='Super Inventory').one()
inventory['name'] = 'New Inventory'
self.assertRaises(sqlalchemy.exc.IntegrityError, update_inventory, i.id, inventory)
# Add some items to "New Inventory"
with get_db_session(commit=True) as session:
before_item_count = session.query(Item).count()
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
# You can rename a inventory to a conflicting name, if the other inventory is marked as deleted.
with get_db_session(commit=True) as session:
delete_inventory(2)
# Check that the items from the deleted Inventory were not deleted, YET.
self.assertEqual(before_item_count + 4, session.query(Item).count())
i = session.query(Inventory).filter_by(name='Super Inventory').one()
inventory['name'] = 'New Inventory'
update_inventory(i.id, inventory)
# Check that the items from the deleted Inventory were really deleted.
self.assertEqual(before_item_count, session.query(Item).count())
def test_get_inventory_by_category(self, test_session):
self.prepare()
expected = [
dict(category='cooking ingredients', total_size=Decimal('1'), unit='gallon'),
dict(category='cooking ingredients', total_size=Decimal('1'), unit='pound'),
dict(category='fruits', total_size=Decimal('4.39375'), unit='pound'),
dict(category='grains', total_size=Decimal('149.25'), unit='pound'),
dict(category='meats', total_size=Decimal('20'), unit='pound'),
]
inventory = get_inventory_by_category(1)
for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
self.assertEqual(i, j, f'category inventory {idx} is not equal')
def test_get_inventory_by_subcategory(self, test_session):
self.prepare()
expected = [
dict(category='cooking ingredients', subcategory='salt', total_size=Decimal('1'), unit='pound'),
dict(category='cooking ingredients', subcategory='vinegar', total_size=Decimal('1'), unit='gallon'),
dict(category='fruits', subcategory='canned', total_size=Decimal('4.39375'), unit='pound'),
dict(category='grains', subcategory='rice', total_size=Decimal('8'), unit='pound'),
dict(category='grains', subcategory='wheat', total_size=Decimal('141.25'), unit='pound'),
dict(category='meats', subcategory='canned', total_size=Decimal('20'), unit='pound'),
]
inventory = get_inventory_by_subcategory(1)
for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
self.assertEqual(i, j, f'subcategory inventory {idx} is not equal')
def test_get_inventory_by_name(self, test_session):
self.prepare()
expected = [
dict(brand='Chewy', name='Beef', total_size=Decimal('12'), unit='pound'),
dict(brand='Chewy', name='Chicken Breast', total_size=Decimal('8'), unit='pound'),
dict(brand='Ricey', name='White Rice', total_size=Decimal('8'), unit='pound'),
dict(brand='Salty', name='Salt', total_size=Decimal('1'), unit='pound'),
dict(brand='Vibrant', name='Peaches', total_size=Decimal('3'), unit='pound'),
dict(brand='Vibrant', name='Pineapple', total_size=Decimal('1.39375'), unit='pound'),
dict(brand='Wheaters', name='Red Wheat', total_size=Decimal('141.25'), unit='pound'),
dict(brand='White', name='Vinegar', total_size=Decimal('1'), unit='gallon'),
]
inventory = get_inventory_by_name(1)
for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
self.assertEqual(i, j, f'named inventory {idx} is not equal')
def quantity_to_string(quantity: Quantity) -> str:
quantity = cleanup_quantity(quantity)
num, (units,) = quantity.to_tuple()
(unit, _) = units
return f'{num} {unit}'
@pytest.mark.parametrize(
'quantity,expected',
[
(Decimal(5) * UNIT_REGISTRY.ounce, '5 ounce'),
(Decimal(16) * UNIT_REGISTRY.ounce, '1 pound'),
(Decimal(500) * UNIT_REGISTRY.pound, '500 pound'),
(Decimal(2000) * UNIT_REGISTRY.pound, '1 ton'),
(Decimal(128000) * UNIT_REGISTRY.ounce, '4 ton'),
]
)
def test_compact_unit(quantity, expected):
# Round the result, so we don't have to specify all those zeros for the test definition.
assert quantity_to_string(compact_unit(quantity)) == expected
pound, oz, gram, gallon = UNIT_REGISTRY('pound'), UNIT_REGISTRY('oz'), UNIT_REGISTRY('gram'), UNIT_REGISTRY('gallon')
pound, oz, gram, gallon = pound.units, oz.units, gram.units, gallon.units
mass = pound.dimensionality
length = gallon.dimensionality
@pytest.mark.parametrize(
'items,expected',
[
(
# No conversion is necessary.
[{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'}],
{(('grains',), oz.dimensionality): Decimal('1') * oz},
),
(
# The largest of the units is what is returned.
[
{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'},
{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'lbs'},
],
{(('grains',), pound.dimensionality): Decimal('1.0625') * pound},
),
(
# Items are summed by category.
[
{'category': 'grains', 'count': Decimal('8'), 'item_size': Decimal('24'), 'unit': 'oz'},
{'category': 'grains', 'count': Decimal('2'), 'item_size': Decimal('45'), 'unit': 'lbs'},
{'category': 'fruits', 'count': Decimal('4'), 'item_size': Decimal('1'), 'unit': 'gram'},
],
{
(('grains',), pound.dimensionality): Decimal('102') * pound,
(('fruits',), gram.dimensionality): Decimal('4') * gram,
},
),
(
[
{'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'),
'unit': 'gallon'},
{'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'),
'unit': 'quart'},
{'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'},
],
{
(('cooking ingredients',), gallon.dimensionality): Decimal('1.25') * gallon,
(('cooking ingredients',), oz.dimensionality): Decimal('1') * oz,
},
),
]
)
def test_sum_by_key(items, expected):
items = [Item(**i) for i in items]
assert sum_by_key(items, lambda i: (i.category,)) == expected
def test_no_inventories(test_session, test_directory):
"""Can't save empty inventories."""
try:
save_inventories_file()
except NoInventories:
pass
def test_inventories_version(test_async_client, test_session, test_directory, init_test_inventory):
"""You can't save over a newer version of an inventory."""
config = get_inventories_config()
for item in TEST_ITEMS:
item = Item(**item)
test_session.add(item)
test_session.commit()
# Version is set to 1 on first save.
save_inventories_file()
assert config.version == 1
# Version is incremented when saving.
save_inventories_file()
assert config.version == 2
# Version is greater than what will be saved.
with config.get_file().open('wt') as fh:
config._config['version'] = 5
yaml.dump(config._config, fh)
with pytest.raises(InventoriesVersionMismatch):
save_inventories_file()
@pytest.mark.asyncio
async def test_inventories_config(test_async_client, test_session, test_directory, init_test_inventory):
for item in TEST_ITEMS:
item = Item(**item)
test_session.add(item)
test_session.commit()
# Save the Inventories/Items that were created above.
save_inventories_file()
# Clear out the DB so the import will be tested
test_session.query(Item).delete()
test_session.query(Inventory).delete()
test_session.commit()
import_inventories_file()
inventories = get_inventories()
assert len(inventories) == 1
# ID has increased because we did not reset the sequence when deleting from the table.
assert inventories[0].id == 2
assert inventories[0].name == 'Food Storage'
# All items in the DB match those in the test list, except for the "deleted" item.
non_deleted_items = [i for i in inventories[0].items if i.deleted_at is None]
assert len(non_deleted_items) == len(TEST_ITEMS) - 1
test_items = {(i['name'], i['brand'], i['count']) for i in TEST_ITEMS[:-1]}
db_items = {(i.name, i.brand, i.count) for i in non_deleted_items}
assert test_items == db_items