Files
Kochbuch/tests/test_recipes.py
T
Kyle DammannandClaude Opus 5.5 23adb882bd Add tabs for all, own and favourite recipes
- Tabs "Alle", "Eigene Rezepte", "Favoriten" above the recipe list;
  search and tag filters stay within the active tab
- Menu link to own recipes; empty states per tab
- Fix recipe card images rendering portrait instead of 4:3

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:45:29 +01:00

232 lines
10 KiB
Python

import io
from pathlib import Path
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from PIL import Image
from recipes.models import Favorite, Recipe, RecipeImage
from .recipe_helpers import form_data, image_upload, make_recipe
pytestmark = pytest.mark.django_db
def test_create_recipe_with_photos(user_client, user, media_root):
data = form_data(photos=[image_upload("a.jpg"), image_upload("b.png", fmt="PNG")])
response = user_client.post(reverse("recipes:create"), data)
recipe = Recipe.objects.get()
assert response.status_code == 302 and response["Location"] == reverse("recipes:detail", args=[recipe.pk])
assert recipe.owner == user and recipe.visibility == "private"
assert [i.name for i in recipe.ingredients.all()] == ["Mehl", "Milch", "Eier", "Salz"]
assert [s.text for s in recipe.steps.all()] == ["Alles verrühren.", "In der Pfanne ausbacken."]
assert sorted(t.name for t in recipe.tags.all()) == ["frühstück", "süß"]
imgs = list(recipe.images.all())
assert len(imgs) == 2 and sum(i.is_cover for i in imgs) == 1
for img in imgs:
assert (Path(media_root) / img.file).exists() and (Path(media_root) / img.thumb).exists()
assert img.width <= 1600 and img.height <= 1600
def test_detail_shows_structured_recipe(user_client, user):
recipe = make_recipe(user)
html = user_client.get(reverse("recipes:detail", args=[recipe.pk])).content.decode()
assert "Mehl" in html and "Backen." in html and "#kuchen" in html
def test_edit_form_roundtrip(user_client, user):
user_client.post(reverse("recipes:create"), form_data())
recipe = Recipe.objects.get()
form = user_client.get(reverse("recipes:edit", args=[recipe.pk])).context["form"]
data = form_data(ingredients_text=form.initial["ingredients_text"], steps_text=form.initial["steps_text"],
tags_text=form.initial["tags_text"], title="Pfannkuchen deluxe")
user_client.post(reverse("recipes:edit", args=[recipe.pk]), data)
recipe.refresh_from_db()
assert recipe.title == "Pfannkuchen deluxe"
assert [i.name for i in recipe.ingredients.all()] == ["Mehl", "Milch", "Eier", "Salz"]
def test_only_owner_can_edit_public_recipe(client, user, other_user):
recipe = make_recipe(user)
client.force_login(other_user)
assert client.get(reverse("recipes:detail", args=[recipe.pk])).status_code == 200
assert client.get(reverse("recipes:edit", args=[recipe.pk])).status_code == 404
assert client.post(reverse("recipes:delete", args=[recipe.pk])).status_code == 404
def test_admin_can_delete_public_recipe(admin_client, user, media_root):
recipe = make_recipe(user)
admin_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
assert not RecipeImage.objects.exists() # admin is not the owner → upload refused (404)
assert admin_client.post(reverse("recipes:delete", args=[recipe.pk])).status_code == 302
assert not Recipe.objects.exists()
def test_delete_removes_image_files(user_client, user, media_root):
user_client.post(reverse("recipes:create"), form_data(photos=[image_upload()]))
recipe = Recipe.objects.get()
img = recipe.images.get()
user_client.post(reverse("recipes:delete", args=[recipe.pk]))
assert not (Path(media_root) / img.file).exists()
def test_upload_rejects_non_image(user_client):
fake = SimpleUploadedFile("evil.jpg", b"<?php echo 1; ?>", content_type="image/jpeg")
response = user_client.post(reverse("recipes:create"), form_data(photos=[fake]))
assert response.status_code == 200
assert "kein unterstütztes Bild" in response.content.decode()
assert not Recipe.objects.exists()
def test_upload_rejects_too_large(user_client, settings):
settings.IMAGE_MAX_UPLOAD_MB = 0
response = user_client.post(reverse("recipes:create"), form_data(photos=[image_upload()]))
assert "zu groß" in response.content.decode()
def test_upload_rejects_gif(user_client):
buffer = io.BytesIO()
Image.new("RGB", (10, 10)).save(buffer, "GIF")
gif = SimpleUploadedFile("a.gif", buffer.getvalue(), content_type="image/gif")
response = user_client.post(reverse("recipes:create"), form_data(photos=[gif]))
assert "kein unterstütztes Bild" in response.content.decode()
def test_uploaded_image_has_no_exif(user_client, media_root):
buffer = io.BytesIO()
img = Image.new("RGB", (800, 600))
exif = Image.Exif()
exif[0x8825] = {1: "N", 2: (52.0, 31.0, 0.0)} # GPS info
exif[0x010F] = "SpyCam"
img.save(buffer, "JPEG", exif=exif)
upload = SimpleUploadedFile("gps.jpg", buffer.getvalue(), content_type="image/jpeg")
user_client.post(reverse("recipes:create"), form_data(photos=[upload]))
stored = Image.open(Path(media_root) / RecipeImage.objects.get().file)
assert stored.format == "WEBP"
assert not stored.getexif()
def test_image_limit_per_recipe(user_client, user, settings):
settings.IMAGE_MAX_PER_RECIPE = 2
recipe = make_recipe(user)
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload(), image_upload()]})
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
assert recipe.images.count() == 2
def test_cover_switch_and_delete(user_client, user):
recipe = make_recipe(user)
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload(), image_upload()]})
first, second = recipe.images.order_by("position")
assert first.is_cover
user_client.post(reverse("recipes:photo_cover", args=[recipe.pk, second.pk]))
assert recipe.images.get(is_cover=True) == second
user_client.post(reverse("recipes:photo_delete", args=[recipe.pk, second.pk]))
assert recipe.images.get().is_cover
def test_search_by_title_ingredient_and_tag(user_client, user):
make_recipe(user, title="Linsensuppe", ingredients="200 g rote Linsen", tags=("suppe",))
make_recipe(user, title="Tomatensalat", ingredients="4 Tomaten", tags=("salat",))
def titles(**q):
return [r.title for r in user_client.get(reverse("recipes:list"), q).context["page"].object_list]
assert titles(q="linsen") == ["Linsensuppe"]
assert titles(q="TOMATEN") == ["Tomatensalat"]
assert titles(q="suppe") == ["Linsensuppe"]
assert titles(zutat="linsen") == ["Linsensuppe"]
assert titles(tag="salat") == ["Tomatensalat"]
assert titles(q="linsen tomaten") == []
def test_tags_stored_lowercase(user):
recipe = make_recipe(user, tags=("Vegan", "SCHNELL"))
assert sorted(t.name for t in recipe.tags.all()) == ["schnell", "vegan"]
def test_favorites(user_client, user, other_user):
recipe = make_recipe(other_user, title="Fremdes Rezept")
make_recipe(other_user, title="Anderes")
user_client.post(reverse("recipes:favorite", args=[recipe.pk]))
assert Favorite.objects.filter(user=user, recipe=recipe).exists()
titles = [r.title for r in user_client.get(reverse("recipes:list"), {"ansicht": "favoriten"}).context["page"].object_list]
assert titles == ["Fremdes Rezept"]
user_client.post(reverse("recipes:favorite", args=[recipe.pk]))
assert not Favorite.objects.exists()
def test_favorite_redirect_is_local_only(user_client, user):
recipe = make_recipe(user)
response = user_client.post(reverse("recipes:favorite", args=[recipe.pk]), {"next": "https://evil.example/"})
assert response["Location"] == reverse("recipes:detail", args=[recipe.pk])
def test_mine_filter(user_client, user, other_user):
make_recipe(user, title="Meins")
make_recipe(other_user, title="Deins")
titles = [r.title for r in user_client.get(reverse("recipes:list"), {"ansicht": "meine"}).context["page"].object_list]
assert titles == ["Meins"]
def test_admin_deletes_recipes_of_deactivated_user(admin_client, user):
make_recipe(user, visibility=Recipe.Visibility.PRIVATE)
make_recipe(user)
url = reverse("accounts:admin_user_delete_recipes", args=[user.pk])
assert admin_client.get(url).status_code == 404 # only for deactivated users
user.is_active = False
user.save()
assert admin_client.get(url).context["count"] == 2
admin_client.post(url)
assert not Recipe.objects.exists()
def test_user_cannot_bulk_delete(user_client, other_user):
other_user.is_active = False
other_user.save()
make_recipe(other_user)
assert user_client.post(reverse("accounts:admin_user_delete_recipes", args=[other_user.pk])).status_code == 403
assert Recipe.objects.exists()
def test_new_recipe_form_defaults_to_public(user_client):
form = user_client.get(reverse("recipes:create")).context["form"]
assert form["visibility"].value() == "public"
def test_imported_draft_defaults_to_public(user_client, settings):
settings.OPENAI_API_KEY = ""
response = user_client.post(reverse("recipes:import_text"), {"text": "200 g Mehl, 2 Eier, verrühren und backen"})
assert response.context["form"]["visibility"].value() == "public"
def test_private_can_still_be_chosen(user_client):
user_client.post(reverse("recipes:create"), form_data(visibility="private"))
assert Recipe.objects.get().visibility == "private"
def test_tabs_and_own_recipes_tab(user_client, user, other_user):
make_recipe(user, title="Meins", visibility=Recipe.Visibility.PRIVATE)
make_recipe(other_user, title="Deins")
response = user_client.get(reverse("recipes:list"), {"ansicht": "meine"})
html = response.content.decode()
assert "Eigene Rezepte" in html and 'aria-current="page">Eigene Rezepte' in html
assert [r.title for r in response.context["page"].object_list] == ["Meins"]
all_titles = {r.title for r in user_client.get(reverse("recipes:list")).context["page"].object_list}
assert all_titles == {"Meins", "Deins"}
def test_search_stays_in_own_tab(user_client, user, other_user):
make_recipe(user, title="Linsensuppe")
make_recipe(other_user, title="Linsensalat")
response = user_client.get(reverse("recipes:list"), {"ansicht": "meine", "q": "linsen"})
assert [r.title for r in response.context["page"].object_list] == ["Linsensuppe"]
assert '<input type="hidden" name="ansicht" value="meine">' in response.content.decode()
def test_own_tab_empty_state(user_client, other_user):
make_recipe(other_user, title="Deins")
html = user_client.get(reverse("recipes:list"), {"ansicht": "meine"}).content.decode()
assert "noch keine eigenen Rezepte" in html and "Deins" not in html