- 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]>
146 lines
5.0 KiB
Python
146 lines
5.0 KiB
Python
"""Acceptance: a private recipe is reachable by its owner only – in no view and under no URL,
|
||
including image URLs. Other users and admins get 404 (indistinguishable from "does not exist")."""
|
||
|
||
import pytest
|
||
from django.test import Client
|
||
from django.urls import reverse
|
||
|
||
from recipes.models import Favorite, Recipe, RecipeImage
|
||
|
||
from .recipe_helpers import image_upload, make_recipe
|
||
|
||
pytestmark = pytest.mark.django_db
|
||
|
||
SECRET_TITLE = "Geheimrezept Omas Gulasch"
|
||
SECRET_INGREDIENT = "Zauberpaprika"
|
||
SECRET_TAG = "geheimtag"
|
||
|
||
|
||
@pytest.fixture
|
||
def private_recipe(user):
|
||
recipe = make_recipe(user, title=SECRET_TITLE, visibility=Recipe.Visibility.PRIVATE,
|
||
ingredients=f"500 g {SECRET_INGREDIENT}", tags=(SECRET_TAG,))
|
||
owner_client(user).post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
|
||
assert recipe.images.count() == 1
|
||
return recipe
|
||
|
||
|
||
@pytest.fixture
|
||
def image(private_recipe):
|
||
return RecipeImage.objects.get(recipe=private_recipe)
|
||
|
||
|
||
@pytest.fixture(params=["other", "admin"])
|
||
def outsider(request, other_user, admin_user):
|
||
c = Client()
|
||
c.force_login(other_user if request.param == "other" else admin_user)
|
||
return c
|
||
|
||
|
||
def owner_client(user):
|
||
c = Client()
|
||
c.force_login(user)
|
||
return c
|
||
|
||
|
||
def get_urls(recipe, image):
|
||
return [
|
||
reverse("recipes:detail", args=[recipe.pk]),
|
||
reverse("recipes:detail", args=[recipe.pk]) + "?portionen=8",
|
||
reverse("recipes:edit", args=[recipe.pk]),
|
||
reverse("recipes:delete", args=[recipe.pk]),
|
||
reverse("recipes:photo", args=[image.uuid, "gross"]),
|
||
reverse("recipes:photo", args=[image.uuid, "klein"]),
|
||
*extra_get_urls(recipe, image),
|
||
]
|
||
|
||
|
||
def post_urls(recipe, image):
|
||
return [
|
||
reverse("recipes:edit", args=[recipe.pk]),
|
||
reverse("recipes:delete", args=[recipe.pk]),
|
||
reverse("recipes:favorite", args=[recipe.pk]),
|
||
reverse("recipes:photo_upload", args=[recipe.pk]),
|
||
reverse("recipes:photo_cover", args=[recipe.pk, image.pk]),
|
||
reverse("recipes:photo_delete", args=[recipe.pk, image.pk]),
|
||
*extra_post_urls(recipe, image),
|
||
]
|
||
|
||
|
||
def extra_get_urls(recipe, image):
|
||
"""Hook for later features (comments, cooking mode, …)."""
|
||
return []
|
||
|
||
|
||
def extra_post_urls(recipe, image):
|
||
return []
|
||
|
||
|
||
def test_owner_sees_everything(user, private_recipe, image):
|
||
c = owner_client(user)
|
||
for url in get_urls(private_recipe, image):
|
||
assert c.get(url).status_code == 200, url
|
||
assert SECRET_TITLE in c.get(reverse("recipes:list")).content.decode()
|
||
|
||
|
||
def test_outsider_gets_404_on_every_get_url(outsider, private_recipe, image):
|
||
for url in get_urls(private_recipe, image):
|
||
assert outsider.get(url).status_code == 404, url
|
||
|
||
|
||
def test_outsider_gets_404_on_every_post_url(outsider, private_recipe, image):
|
||
for url in post_urls(private_recipe, image):
|
||
response = outsider.post(url, {"title": "gehackt", "servings": 1, "visibility": "public", "text": "x"})
|
||
assert response.status_code == 404, url
|
||
private_recipe.refresh_from_db()
|
||
assert private_recipe.title == SECRET_TITLE and not private_recipe.is_public
|
||
assert RecipeImage.objects.filter(pk=image.pk).exists()
|
||
assert not Favorite.objects.exists()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"query",
|
||
[
|
||
{},
|
||
{"q": "Geheimrezept"},
|
||
{"q": SECRET_INGREDIENT},
|
||
{"q": SECRET_TAG},
|
||
{"zutat": SECRET_INGREDIENT},
|
||
{"tag": SECRET_TAG},
|
||
{"ansicht": "favoriten"},
|
||
],
|
||
)
|
||
def test_private_recipe_absent_from_list_and_search(outsider, private_recipe, image, query):
|
||
html = outsider.get(reverse("recipes:list"), query).content.decode()
|
||
assert SECRET_TITLE not in html
|
||
assert str(image.uuid) not in html
|
||
assert reverse("recipes:detail", args=[private_recipe.pk]) not in html
|
||
|
||
|
||
def test_private_tag_not_listed(outsider, private_recipe):
|
||
assert f"#{SECRET_TAG}" not in outsider.get(reverse("recipes:list")).content.decode()
|
||
|
||
|
||
def test_existing_favorite_disappears_when_recipe_turns_private(user, other_user):
|
||
recipe = make_recipe(user, title="Bald privat")
|
||
Favorite.objects.create(user=other_user, recipe=recipe)
|
||
recipe.visibility = Recipe.Visibility.PRIVATE
|
||
recipe.save()
|
||
c = owner_client(other_user)
|
||
assert "Bald privat" not in c.get(reverse("recipes:list"), {"ansicht": "favoriten"}).content.decode()
|
||
assert c.get(reverse("recipes:detail", args=[recipe.pk])).status_code == 404
|
||
|
||
|
||
def test_anonymous_gets_redirected_everywhere(private_recipe, image):
|
||
c = Client()
|
||
for url in get_urls(private_recipe, image):
|
||
response = c.get(url)
|
||
assert response.status_code == 302 and "/anmelden/" in response["Location"], url
|
||
|
||
|
||
def test_visible_to_queryset_rule(user, other_user, admin_user, private_recipe):
|
||
public = make_recipe(user, title="Öffentlich")
|
||
assert set(Recipe.objects.visible_to(user)) == {private_recipe, public}
|
||
assert set(Recipe.objects.visible_to(other_user)) == {public}
|
||
assert set(Recipe.objects.visible_to(admin_user)) == {public}
|