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:
2026-08-29 22:00:58 +02:00
co-authored by Claude Opus 5
parent 3cf6903df5
commit 3d7b7c706a
22 changed files with 1443 additions and 0 deletions
+85
View File
@@ -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