Files
Kochbuch/tests/test_recipes.py
T
Kyle DammannandClaude Opus 5.5 6f7e4f92ca Add phase 2: recipes with visibility, photos, search and favourites
- Recipe model with ingredients (amount/range, unit, group, note), steps,
  lowercase tags, author notes, source URL and private/public visibility
- Central RecipeQuerySet.visible_to() used by every read path; edit only
  by owner, delete by owner or admin (visible recipes only)
- Text-based editing: ingredient/step parser with roundtrip and scaling
- Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF,
  thumbnails, cover image, served only through permission-checked view
- Search across title, description, ingredients, tags; ingredient and tag
  filters; "mine" and favourites views
- Admin can delete all recipes of a deactivated user
- Acceptance tests: private recipes unreachable for others and admins
  in list, search, tags, favourites, detail, edit, delete and image URLs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:37:13 +01:00

191 lines
8.5 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()