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,45 @@
|
||||
"""Gemeinsame Test-Vorbereitung.
|
||||
|
||||
Jeder Test bekommt eine eigene, frische SQLite-Datei in einem temporären
|
||||
Verzeichnis - keine geteilten Zustände zwischen Tests, und die echte
|
||||
Datenbank unter data/ wird nie angefasst.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.database import Basis, engine_erzeugen, get_db
|
||||
from app.main import app
|
||||
from app.seed import stammdaten_anlegen
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_sitzung(tmp_path):
|
||||
engine = engine_erzeugen(f"sqlite:///{tmp_path/'test.sqlite'}")
|
||||
Basis.metadata.create_all(engine)
|
||||
Sitzung = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
with Sitzung() as db:
|
||||
stammdaten_anlegen(db)
|
||||
yield db
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db_sitzung):
|
||||
"""TestClient, der auf dieselbe Testdatenbank zeigt wie db_sitzung."""
|
||||
|
||||
def _db():
|
||||
yield db_sitzung
|
||||
|
||||
app.dependency_overrides[get_db] = _db
|
||||
# Bewusst ohne "with": nur dann startet Starlette den Lifespan, und der
|
||||
# würde die echte Datenbank unter data/ anlegen und befüllen. Die
|
||||
# Stammdaten stehen in der Testdatenbank ohnehin schon (db_sitzung).
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kategorie_id(client):
|
||||
return client.get("/api/v1/categories").json()[0]["id"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests zum Stapel-Import.
|
||||
|
||||
Entwürfe entstehen beim Hochladen vieler Fotos auf einmal und haben noch
|
||||
keine Angaben. Entscheidend ist, dass sie nicht in der Galerie auftauchen.
|
||||
"""
|
||||
|
||||
from app import crud
|
||||
|
||||
|
||||
def test_entwuerfe_erscheinen_nicht_in_der_galerie(client, db_sitzung, kategorie_id):
|
||||
crud.entwuerfe_anlegen(db_sitzung, 3)
|
||||
client.post("/api/v1/items", json={"size": "80", "category_id": kategorie_id})
|
||||
|
||||
galerie = client.get("/api/v1/items").json()
|
||||
assert galerie["total"] == 1
|
||||
|
||||
entwuerfe = client.get("/api/v1/items?status=draft").json()
|
||||
assert entwuerfe["total"] == 3
|
||||
|
||||
|
||||
def test_entwurf_darf_unvollstaendig_sein(client, db_sitzung):
|
||||
entwurf = crud.entwuerfe_anlegen(db_sitzung, 1)[0]
|
||||
assert entwurf.size is None
|
||||
assert entwurf.category_id is None
|
||||
assert entwurf.anzeige_titel == "Kleidungsstück"
|
||||
|
||||
|
||||
def test_entwurf_wird_durch_nachtragen_sichtbar(client, db_sitzung, kategorie_id):
|
||||
entwurf = crud.entwuerfe_anlegen(db_sitzung, 1)[0]
|
||||
|
||||
antwort = client.patch(
|
||||
f"/api/v1/items/{entwurf.id}",
|
||||
json={"size": "92", "category_id": kategorie_id, "status": "available"},
|
||||
)
|
||||
assert antwort.status_code == 200
|
||||
assert client.get("/api/v1/items").json()["total"] == 1
|
||||
|
||||
|
||||
def test_unvollstaendiger_eintrag_kann_nicht_veroeffentlicht_werden(
|
||||
client, db_sitzung
|
||||
):
|
||||
"""Die CHECK-Bedingung greift auch dann, wenn die Route es zuliesse."""
|
||||
entwurf = crud.entwuerfe_anlegen(db_sitzung, 1)[0]
|
||||
antwort = client.patch(f"/api/v1/items/{entwurf.id}", json={"status": "available"})
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
def test_entwurf_kann_nicht_reserviert_werden(client, db_sitzung):
|
||||
entwurf = crud.entwuerfe_anlegen(db_sitzung, 1)[0]
|
||||
antwort = client.post(
|
||||
f"/api/v1/items/{entwurf.id}/reserve", json={"reserved_by": "Wer auch immer"}
|
||||
)
|
||||
assert antwort.status_code == 404
|
||||
@@ -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
|
||||
@@ -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