Files
Vorrania/backend/tests/test_zubehoer.py
T
ScarriffleandClaude Opus 5 a3fd9819c4 Rueckgabe sichtbar machen, Zubehoerteile am Einzelstueck
RUECKGABE
Der Knopf war da, nur nicht zu finden: ein 16-px-Symbol in einer Spalte ohne
Ueberschrift, 52 px breit, ganz rechts in einer horizontal scrollenden Tabelle.
Beschriftet war er allein ueber title= - ein Tooltip, den auf einem Touchgeraet
nie jemand sieht.

Jetzt traegt er Text ("Zuruecknehmen"), die Spalte heisst "Verleih" und ist
breit genug, und beide Knoepfe haben ein aria-label. Dazu eine neue Seite
"Verliehen" in der Seitenleiste: alles Draussene an einem Ort, Rueckgabe direkt
aus der Liste. Bisher gab es diese Uebersicht nur als zwei Dashboard-Karten, und
die stehen nicht im Standard-Layout - wer sie nicht selbst hinzufuegte, hatte
gar keine.

In der App fuehrte der einzige Weg ueber das Bearbeiten-Formular des Stuecks;
jetzt geht es per Wischgeste und Kontextmenue direkt aus der Liste. Der
Verleih-Filter dort sass hinter einem nackten Symbol und ist beschriftet.

Zurueckgenommen wird nur noch nach Rueckfrage, in Web und App.

ZUBEHOER
Ein Netzteil gehoert zu einem Laptop: items.parent_item_id, genau ein Geraet je
Teil, beliebig viele Teile je Geraet. Eine Spalte genuegt dafuer, es braucht
keine Verknuepfungstabelle. ON DELETE SET NULL, damit das Netzteil den Laptop
ueberlebt statt mitgeloescht zu werden - und weil SQLite Fremdschluessel nicht
erzwingt, loest der Router die Zuordnung beim Loeschen zusaetzlich selbst.

Ringe (A traegt B, B traegt A) weist die Zuordnung mit 409 ab; die Elternkette
wird dafuer hochgelaufen, wie beim Kategoriebaum.

Die Beziehung laeuft ueber eigene Endpunkte und bewusst NICHT ueber ItemUpdate:
dessen Schema sendet immer alle Felder mit, auch leere, damit sich Felder
loeschen lassen. Genau so gingen schon einmal Kaufpreis und Waehrung verloren,
als ein Aufrufer ein Feld vergass.

Beim Verleihen fragt die Oberflaeche, welche Teile mitgehen (vorausgewaehlt:
alles, was im Haus ist), beim Zuruecknehmen kommen sie zusammen zurueck. Ohne
das Spiegelbild kaeme das Geraet zurueck und sein Netzteil bliebe ewig
"verliehen", weil niemand mehr danach sucht. Ungefragt nimmt der Server nie
etwas mit.

In der App sind zubehoer und gehoert_zu optional dekodiert: Die App spricht mit
mehreren Serverprofilen, und ein aelterer Server schickt die Felder nicht - ein
Vorgabewert reicht dafuer nicht, Swift nimmt ihn beim Dekodieren nicht her.

12 neue Tests (320 gesamt). Der Umlauf ist ausserdem gegen ein lokales Backend
im Browser durchgespielt: verleihen mit Zubehoer, Rueckfrage, beide zusammen
zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:08:11 +02:00

