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,119 @@
|
||||
"""Tests für Anlegen, Filtern, Ändern und Löschen."""
|
||||
|
||||
|
||||
def test_stammdaten_sind_da(client):
|
||||
kategorien = client.get("/api/v1/categories").json()
|
||||
groessen = client.get("/api/v1/sizes").json()
|
||||
assert len(kategorien) >= 10
|
||||
assert len(groessen) > 20
|
||||
|
||||
|
||||
def test_groessen_sind_sinnvoll_sortiert(client):
|
||||
"""Der eigentliche Grund für die eigene Tabelle: als Text sortiert
|
||||
stünde '104' vor '56'."""
|
||||
labels = [g["label"] for g in client.get("/api/v1/sizes").json()]
|
||||
assert labels.index("56") < labels.index("104")
|
||||
|
||||
|
||||
def test_anlegen_und_abrufen(client, kategorie_id):
|
||||
antwort = client.post(
|
||||
"/api/v1/items",
|
||||
json={"title": "Warme Winterjacke rot", "size": "98/104",
|
||||
"category_id": kategorie_id, "gender": "girl"},
|
||||
)
|
||||
assert antwort.status_code == 201
|
||||
item = antwort.json()
|
||||
assert item["title"] == "Warme Winterjacke rot"
|
||||
assert item["display_title"] == "Warme Winterjacke rot"
|
||||
assert item["status"] == "available"
|
||||
|
||||
einzeln = client.get(f"/api/v1/items/{item['id']}").json()
|
||||
assert einzeln["kategorie"]["id"] == kategorie_id
|
||||
|
||||
|
||||
def test_titel_wird_aus_kategorie_und_groesse_gebildet(client, kategorie_id):
|
||||
"""Titel ist freiwillig - ohne ihn entsteht 'Hosen 98/104'."""
|
||||
item = client.post(
|
||||
"/api/v1/items",
|
||||
json={"size": "98/104", "category_id": kategorie_id},
|
||||
).json()
|
||||
assert item["title"] is None
|
||||
kategorie_name = client.get("/api/v1/categories").json()[0]["name"]
|
||||
assert item["display_title"] == f"{kategorie_name} 98/104"
|
||||
|
||||
|
||||
def test_abgeleiteter_titel_folgt_einer_korrektur(client, kategorie_id):
|
||||
"""Deshalb wird er beim Anzeigen gebildet und nicht beim Speichern."""
|
||||
item = client.post(
|
||||
"/api/v1/items", json={"size": "98/104", "category_id": kategorie_id}
|
||||
).json()
|
||||
geaendert = client.patch(
|
||||
f"/api/v1/items/{item['id']}", json={"size": "110/116"}
|
||||
).json()
|
||||
assert geaendert["display_title"].endswith("110/116")
|
||||
|
||||
|
||||
def test_leerer_titel_zaehlt_als_nicht_gesetzt(client, kategorie_id):
|
||||
item = client.post(
|
||||
"/api/v1/items",
|
||||
json={"title": " ", "size": "80", "category_id": kategorie_id},
|
||||
).json()
|
||||
assert item["title"] is None
|
||||
assert item["display_title"].endswith("80")
|
||||
|
||||
|
||||
def test_filtern(client, kategorie_id):
|
||||
for groesse, geschlecht in [("80", "boy"), ("86", "girl"), ("80", "girl")]:
|
||||
client.post(
|
||||
"/api/v1/items",
|
||||
json={"size": groesse, "category_id": kategorie_id, "gender": geschlecht},
|
||||
)
|
||||
|
||||
assert client.get("/api/v1/items?size=80").json()["total"] == 2
|
||||
assert client.get("/api/v1/items?gender=girl").json()["total"] == 2
|
||||
assert client.get("/api/v1/items?size=80&gender=girl").json()["total"] == 1
|
||||
|
||||
|
||||
def test_pagination(client, kategorie_id):
|
||||
for i in range(5):
|
||||
client.post(
|
||||
"/api/v1/items",
|
||||
json={"size": "80", "category_id": kategorie_id, "title": f"Teil {i}"},
|
||||
)
|
||||
seite = client.get("/api/v1/items?limit=2&offset=0").json()
|
||||
assert seite["total"] == 5
|
||||
assert len(seite["items"]) == 2
|
||||
|
||||
|
||||
def test_aendern(client, kategorie_id):
|
||||
item = client.post(
|
||||
"/api/v1/items", json={"size": "80", "category_id": kategorie_id}
|
||||
).json()
|
||||
geaendert = client.patch(
|
||||
f"/api/v1/items/{item['id']}", json={"condition": "worn"}
|
||||
).json()
|
||||
assert geaendert["condition"] == "worn"
|
||||
# Nicht gesetzte Felder bleiben unangetastet
|
||||
assert geaendert["size"] == "80"
|
||||
|
||||
|
||||
def test_loeschen(client, kategorie_id):
|
||||
item = client.post(
|
||||
"/api/v1/items", json={"size": "80", "category_id": kategorie_id}
|
||||
).json()
|
||||
assert client.delete(f"/api/v1/items/{item['id']}").status_code == 204
|
||||
assert client.get(f"/api/v1/items/{item['id']}").status_code == 404
|
||||
|
||||
|
||||
def test_unbekannte_kategorie_wird_abgewiesen(client):
|
||||
antwort = client.post("/api/v1/items", json={"size": "80", "category_id": 9999})
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
def test_ungueltiger_wert_wird_abgewiesen(client, kategorie_id):
|
||||
"""Feste Wertelisten statt freier VARCHAR - 'Gril' darf nicht durch."""
|
||||
antwort = client.post(
|
||||
"/api/v1/items",
|
||||
json={"size": "80", "category_id": kategorie_id, "gender": "Gril"},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
Reference in New Issue
Block a user