wrolpi/modules/inventory/test/test_inventory.py
Brian S. adce235a7e
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
Strip whitespace from inventory item string values on save
modules/inventory/common.py: _normalize_items now strips leading/trailing whitespace from any string-valued item field before persisting an inventory (name, category, subcategory, location, etc.). Non-string values (numbers, None) are left untouched.

Closes #52
2026-07-30 17:34:46 -06:00

277 lines
13 KiB
Python

import pytest
from modules.inventory import common as inventory_common
from modules.inventory.defaults import (slugify, default_fields, recommended_food_storage_items,
RECOMMENDED_ONE_YEAR_FOOD_COUNTS)
from modules.inventory.errors import InventoryConflict
def test_slugify():
assert slugify('Food Storage') == 'food-storage'
assert slugify(' Tools & Stuff!! ') == 'tools-stuff'
assert slugify('') == 'inventory'
def test_default_fields():
food = default_fields('food')
assert [f['key'] for f in food][:2] == ['brand', 'name']
# Unknown type falls back to food.
assert default_fields('nonexistent')[0]['key'] == 'brand'
# The size field defaults to a mathjs-valid unit (lb, not pound).
size = next(f for f in food if f['key'] == 'item_size')
assert size['type'] == 'quantity'
assert size['unit'] == 'lb'
def test_create_and_list_inventory(test_inventory_configs):
config = test_inventory_configs
inventory = config.create_inventory('Food Storage', 'food')
assert inventory['slug'] == 'food-storage'
inventories = config.all_inventories()
assert len(inventories) == 1
assert inventories[0]['name'] == 'Food Storage'
assert inventories[0]['type'] == 'food'
# all_inventories returns full inventories (fields + items), not summaries.
assert [f['key'] for f in inventories[0]['fields']] == [f['key'] for f in default_fields('food')]
assert inventories[0]['items'] == []
def test_create_inventory_unique_slug(test_inventory_configs):
config = test_inventory_configs
first = config.create_inventory('Tools', 'tool')
second = config.create_inventory('Tools', 'tool')
assert first['slug'] == 'tools'
assert second['slug'] == 'tools-2'
assert len(config.all_inventories()) == 2
def test_save_inventory_items(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory()
# Whole-inventory save: add two items. Ids are assigned server-side.
saved = config.save_inventory(slug, dict(items=[
dict(brand='Ocean', name='Salt', item_size='25', item_size_unit='lb', count='8', bogus='nope'),
dict(name='Rice', count='4'),
]))
items = saved['items']
assert {i['name'] for i in items} == {'Salt', 'Rice'}
assert all(isinstance(i['id'], int) for i in items)
assert len({i['id'] for i in items}) == 2
# Unknown keys not in the field schema are dropped.
assert all('bogus' not in i for i in items)
# Saving again with one item removed persists the deletion.
keep = [i for i in items if i['name'] == 'Rice']
saved = config.save_inventory(slug, dict(items=keep))
assert [i['name'] for i in saved['items']] == ['Rice']
def test_save_inventory_strips_whitespace(food_inventory_factory, test_inventory_configs):
"""Leading/trailing whitespace in string field values is stripped on save (categories, names, etc.)."""
config = test_inventory_configs
slug = food_inventory_factory()
saved = config.save_inventory(slug, dict(items=[
dict(name=' Rice ', category=' grains ', count='4'),
]))
item = saved['items'][0]
assert item['name'] == 'Rice'
assert item['category'] == 'grains'
assert item['count'] == '4'
def test_save_inventory_fields(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory()
new_fields = [
dict(key='name', label='Name', type='text'),
dict(key='location', label='Location', type='location'),
]
saved = config.save_inventory(slug, dict(fields=new_fields))
assert [f['key'] for f in saved['fields']] == ['name', 'location']
# Order is normalized to list order.
assert [f['order'] for f in saved['fields']] == [0, 1]
def test_save_inventory_fields_preserve_compute_metadata(food_inventory_factory, test_inventory_configs):
"""A field's extra `compute` metadata (e.g. count-by-weight) must round-trip through save and re-import."""
config = test_inventory_configs
slug = food_inventory_factory()
compute = {'kind': 'count_by_weight', 'total': 'total_weight', 'unit': 'unit_weight'}
saved = config.save_inventory(slug, dict(fields=[
dict(key='name', label='Name', type='text'),
dict(key='unit_weight', label='Unit Weight', type='quantity', unit='g'),
dict(key='total_weight', label='Total Weight', type='quantity', unit='g'),
dict(key='count', label='Count', type='number', compute=compute),
]))
assert next(f for f in saved['fields'] if f['key'] == 'count')['compute'] == compute
# A fresh config instance re-reads from disk; the metadata persists.
fresh = inventory_common.InventoriesConfig()
fresh.initialize()
restored = fresh.get_inventory(slug)
assert next(f for f in restored['fields'] if f['key'] == 'count')['compute'] == compute
def test_save_inventory_rename_keeps_slug(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory(name='Food Storage')
saved = config.save_inventory(slug, dict(name='Pantry'))
# Slug is a stable id; only the display name changes.
assert saved['slug'] == slug
assert saved['name'] == 'Pantry'
assert config.get_file(slug).is_file()
def test_save_inventory_version_conflict(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory()
current = config.get_inventory(slug)
version = current['version']
# A save with the matching version succeeds and bumps the version.
saved = config.save_inventory(slug, dict(name='First'), expected_version=version)
assert saved['version'] == version + 1
# A stale client (still on the old version) is rejected rather than clobbering.
with pytest.raises(InventoryConflict):
config.save_inventory(slug, dict(name='Stale'), expected_version=version)
def test_delete_inventory(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory()
assert config.get_file(slug).is_file()
config.delete_inventory(slug)
assert not config.get_file(slug).is_file()
assert config.all_inventories() == []
def test_recommended_food_storage_items():
"""The example supply joins the shipped food catalog (per-package nutrition) with the recommended counts."""
items = recommended_food_storage_items()
# One item per recommended count, all resolved against the catalog.
assert len(items) == len(RECOMMENDED_ONE_YEAR_FOOD_COUNTS)
# Kept generic: no brand names.
assert all(not item.get('brand') for item in items)
# Counts are positive strings; the per-package calorie basis comes from the catalog.
assert all(item['count'] and int(item['count']) > 0 for item in items)
rice = next(i for i in items if i['name'] == 'Long Grain White Rice')
assert rice['count'] == '14'
assert rice['calories'] == '8040'
assert rice['item_size'] == '5' and rice['item_size_unit'] == 'lb'
# Total stored calories approximate one adult-year at ~3,000 kcal/day.
total = sum(int(i['calories']) * int(i['count']) for i in items if i.get('calories'))
assert 1_000_000 < total < 1_300_000
def test_seed_defaults_populates_food_storage(test_inventory_configs):
config = test_inventory_configs
config.seed_defaults()
inventories = config.all_inventories()
assert len(inventories) == 1
inv = inventories[0]
assert inv['name'] == 'Food Storage'
assert inv['type'] == 'food'
# Seeded with the full recommended one-adult/one-year supply, with server-assigned ids.
assert len(inv['items']) == len(RECOMMENDED_ONE_YEAR_FOOD_COUNTS)
assert all(isinstance(i['id'], int) for i in inv['items'])
def test_seed_defaults_idempotent(test_inventory_configs):
config = test_inventory_configs
config.seed_defaults()
config.seed_defaults()
assert len(config.all_inventories()) == 1
def _write_backup(config, slug, date, items, name='Food Storage'):
"""Write a dated backup file for an inventory (as the daily save would)."""
from wrolpi.common import write_config_data
backup = config._get_backup_file(slug, date)
backup.parent.mkdir(parents=True, exist_ok=True)
write_config_data(dict(version=9, slug=slug, name=name, type='food',
fields=default_fields('food'), items=items), backup)
return backup
def test_restore_overwrite_replaces_inventory(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory(items=[dict(id=1, name='Rice', count='4'), dict(id=2, name='Beans', count='2')])
_write_backup(config, slug, '20260101', name='Old Name',
items=[dict(id=1, name='Rice', count='99'), dict(id=3, name='Oats', count='5')])
preview = config.preview_restore(slug, '20260101', 'overwrite')
assert {i['id'] for i in preview['add']} == {3} # Oats only in the backup
assert {i['id'] for i in preview['remove']} == {2} # Beans only in the current inventory
assert preview['unchanged'] == 1 # Rice (id 1) is in both
restored = config.apply_restore(slug, '20260101', 'overwrite')
assert restored['name'] == 'Old Name'
assert {i['name'] for i in restored['items']} == {'Rice', 'Oats'}
# Overwrite takes the backup's value for the shared item.
assert next(i for i in restored['items'] if i['name'] == 'Rice')['count'] == '99'
def test_restore_merge_unions_items_by_id(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory(items=[dict(id=1, name='Rice', count='4')])
_write_backup(config, slug, '20260101',
items=[dict(id=1, name='Rice', count='99'), dict(id=2, name='Beans', count='2')])
preview = config.preview_restore(slug, '20260101', 'merge')
assert {i['id'] for i in preview['add']} == {2} # only Beans is new; id 1 already present
assert preview['remove'] == []
restored = config.apply_restore(slug, '20260101', 'merge')
assert {i['name'] for i in restored['items']} == {'Rice', 'Beans'}
# Merge keeps the existing item untouched (does NOT take the backup's count=99 for id 1).
assert next(i for i in restored['items'] if i['name'] == 'Rice')['count'] == '4'
def test_restore_rejects_bad_mode_and_missing_backup(food_inventory_factory, test_inventory_configs):
from wrolpi.errors import ValidationError
config = test_inventory_configs
slug = food_inventory_factory()
with pytest.raises(ValidationError):
config.preview_restore(slug, '20260101', 'bogus')
with pytest.raises(ValidationError):
config.preview_restore(slug, '20260101', 'overwrite') # no such backup file
# A non-YYYYMMDD backup_date must be rejected (path-traversal guard), not used to build a path.
with pytest.raises(ValidationError):
config.preview_restore(slug, '../../../../etc/passwd', 'overwrite')
def test_restore_overwrite_rejects_corrupt_fields(food_inventory_factory, test_inventory_configs):
"""A backup with a malformed field (no 'key') is rejected with a 400, not a 500 inside the save lock."""
from wrolpi.common import write_config_data
from wrolpi.errors import ValidationError
config = test_inventory_configs
slug = food_inventory_factory(items=[dict(id=1, name='Rice', count='4')])
backup = config._get_backup_file(slug, '20260101')
backup.parent.mkdir(parents=True, exist_ok=True)
write_config_data(dict(version=9, slug=slug, name='Food Storage', type='food',
fields=[dict(label='Broken', type='text')], # missing 'key'
items=[]), backup)
with pytest.raises(ValidationError):
config.apply_restore(slug, '20260101', 'overwrite')
def test_config_round_trip(food_inventory_factory, test_inventory_configs):
"""Saving to per-inventory YAML and re-importing preserves fields and items."""
config = test_inventory_configs
slug = food_inventory_factory(name='Food Storage', items=[
dict(brand='Ocean', name='Salt', item_size='25', item_size_unit='lb', count='8'),
dict(brand='Ricey', name='Rice', item_size='10', item_size_unit='lb', count='4'),
])
assert config.get_file(slug).is_file()
# A fresh config instance re-reads the directory from disk.
fresh = inventory_common.InventoriesConfig()
fresh.initialize()
restored = fresh.get_inventory(slug)
assert restored is not None
assert restored['name'] == 'Food Storage'
assert {i['name'] for i in restored['items']} == {'Salt', 'Rice'}
assert [f['key'] for f in restored['fields']] == [f['key'] for f in default_fields('food')]