Files
StefanandClaude Opus 5 3d7b7c706a 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
2026-08-29 22:00:58 +02:00

120 lines
4.2 KiB
Python

"""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