Schritt 1: Fundament der Lebensmittel-Lagerverwaltung (Pantry)
Backend (FastAPI + PostgreSQL): Chargen mit MHD, FEFO-Auslagern, Einheiten-Umrechnung (Stueck/g/ml + Packungen), Open-Food-Facts-Lookup mit lokalem Fallback, JWT-Auth mit Rollen (Admin/Nutzer), erster Admin beim Setup, Einkaufsliste, Ablaufwarnung, Lagerorte, pytest fuer FEFO. Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte, Lagerorte, Benutzerverwaltung, Einkaufsliste - rollenabhaengig. Deploy: docker-compose + install.sh (Docker-Autoinstall, Secrets), README und Roadmap fuer Schritt 2 (iOS) und Schritt 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
36
backend/tests/conftest.py
Normal file
36
backend/tests/conftest.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.models import BaseUnit, Product
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
session = TestingSession()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rice(db):
|
||||
"""Produkt mit Basiseinheit Gramm und 500 g pro Packung."""
|
||||
product = Product(
|
||||
name="Basmati-Reis", base_unit=BaseUnit.gram, package_size=500, source="manual"
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
81
backend/tests/test_fefo.py
Normal file
81
backend/tests/test_fefo.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.stock import StockError, check_in, check_out, current_stock
|
||||
|
||||
|
||||
def _checkin(db, product, qty, unit, best_before=None):
|
||||
lot = check_in(db, product, qty, unit, best_before, None, None)
|
||||
db.commit()
|
||||
return lot
|
||||
|
||||
|
||||
def test_checkin_creates_lot_in_base_units(db, rice):
|
||||
_checkin(db, rice, 3, "package") # 1500 g
|
||||
assert current_stock(db, rice.id) == 1500
|
||||
|
||||
|
||||
def test_checkout_partial_amount(db, rice):
|
||||
_checkin(db, rice, 3, "package") # 1500 g
|
||||
check_out(db, rice, 200, "g", None)
|
||||
db.commit()
|
||||
assert current_stock(db, rice.id) == 1300
|
||||
|
||||
|
||||
def test_fefo_takes_earliest_expiry_first(db, rice):
|
||||
soon = date.today() + timedelta(days=5)
|
||||
later = date.today() + timedelta(days=60)
|
||||
# Zuerst die später ablaufende Charge einlagern ...
|
||||
_checkin(db, rice, 500, "g", best_before=later)
|
||||
# ... dann die früher ablaufende.
|
||||
_checkin(db, rice, 500, "g", best_before=soon)
|
||||
|
||||
check_out(db, rice, 400, "g", None)
|
||||
db.commit()
|
||||
|
||||
# Die früh ablaufende Charge (soon) muss zuerst reduziert worden sein.
|
||||
from app.models import Lot
|
||||
|
||||
lots = {l.best_before: l.quantity for l in db.query(Lot).all()}
|
||||
assert lots[soon] == 100
|
||||
assert lots[later] == 500
|
||||
|
||||
|
||||
def test_fefo_spans_multiple_lots_and_removes_empty(db, rice):
|
||||
soon = date.today() + timedelta(days=5)
|
||||
later = date.today() + timedelta(days=60)
|
||||
_checkin(db, rice, 500, "g", best_before=soon)
|
||||
_checkin(db, rice, 500, "g", best_before=later)
|
||||
|
||||
check_out(db, rice, 700, "g", None) # 500 (soon) + 200 (later)
|
||||
db.commit()
|
||||
|
||||
from app.models import Lot
|
||||
|
||||
lots = db.query(Lot).all()
|
||||
assert len(lots) == 1 # leere soon-Charge entfernt
|
||||
assert lots[0].best_before == later
|
||||
assert lots[0].quantity == 300
|
||||
|
||||
|
||||
def test_null_best_before_is_last(db, rice):
|
||||
dated = date.today() + timedelta(days=30)
|
||||
_checkin(db, rice, 300, "g", best_before=None)
|
||||
_checkin(db, rice, 300, "g", best_before=dated)
|
||||
|
||||
check_out(db, rice, 300, "g", None)
|
||||
db.commit()
|
||||
|
||||
from app.models import Lot
|
||||
|
||||
remaining = db.query(Lot).all()
|
||||
# Die datierte Charge wird zuerst genommen, die NULL-Charge bleibt.
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0].best_before is None
|
||||
|
||||
|
||||
def test_checkout_more_than_available_raises(db, rice):
|
||||
_checkin(db, rice, 100, "g")
|
||||
with pytest.raises(StockError):
|
||||
check_out(db, rice, 200, "g", None)
|
||||
37
backend/tests/test_units.py
Normal file
37
backend/tests/test_units.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
|
||||
from app.models import BaseUnit, Product
|
||||
from app.services.units import UnitError, base_to_packages, to_base_quantity
|
||||
|
||||
|
||||
def test_base_unit_passthrough(rice):
|
||||
assert to_base_quantity(rice, 200, "g") == 200
|
||||
assert to_base_quantity(rice, 200, "gramm") == 200
|
||||
|
||||
|
||||
def test_package_conversion(rice):
|
||||
# 3 Packungen * 500 g = 1500 g
|
||||
assert to_base_quantity(rice, 3, "package") == 1500
|
||||
assert to_base_quantity(rice, 2, "packung") == 1000
|
||||
|
||||
|
||||
def test_wrong_unit_rejected(rice):
|
||||
with pytest.raises(UnitError):
|
||||
to_base_quantity(rice, 1, "ml")
|
||||
|
||||
|
||||
def test_package_without_size_rejected():
|
||||
product = Product(name="Ei", base_unit=BaseUnit.piece, package_size=None)
|
||||
with pytest.raises(UnitError):
|
||||
to_base_quantity(product, 1, "package")
|
||||
|
||||
|
||||
def test_non_positive_quantity_rejected(rice):
|
||||
with pytest.raises(UnitError):
|
||||
to_base_quantity(rice, 0, "g")
|
||||
|
||||
|
||||
def test_base_to_packages(rice):
|
||||
assert base_to_packages(rice, 1500) == 3
|
||||
product = Product(name="Ei", base_unit=BaseUnit.piece, package_size=None)
|
||||
assert base_to_packages(product, 10) is None
|
||||
Reference in New Issue
Block a user