mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
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
277 lines
13 KiB
Python
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')]
|