Phase 4: Oberfläche mit Galerie, Erfassung und Reservierung
Jinja2 mit HTMX, Tailwind über die Standalone-Binärdatei im Docker-Build - kein Node.js, kein npm. Mobile-First, Tippflächen mindestens 44 px hoch. Seiten: Galerie mit Filterleiste, Detailseite, Erfassungsformular, Stapel-Import mit Entwurfsliste, Anmeldung. HTMX tauscht beim Filtern nur die Liste aus statt die ganze Seite neu zu laden. Aus den Entscheidungen im Plan folgt konkret: - Der Stapel-Import steht ZUERST im Formular: wer zwanzig Teile hat, will nicht zwanzigmal ein Formular ausfüllen. Bewusst ohne capture-Attribut, damit sich wahlweise fotografieren oder aus der Mediathek wählen lässt - mit capture ginge nur je ein Foto pro Vorgang. - Grösse, Kategorie, Geschlecht, Saison und Zustand werden vom zuletzt gespeicherten Teil übernommen. Beim Abarbeiten einer Kiste sind das fünf Felder weniger pro Stück. - Nach dem Nachtragen eines Entwurfs geht es direkt zum nächsten, nicht auf die Detailseite - das ist der eigentliche Arbeitsablauf. - Titel bleibt leer erlaubt; angezeigt wird dann Kategorie und Grösse. - reserved_by erscheint öffentlich nur als "reserviert". Den Namen sieht ausschliesslich der angemeldete Betreiber. - Nach dem Reservieren wird der Freigabe-Link angezeigt, als vollständige Adresse zum Aufbewahren. - Post/Redirect/Get überall, damit ein Neuladen nicht erneut abschickt. - CSRF-Token in jedem Formular, zusätzlich zu SameSite=Lax. HTMX liegt lokal im Repository, nicht vom CDN: die CSP erlaubt nur script-src 'self', und ein CDN-Aufruf gäbe die IP jedes Besuchers weiter - dieselbe Überlegung wie bei Swagger und bei den Schriften der Kantone-App. Ein Test prüft für alle Seiten, dass nichts von aussen geladen wird. Nebenbei TemplateResponse auf die aktuelle Signatur umgestellt (64 Deprecation-Warnungen weniger). 77 Tests, alle grün. Zusätzlich der ganze Weg gegen den Container: anmelden, drei Fotos als Stapel, Entwurf nachtragen, Werte werden für das nächste Teil übernommen, als Gast reservieren, Name bleibt verborgen, Selbst-Freigabe über den Link. 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,223 @@
|
||||
"""Tests der sichtbaren Seiten."""
|
||||
|
||||
import io
|
||||
import re
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def bild_bytes() -> bytes:
|
||||
puffer = io.BytesIO()
|
||||
Image.new("RGB", (600, 800), (120, 160, 200)).save(puffer, "JPEG")
|
||||
return puffer.getvalue()
|
||||
|
||||
|
||||
def csrf_aus(html: str) -> str:
|
||||
treffer = re.search(r'name="csrf" value="([^"]+)"', html)
|
||||
assert treffer, "kein CSRF-Token im Formular"
|
||||
return treffer.group(1)
|
||||
|
||||
|
||||
def erfasse(client, kategorie_id, **felder):
|
||||
"""Legt über das Formular ein Kleidungsstück an."""
|
||||
seite = client.get("/erfassen")
|
||||
daten = {
|
||||
"csrf": csrf_aus(seite.text),
|
||||
"size": "98/104",
|
||||
"category_id": str(kategorie_id),
|
||||
"gender": "unisex",
|
||||
"season": "all_year",
|
||||
"condition": "good",
|
||||
"title": "",
|
||||
"description": "",
|
||||
}
|
||||
daten.update(felder)
|
||||
return client.post("/erfassen", data=daten, follow_redirects=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- Galerie ---
|
||||
|
||||
def test_galerie_ist_ohne_anmeldung_sichtbar(gast):
|
||||
antwort = gast.get("/")
|
||||
assert antwort.status_code == 200
|
||||
assert "Kleider-Börse" in antwort.text
|
||||
|
||||
|
||||
def test_galerie_zeigt_erfasstes(client, gast, kategorie_id):
|
||||
erfasse(client, kategorie_id, title="Rote Winterjacke")
|
||||
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
|
||||
|
||||
seite = gast.get("/")
|
||||
assert "Rote Winterjacke" in seite.text
|
||||
|
||||
|
||||
def test_abgeleiteter_titel_erscheint(client, kategorie_id):
|
||||
"""Titel leer gelassen - dann bildet ihn die Anwendung."""
|
||||
erfasse(client, kategorie_id, size="80")
|
||||
seite = client.get("/")
|
||||
assert "80" in seite.text
|
||||
|
||||
|
||||
def test_filter_liefert_nur_die_liste(client, kategorie_id):
|
||||
"""HTMX tauscht nur den Ausschnitt aus, nicht die ganze Seite."""
|
||||
erfasse(client, kategorie_id, size="80", title="Kleines Teil")
|
||||
erfasse(client, kategorie_id, size="122", title="Grosses Teil")
|
||||
|
||||
ausschnitt = client.get("/teil/liste?size=80")
|
||||
assert ausschnitt.status_code == 200
|
||||
assert "Kleines Teil" in ausschnitt.text
|
||||
assert "Grosses Teil" not in ausschnitt.text
|
||||
# Kein vollständiges Dokument
|
||||
assert "<html" not in ausschnitt.text.lower()
|
||||
|
||||
|
||||
def test_entwuerfe_tauchen_nicht_in_der_galerie_auf(client, db_sitzung):
|
||||
from app import crud
|
||||
|
||||
crud.entwuerfe_anlegen(db_sitzung, 3)
|
||||
assert "Nachtragen" not in client.get("/").text
|
||||
|
||||
|
||||
# ------------------------------------------------------------- Erfassung ---
|
||||
|
||||
def test_erfassen_braucht_anmeldung(gast):
|
||||
assert gast.get("/erfassen").status_code == 401
|
||||
|
||||
|
||||
def test_erfassen_und_anzeigen(client, kategorie_id):
|
||||
antwort = erfasse(client, kategorie_id, title="Blaue Hose")
|
||||
assert antwort.status_code == 200
|
||||
assert "gespeichert" in antwort.text
|
||||
|
||||
detail = client.get("/kleid/1")
|
||||
assert detail.status_code == 200
|
||||
assert "Blaue Hose" in detail.text
|
||||
|
||||
|
||||
def test_werte_werden_fuer_das_naechste_teil_gemerkt(client, kategorie_id):
|
||||
"""Beim Abarbeiten einer Kiste ändert sich meist nur der Titel."""
|
||||
erfasse(client, kategorie_id, size="122", gender="girl")
|
||||
formular = client.get("/erfassen").text
|
||||
|
||||
assert re.search(r'<option value="122"\s+selected', formular)
|
||||
assert re.search(r'<option value="girl"\s+selected', formular)
|
||||
|
||||
|
||||
def test_formular_ohne_token_wird_abgewiesen(client, kategorie_id):
|
||||
antwort = client.post("/erfassen", data={
|
||||
"csrf": "erfunden", "size": "80", "category_id": str(kategorie_id),
|
||||
"gender": "unisex", "season": "all_year", "condition": "good",
|
||||
"title": "", "description": "",
|
||||
})
|
||||
assert antwort.status_code == 400
|
||||
|
||||
|
||||
# ----------------------------------------------------------- Stapelweise ---
|
||||
|
||||
def test_stapel_import_ueber_das_formular(client):
|
||||
seite = client.get("/erfassen")
|
||||
antwort = client.post(
|
||||
"/stapel",
|
||||
data={"csrf": csrf_aus(seite.text)},
|
||||
files=[("dateien", (f"f{i}.jpg", bild_bytes(), "image/jpeg")) for i in range(4)],
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert antwort.status_code == 200
|
||||
assert "4 Fotos hochgeladen" in antwort.text
|
||||
assert "Unfertige Einträge (4)" in antwort.text
|
||||
|
||||
|
||||
def test_entwurf_nachtragen_macht_ihn_sichtbar(client, kategorie_id):
|
||||
seite = client.get("/erfassen")
|
||||
client.post(
|
||||
"/stapel",
|
||||
data={"csrf": csrf_aus(seite.text)},
|
||||
files={"dateien": ("f.jpg", bild_bytes(), "image/jpeg")},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
formular = client.get("/kleid/1/bearbeiten")
|
||||
assert formular.status_code == 200
|
||||
assert "Entwurf nachtragen" in formular.text
|
||||
|
||||
fertig = client.post("/kleid/1/bearbeiten", data={
|
||||
"csrf": csrf_aus(formular.text), "size": "92",
|
||||
"category_id": str(kategorie_id), "gender": "boy",
|
||||
"season": "all_year", "condition": "good", "title": "", "description": "",
|
||||
}, follow_redirects=True)
|
||||
assert fertig.status_code == 200
|
||||
|
||||
# Jetzt in der Galerie
|
||||
galerie = client.get("/")
|
||||
assert "92" in galerie.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------- Reservierung ---
|
||||
|
||||
def test_reservieren_zeigt_den_freigabe_link(client, gast, kategorie_id):
|
||||
erfasse(client, kategorie_id, title="Warme Jacke")
|
||||
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
|
||||
|
||||
detail = gast.get("/kleid/1")
|
||||
antwort = gast.post("/kleid/1/reservieren", data={
|
||||
"csrf": csrf_aus(detail.text), "reserved_by": "Familie Meier",
|
||||
})
|
||||
assert antwort.status_code == 200
|
||||
assert "Reserviert für dich" in antwort.text
|
||||
|
||||
# Absolute Adresse - der Link soll sich aufbewahren und weiterschicken
|
||||
# lassen, ein reiner Pfad nützte dabei nichts.
|
||||
link = re.search(r'value="(https?://[^"]*/kleid/1/freigeben\?token=[^"]+)"',
|
||||
antwort.text)
|
||||
assert link, f"kein Freigabe-Link angezeigt:\n{antwort.text[-600:]}"
|
||||
|
||||
# Der Link hebt die Reservierung wieder auf
|
||||
auf = gast.get(link.group(1), follow_redirects=True)
|
||||
assert "aufgehoben" in auf.text
|
||||
|
||||
|
||||
def test_name_bleibt_oeffentlich_verborgen(client, gast, kategorie_id):
|
||||
"""Die Galerie ist frei zugänglich - der Name gehört nicht hinein."""
|
||||
erfasse(client, kategorie_id, title="Warme Jacke")
|
||||
detail = client.get("/kleid/1")
|
||||
client.post("/kleid/1/reservieren", data={
|
||||
"csrf": csrf_aus(detail.text), "reserved_by": "Familie Geheim",
|
||||
})
|
||||
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
|
||||
|
||||
assert "Familie Geheim" not in gast.get("/").text
|
||||
assert "Familie Geheim" not in gast.get("/kleid/1").text
|
||||
assert "reserviert" in gast.get("/kleid/1").text
|
||||
|
||||
|
||||
def test_betreiber_sieht_den_namen(client, kategorie_id):
|
||||
erfasse(client, kategorie_id, title="Warme Jacke")
|
||||
detail = client.get("/kleid/1")
|
||||
client.post("/kleid/1/reservieren", data={
|
||||
"csrf": csrf_aus(detail.text), "reserved_by": "Familie Meier",
|
||||
})
|
||||
assert "Familie Meier" in client.get("/kleid/1").text
|
||||
|
||||
|
||||
def test_falscher_freigabe_link_aendert_nichts(client, gast, kategorie_id):
|
||||
erfasse(client, kategorie_id)
|
||||
detail = client.get("/kleid/1")
|
||||
client.post("/kleid/1/reservieren", data={
|
||||
"csrf": csrf_aus(detail.text), "reserved_by": "Wer auch immer",
|
||||
})
|
||||
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
|
||||
|
||||
antwort = gast.get("/kleid/1/freigeben?token=daneben", follow_redirects=True)
|
||||
assert "passt nicht" in antwort.text
|
||||
assert "reserviert" in gast.get("/kleid/1").text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Seiten ---
|
||||
|
||||
def test_seiten_laden_nichts_von_fremden_servern(client, kategorie_id):
|
||||
"""Sonst blockiert die CSP es - und die Seite bliebe leer."""
|
||||
erfasse(client, kategorie_id)
|
||||
for pfad in ["/", "/kleid/1", "/erfassen", "/anmelden", "/entwuerfe"]:
|
||||
html = client.get(pfad).text
|
||||
fremde = re.findall(r'(?:src|href)="(?:https?:)?//[^"]+"', html)
|
||||
assert not fremde, f"{pfad} lädt von aussen: {fremde}"
|
||||
Reference in New Issue
Block a user