New recipes (manual, AI draft and all imports) now start as public; private can still be chosen per recipe. Existing recipes are unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
207 lines
9.1 KiB
Python
207 lines
9.1 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"
|