Farbschema folgt zunächst der Systemeinstellung, lässt sich per Knopf in der Kopfzeile umstellen und wird dann gemerkt. Bei gesperrtem Speicher (privates Fenster) greift wieder die Systemeinstellung. theme.js steht bewusst OHNE defer und vor dem Inhalt: es setzt die Farbklasse, bevor gezeichnet wird. Mit defer blitzte beim Laden kurz die helle Fassung auf. Eine eigene Datei statt eines <script>-Blocks, weil die CSP nur script-src 'self' erlaubt - inline wäre blockiert. Fusszeile zeigt Version oder Commit, gleiches Muster wie die Kantone-App: die CI reicht beides als Build-Argument durch, lokal erscheint der Build-Zeitpunkt. Der ändert sich bei jedem Neubau und zeigt damit sofort, ob wirklich der neue Stand läuft. Beim Ergänzen der Dunkel-Varianten hatte mein Ersetzer die eben eingefügten dark:-Klassen selbst nochmal getroffen und daraus Paare wie "dark:text-slate-300 dark:text-slate-600" gemacht - bereinigt. 81 Tests, alle grün. Neu darunter: die Fusszeile zeigt Tag bzw. Commit, theme.js steht vor <body> und ohne defer, der Umschalter ist da. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
266 lines
9.2 KiB
Python
266 lines
9.2 KiB
Python
"""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}"
|
|
|
|
|
|
# --------------------------------------------------------- Darstellung ---
|
|
|
|
def test_fusszeile_zeigt_die_version(client, monkeypatch):
|
|
"""Die CI reicht Tag und Commit durch; lokal steht der Build-Zeitpunkt."""
|
|
from app.version import version_info
|
|
|
|
version_info.cache_clear()
|
|
monkeypatch.setenv("APP_VERSION", "1.2.3")
|
|
monkeypatch.setenv("APP_REVISION", "abcdef1234567")
|
|
try:
|
|
assert "Version 1.2.3" in client.get("/").text
|
|
finally:
|
|
version_info.cache_clear()
|
|
|
|
|
|
def test_ohne_tag_erscheint_der_commit(client, monkeypatch):
|
|
from app.version import version_info
|
|
|
|
version_info.cache_clear()
|
|
monkeypatch.setenv("APP_VERSION", "")
|
|
monkeypatch.setenv("APP_REVISION", "abcdef1234567")
|
|
try:
|
|
assert "Commit abcdef1" in client.get("/").text
|
|
finally:
|
|
version_info.cache_clear()
|
|
|
|
|
|
def test_farbschema_skript_laeuft_vor_dem_zeichnen(client):
|
|
"""Ohne das blitzt beim Laden kurz die helle Fassung auf."""
|
|
html = client.get("/").text
|
|
theme = html.index('src="/static/theme.js"')
|
|
koerper = html.index("<body")
|
|
assert theme < koerper, "theme.js muss vor <body> stehen"
|
|
# und darf nicht verzögert werden
|
|
zeile = html[html.rindex("<script", 0, theme):html.index(">", theme) + 1]
|
|
assert "defer" not in zeile and "async" not in zeile
|
|
|
|
|
|
def test_umschalter_ist_vorhanden(client):
|
|
assert 'id="schema-knopf"' in client.get("/").text
|