210 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Zubehör: ein Einzelstück gehört zu einem anderen (Netzteil → Laptop).
Ein Teil hängt an genau einem Gerät, ein Gerät trägt beliebig viele Teile.
Geprüft werden die Zuordnung selbst, ihre Grenzen (Selbstbezug, Ring) und das
Mitverleihen, das die Oberfläche erfragt und der Server nie von sich aus tut.
"""
import pytest
from fastapi import HTTPException
from app.models import Category, CategoryTracking, Item, Product, Role, User
from app.routers.items import (
create_items,
delete_item,
get_item,
zubehoer_loesen,
zubehoer_zuordnen,
)
from app.routers.loans import lend_item, return_item
from app.schemas import ItemCreate, LoanCreate, LoanReturn, ZubehoerZuordnen
from app.services.loans import offener_verleih
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def laptop(db):
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name="Laptop", category_id=cat.id, individual=True, package_size=1)
db.add(product)
db.commit()
db.refresh(product)
return product
def _stueck(db, admin, product) -> Item:
out = create_items(product.id, ItemCreate(count=1), db=db, user=admin)[0]
return db.get(Item, out.id)
def _haenge(db, admin, geraet: Item, teil: Item):
return zubehoer_zuordnen(
geraet.id, ZubehoerZuordnen(child_item_id=teil.id), db=db, _=admin
)
# --------------------------------------------------------------------------
# Zuordnen und lösen
# --------------------------------------------------------------------------
def test_geraet_traegt_mehrere_teile(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
tasche = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
out = _haenge(db, admin, geraet, tasche)
assert {t.uid for t in out.zubehoer} == {netzteil.uid, tasche.uid}
# Und die Gegenrichtung steht am Teil.
assert get_item(netzteil.id, db=db, _=admin).gehoert_zu.uid == geraet.uid
def test_teil_wechselt_das_geraet_ohne_dublette(db, admin, laptop):
"""Ein Teil haengt an genau einem Geraet – Umhaengen ist ein Wechsel."""
a = _stueck(db, admin, laptop)
b = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, a, netzteil)
_haenge(db, admin, b, netzteil)
assert get_item(a.id, db=db, _=admin).zubehoer == []
assert [t.uid for t in get_item(b.id, db=db, _=admin).zubehoer] == [netzteil.uid]
def test_loesen_laesst_das_stueck_bestehen(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
zubehoer_loesen(geraet.id, netzteil.id, db=db, _=admin)
assert get_item(geraet.id, db=db, _=admin).zubehoer == []
assert db.get(Item, netzteil.id) is not None
def test_loesen_eines_fremden_teils_ist_ein_fehler(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
fremdes = _stueck(db, admin, laptop)
with pytest.raises(HTTPException) as fehler:
zubehoer_loesen(geraet.id, fremdes.id, db=db, _=admin)
assert fehler.value.status_code == 404
# --------------------------------------------------------------------------
# Grenzen
# --------------------------------------------------------------------------
def test_selbstbezug_wird_abgelehnt(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
with pytest.raises(HTTPException) as fehler:
_haenge(db, admin, geraet, geraet)
assert fehler.value.status_code == 409
def test_ring_wird_abgelehnt(db, admin, laptop):
"""A trägt B – dann darf B nicht A tragen, sonst läuft jede Kette endlos."""
a = _stueck(db, admin, laptop)
b = _stueck(db, admin, laptop)
_haenge(db, admin, a, b)
with pytest.raises(HTTPException) as fehler:
_haenge(db, admin, b, a)
assert fehler.value.status_code == 409
def test_laengerer_ring_wird_abgelehnt(db, admin, laptop):
a, b, c = (_stueck(db, admin, laptop) for _ in range(3))
_haenge(db, admin, a, b)
_haenge(db, admin, b, c)
with pytest.raises(HTTPException) as fehler:
_haenge(db, admin, c, a)
assert fehler.value.status_code == 409
def test_geraet_loeschen_gibt_das_teil_frei(db, admin, laptop):
"""Das Netzteil ueberlebt den Laptop – es wird wieder eigenstaendig."""
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
delete_item(geraet.id, db=db, _=admin)
uebrig = db.get(Item, netzteil.id)
assert uebrig is not None
assert uebrig.parent_item_id is None
# --------------------------------------------------------------------------
# Verleihen und Zurücknehmen mit Zubehör
# --------------------------------------------------------------------------
def test_ohne_angabe_geht_nur_das_geraet(db, admin, laptop):
"""Der Server nimmt nie ungefragt etwas mit."""
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin)
assert offener_verleih(db, geraet.id) is not None
assert offener_verleih(db, netzteil.id) is None
def test_mit_zubehoer_verleihen_und_zuruecknehmen(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
tasche = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
_haenge(db, admin, geraet, tasche)
teile = [netzteil.id, tasche.id]
lend_item(
geraet.id,
LoanCreate(borrower="Markus", mit_zubehoer=teile),
db=db, user=admin,
)
assert all(offener_verleih(db, i) is not None for i in [geraet.id, *teile])
return_item(geraet.id, LoanReturn(mit_zubehoer=teile), db=db, _=admin)
# Keines darf offen haengenbleiben – sonst sucht es niemand mehr.
assert all(offener_verleih(db, i) is None for i in [geraet.id, *teile])
def test_fremdes_stueck_laesst_sich_nicht_mitverleihen(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
fremdes = _stueck(db, admin, laptop)
with pytest.raises(HTTPException) as fehler:
lend_item(
geraet.id,
LoanCreate(borrower="Markus", mit_zubehoer=[fremdes.id]),
db=db, user=admin,
)
assert fehler.value.status_code == 400
assert offener_verleih(db, fremdes.id) is None
def test_zuruecknehmen_uebergeht_ein_teil_das_dablieb(db, admin, laptop):
"""Wer das Netzteil behalten hat, verleiht es nicht – die Rückgabe darf
daran trotzdem nicht scheitern."""
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin)
return_item(geraet.id, LoanReturn(mit_zubehoer=[netzteil.id]), db=db, _=admin)
assert offener_verleih(db, geraet.id) is None