Phase 1: Datenmodell, CRUD-API und Stammdaten
FastAPI mit SQLAlchemy und SQLite, Migrationen über Alembic. Die
Endpunkte aus projekt.md für items, categories und sizes stehen samt
Reservierung; Bilder und Anmeldung folgen in Phase 2 und 3.
Umgesetzt wie in Plan.md festgelegt:
- Feste Wertelisten als Enum UND als CHECK in der Datenbank. Die
CHECK-Bedingung ist der eigentliche Schutz: an SQLAlchemy vorbei (Import,
sqlite3 von Hand) käme sonst "Gril" durch, und die Filter griffen still
nicht mehr.
- Status "draft" für den Stapel-Import. Entwürfe erscheinen weder in der
Galerie noch in GET /items, solange nicht ausdrücklich status=draft
angefragt wird, und lassen sich nicht reservieren.
- Titel ist freiwillig und wird sonst beim ANZEIGEN aus Kategorie und
Grösse gebildet ("Jacken 98/104") - nicht beim Speichern, damit er einer
späteren Korrektur der Grösse folgt.
- reservation_token: nur wer es hat, kann die eigene Reservierung aufheben.
Verglichen mit compare_digest, gelöscht beim Freigeben und beim
Erledigen, damit ein alter Link nicht später eine fremde Reservierung
aufhebt.
- Eigene sizes-Tabelle mit sort_order statt SELECT DISTINCT: sonst stünde
"104" vor "56" und jeder Tippfehler würde zur Filteroption.
- Pagination auf GET /items, in projekt.md nicht vorgesehen.
Zwei SQLite-Eigenheiten, die leicht untergehen: foreign_keys ist
standardmässig AUS (ohne PRAGMA greift ON DELETE CASCADE nicht), und
check_same_thread muss für FastAPI abgeschaltet werden. Beides in
database.py, dazu WAL fürs gleichzeitige Lesen.
24 Tests, alle grün. Zusätzlich von Hand geprüft: Migration anwenden,
Eintrag anlegen, reservieren, ohne Token freigeben (403), mit Token
freigeben (200), Swagger UI erreichbar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""Tests rund ums Reservieren.
|
||||
|
||||
Schwerpunkt ist die Berechtigung: die Galerie ist frei zugänglich, also
|
||||
darf ein offener release-Endpunkt nicht fremde Reservierungen löschen
|
||||
können.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def item_id(client, kategorie_id):
|
||||
return client.post(
|
||||
"/api/v1/items", json={"size": "98/104", "category_id": kategorie_id}
|
||||
).json()["id"]
|
||||
|
||||
|
||||
def test_reservieren(client, item_id):
|
||||
antwort = client.post(
|
||||
f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"}
|
||||
)
|
||||
assert antwort.status_code == 200
|
||||
daten = antwort.json()
|
||||
assert daten["item"]["status"] == "reserved"
|
||||
assert daten["item"]["reserved_by"] == "Familie Meier"
|
||||
assert len(daten["reservation_token"]) > 20
|
||||
|
||||
|
||||
def test_zweimal_reservieren_geht_nicht(client, item_id):
|
||||
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "A"})
|
||||
zweiter = client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "B"})
|
||||
assert zweiter.status_code == 409
|
||||
|
||||
|
||||
def test_freigeben_mit_token(client, item_id):
|
||||
token = client.post(
|
||||
f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"}
|
||||
).json()["reservation_token"]
|
||||
|
||||
antwort = client.post(f"/api/v1/items/{item_id}/release?token={token}")
|
||||
assert antwort.status_code == 200
|
||||
assert antwort.json()["status"] == "available"
|
||||
assert antwort.json()["reserved_by"] is None
|
||||
|
||||
|
||||
def test_freigeben_ohne_token_wird_abgewiesen(client, item_id):
|
||||
"""Der Kern der Entscheidung: sonst löscht jeder Besucher fremde
|
||||
Reservierungen."""
|
||||
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"})
|
||||
|
||||
assert client.post(f"/api/v1/items/{item_id}/release").status_code == 403
|
||||
assert client.post(f"/api/v1/items/{item_id}/release?token=falsch").status_code == 403
|
||||
|
||||
# Reservierung steht unverändert
|
||||
item = client.get(f"/api/v1/items/{item_id}").json()
|
||||
assert item["status"] == "reserved"
|
||||
assert item["reserved_by"] == "Familie Meier"
|
||||
|
||||
|
||||
def test_token_gilt_nach_dem_freigeben_nicht_mehr(client, item_id):
|
||||
"""Sonst könnte ein alter Link später die Reservierung einer anderen
|
||||
Person aufheben."""
|
||||
token = client.post(
|
||||
f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Erste"}
|
||||
).json()["reservation_token"]
|
||||
client.post(f"/api/v1/items/{item_id}/release?token={token}")
|
||||
|
||||
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Zweite"})
|
||||
assert client.post(f"/api/v1/items/{item_id}/release?token={token}").status_code == 403
|
||||
|
||||
|
||||
def test_abgegeben_loescht_den_namen(client, item_id):
|
||||
"""Datensparsam - die Galerie ist öffentlich."""
|
||||
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"})
|
||||
antwort = client.post(f"/api/v1/items/{item_id}/mark-given").json()
|
||||
assert antwort["status"] == "given_away"
|
||||
assert antwort["reserved_by"] is None
|
||||
|
||||
|
||||
def test_name_ohne_werbung(client, item_id):
|
||||
antwort = client.post(
|
||||
f"/api/v1/items/{item_id}/reserve",
|
||||
json={"reserved_by": "Jetzt billig kaufen http://spam.example"},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
Reference in New Issue
Block a user