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"", 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()