1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-03 04:25:24 +02:00

feat: Cross-Household Recipes (#4089)

This commit is contained in:
Michael Genson 2024-08-31 21:54:10 -05:00 committed by GitHub
parent 7ef2e91ecf
commit 9acf9ec27c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 545 additions and 92 deletions

View file

@ -124,3 +124,20 @@ def test_admin_can_delete(
response = api_client.get(api_routes.comments_item_id(comment_id), headers=admin_user.token)
assert response.status_code == 404
def test_user_can_comment_on_other_household(api_client: TestClient, unique_recipe: Recipe, h2_user: TestUser):
# Create Comment
create_data = random_comment(unique_recipe.id)
response = api_client.post(api_routes.comments, json=create_data, headers=h2_user.token)
assert response.status_code == 201
# Delete Comment
comment_id = response.json()["id"]
response = api_client.delete(api_routes.comments_item_id(comment_id), headers=h2_user.token)
assert response.status_code == 200
# Validate Deletion
response = api_client.get(api_routes.comments_item_id(comment_id), headers=h2_user.token)
assert response.status_code == 404

View file

@ -0,0 +1,186 @@
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from tests.utils import api_routes
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
@pytest.mark.parametrize("is_private_household", [True, False])
def test_duplicate_recipe_changes_household(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
):
household = unique_user.repos.households.get_one(h2_user.household_id)
assert household and household.preferences
household.preferences.private_household = is_private_household
unique_user.repos.household_preferences.update(household.id, household.preferences)
source_recipe_name = random_string()
duplicate_recipe_name = random_string()
response = api_client.post(api_routes.recipes, json={"name": source_recipe_name}, headers=unique_user.token)
assert response.status_code == 201
recipe = unique_user.repos.recipes.get_one(response.json())
assert recipe
assert recipe.name == source_recipe_name
assert str(recipe.household_id) == unique_user.household_id
response = api_client.post(
api_routes.recipes_slug_duplicate(recipe.slug), json={"name": duplicate_recipe_name}, headers=h2_user.token
)
assert response.status_code == 201
duplicate_recipe = h2_user.repos.recipes.get_one(response.json()["slug"])
assert duplicate_recipe
assert duplicate_recipe.name == duplicate_recipe_name
assert str(duplicate_recipe.household_id) == h2_user.household_id != unique_user.household_id
@pytest.mark.parametrize("is_private_household", [True, False])
def test_get_all_recipes_includes_all_households(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
):
household = unique_user.repos.households.get_one(h2_user.household_id)
assert household and household.preferences
household.preferences.private_household = is_private_household
unique_user.repos.household_preferences.update(household.id, household.preferences)
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
assert response.status_code == 201
recipe = unique_user.repos.recipes.get_one(response.json())
assert recipe and recipe.id
recipe_id = recipe.id
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
assert response.status_code == 201
h2_recipe = h2_user.repos.recipes.get_one(response.json())
assert h2_recipe and h2_recipe.id
h2_recipe_id = h2_recipe.id
response = api_client.get(api_routes.recipes, params={"page": 1, "perPage": -1}, headers=unique_user.token)
assert response.status_code == 200
response_ids = {recipe["id"] for recipe in response.json()["items"]}
assert str(recipe_id) in response_ids
assert str(h2_recipe_id) in response_ids
@pytest.mark.parametrize("is_private_household", [True, False])
def test_get_one_recipe_from_another_household(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
):
household = unique_user.repos.households.get_one(h2_user.household_id)
assert household and household.preferences
household.preferences.private_household = is_private_household
unique_user.repos.household_preferences.update(household.id, household.preferences)
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
assert response.status_code == 201
h2_recipe = h2_user.repos.recipes.get_one(response.json())
assert h2_recipe and h2_recipe.id
h2_recipe_id = h2_recipe.id
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
assert response.status_code == 200
assert response.json()["id"] == str(h2_recipe_id)
@pytest.mark.parametrize("is_private_household", [True, False])
@pytest.mark.parametrize("use_patch", [True, False])
def test_prevent_updates_to_recipes_from_other_households(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool, use_patch: bool
):
household = unique_user.repos.households.get_one(h2_user.household_id)
assert household and household.preferences
household.preferences.private_household = is_private_household
unique_user.repos.household_preferences.update(household.id, household.preferences)
original_name = random_string()
response = api_client.post(api_routes.recipes, json={"name": original_name}, headers=h2_user.token)
assert response.status_code == 201
h2_recipe = h2_user.repos.recipes.get_one(response.json())
assert h2_recipe and h2_recipe.id
h2_recipe_id = h2_recipe.id
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
assert response.status_code == 200
recipe = response.json()
assert recipe["id"] == str(h2_recipe_id)
updated_name = random_string()
recipe["name"] = updated_name
client_func = api_client.patch if use_patch else api_client.put
response = client_func(api_routes.recipes_slug(recipe["slug"]), json=recipe, headers=unique_user.token)
assert response.status_code == 403
# confirm the recipe is unchanged
response = api_client.get(api_routes.recipes_slug(recipe["slug"]), headers=unique_user.token)
assert response.status_code == 200
updated_recipe = response.json()
assert updated_recipe["name"] == original_name != updated_name
@pytest.mark.parametrize("is_private_household", [True, False])
def test_prevent_deletes_to_recipes_from_other_households(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
):
household = unique_user.repos.households.get_one(h2_user.household_id)
assert household and household.preferences
household.preferences.private_household = is_private_household
unique_user.repos.household_preferences.update(household.id, household.preferences)
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
assert response.status_code == 201
h2_recipe = h2_user.repos.recipes.get_one(response.json())
assert h2_recipe and h2_recipe.id
h2_recipe_id = str(h2_recipe.id)
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
assert response.status_code == 200
recipe_json = response.json()
assert recipe_json["id"] == h2_recipe_id
response = api_client.delete(api_routes.recipes_slug(recipe_json["slug"]), headers=unique_user.token)
assert response.status_code == 403
# confirm the recipe still exists
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
assert response.status_code == 200
assert response.json()["id"] == h2_recipe_id
@pytest.mark.parametrize("is_private_household", [True, False])
def test_user_can_update_last_made_on_other_household(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
):
household = unique_user.repos.households.get_one(h2_user.household_id)
assert household and household.preferences
household.preferences.private_household = is_private_household
unique_user.repos.household_preferences.update(household.id, household.preferences)
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
assert response.status_code == 201
h2_recipe = h2_user.repos.recipes.get_one(response.json())
assert h2_recipe and h2_recipe.id
h2_recipe_id = h2_recipe.id
h2_recipe_slug = h2_recipe.slug
response = api_client.get(api_routes.recipes_slug(h2_recipe_slug), headers=unique_user.token)
assert response.status_code == 200
recipe = response.json()
assert recipe["id"] == str(h2_recipe_id)
old_last_made = recipe["lastMade"]
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
response = api_client.patch(
api_routes.recipes_slug_last_made(h2_recipe_slug), json={"timestamp": now}, headers=unique_user.token
)
assert response.status_code == 200
# confirm the last made date was updated
response = api_client.get(api_routes.recipes_slug(h2_recipe_slug), headers=unique_user.token)
assert response.status_code == 200
recipe = response.json()
assert recipe["id"] == str(h2_recipe_id)
new_last_made = recipe["lastMade"]
assert new_last_made == now != old_last_made

View file

@ -368,3 +368,47 @@ def test_recipe_rating_is_readonly(
assert response.status_code == 200
data = response.json()
assert data["rating"] == rating.rating
def test_user_can_rate_recipes_in_other_households(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
assert response.status_code == 201
recipe = unique_user.repos.recipes.get_one(response.json())
assert recipe and recipe.id
rating = UserRatingUpdate(rating=random.uniform(1, 5), is_favorite=True)
response = api_client.post(
api_routes.users_id_ratings_slug(h2_user.user_id, recipe.slug),
json=rating.model_dump(),
headers=h2_user.token,
)
assert response.status_code == 200
response = api_client.get(api_routes.users_self_ratings_recipe_id(recipe.id), headers=h2_user.token)
data = response.json()
assert data["recipeId"] == str(recipe.id)
assert data["rating"] == rating.rating
assert data["isFavorite"] is True
def test_average_recipe_rating_includes_all_households(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser
):
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
assert response.status_code == 201
recipe = unique_user.repos.recipes.get_one(response.json())
assert recipe
user_ratings = (UserRatingUpdate(rating=5), UserRatingUpdate(rating=2))
for i, user in enumerate([unique_user, h2_user]):
response = api_client.post(
api_routes.users_id_ratings_slug(user.user_id, recipe.slug),
json=user_ratings[i].model_dump(),
headers=user.token,
)
assert response.status_code == 200
response = api_client.get(api_routes.recipes_slug(recipe.slug), headers=unique_user.token)
assert response.status_code == 200
data = response.json()
assert data["rating"] == 3.5

View file

@ -35,11 +35,19 @@ def recipes(api_client: TestClient, unique_user: TestUser):
response = api_client.delete(f"{api_routes.recipes}/{slug}", headers=unique_user.token)
def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_create_timeline_event(
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
h2_user: TestUser,
use_other_household_user: bool,
):
user = h2_user if use_other_household_user else unique_user
recipe = recipes[0]
new_event = {
"recipe_id": str(recipe.id),
"user_id": str(unique_user.user_id),
"user_id": str(user.user_id),
"subject": random_string(),
"event_type": "info",
"message": random_string(),
@ -48,41 +56,53 @@ def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, re
event_response = api_client.post(
api_routes.recipes_timeline_events,
json=new_event,
headers=unique_user.token,
headers=user.token,
)
assert event_response.status_code == 201
event = RecipeTimelineEventOut.model_validate(event_response.json())
assert event.recipe_id == recipe.id
assert str(event.user_id) == str(unique_user.user_id)
assert str(event.user_id) == str(user.user_id)
def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_get_all_timeline_events(
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
h2_user: TestUser,
use_other_household_user: bool,
):
user = h2_user if use_other_household_user else unique_user
# create some events
recipe = recipes[0]
events_data = [
{
"recipe_id": str(recipe.id),
"user_id": str(unique_user.user_id),
"subject": random_string(),
"event_type": "info",
"message": random_string(),
}
for _ in range(10)
]
events_data: list[dict] = []
for user in [unique_user, h2_user]:
events_data.extend(
[
{
"recipe_id": str(recipe.id),
"user_id": str(user.user_id),
"subject": random_string(),
"event_type": "info",
"message": random_string(),
}
for _ in range(10)
]
)
events: list[RecipeTimelineEventOut] = []
for event_data in events_data:
params: dict = {"queryFilter": f"recipe_id={event_data['recipe_id']}"}
event_response = api_client.post(
api_routes.recipes_timeline_events, params=params, json=event_data, headers=unique_user.token
api_routes.recipes_timeline_events, params=params, json=event_data, headers=user.token
)
events.append(RecipeTimelineEventOut.model_validate(event_response.json()))
# check that we see them all
params = {"page": 1, "perPage": -1}
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=user.token)
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
event_ids = [event.id for event in events]
@ -93,12 +113,20 @@ def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser,
assert event_id in paginated_event_ids
def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_get_timeline_event(
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
h2_user: TestUser,
use_other_household_user: bool,
):
user = h2_user if use_other_household_user else unique_user
# create an event
recipe = recipes[0]
new_event_data = {
"recipe_id": str(recipe.id),
"user_id": str(unique_user.user_id),
"user_id": str(user.user_id),
"subject": random_string(),
"event_type": "info",
"message": random_string(),
@ -107,19 +135,27 @@ def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recip
event_response = api_client.post(
api_routes.recipes_timeline_events,
json=new_event_data,
headers=unique_user.token,
headers=user.token,
)
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
# fetch the new event
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token)
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=user.token)
assert event_response.status_code == 200
event = RecipeTimelineEventOut.model_validate(event_response.json())
assert event == new_event
def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_update_timeline_event(
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
h2_user: TestUser,
use_other_household_user: bool,
):
user = h2_user if use_other_household_user else unique_user
old_subject = random_string()
new_subject = random_string()
@ -127,12 +163,12 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
recipe = recipes[0]
new_event_data = {
"recipe_id": str(recipe.id),
"user_id": str(unique_user.user_id),
"user_id": str(user.user_id),
"subject": old_subject,
"event_type": "info",
}
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=user.token)
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
assert new_event.subject == old_subject
@ -142,7 +178,7 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
event_response = api_client.put(
api_routes.recipes_timeline_events_item_id(new_event.id),
json=updated_event_data,
headers=unique_user.token,
headers=user.token,
)
assert event_response.status_code == 200
@ -152,42 +188,54 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
assert updated_event.timestamp == new_event.timestamp
def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_delete_timeline_event(
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
h2_user: TestUser,
use_other_household_user: bool,
):
user = h2_user if use_other_household_user else unique_user
# create an event
recipe = recipes[0]
new_event_data = {
"recipe_id": str(recipe.id),
"user_id": str(unique_user.user_id),
"user_id": str(user.user_id),
"subject": random_string(),
"event_type": "info",
"message": random_string(),
}
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=user.token)
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
# delete the event
event_response = api_client.delete(
api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token
)
event_response = api_client.delete(api_routes.recipes_timeline_events_item_id(new_event.id), headers=user.token)
assert event_response.status_code == 200
deleted_event = RecipeTimelineEventOut.model_validate(event_response.json())
assert deleted_event.id == new_event.id
# try to get the event
event_response = api_client.get(
api_routes.recipes_timeline_events_item_id(deleted_event.id), headers=unique_user.token
)
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(deleted_event.id), headers=user.token)
assert event_response.status_code == 404
def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_timeline_event_message_alias(
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
h2_user: TestUser,
use_other_household_user: bool,
):
user = h2_user if use_other_household_user else unique_user
# create an event using aliases
recipe = recipes[0]
new_event_data = {
"recipeId": str(recipe.id),
"userId": str(unique_user.user_id),
"userId": str(user.user_id),
"subject": random_string(),
"eventType": "info",
"eventMessage": random_string(), # eventMessage is the correct alias for the message
@ -196,7 +244,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
event_response = api_client.post(
api_routes.recipes_timeline_events,
json=new_event_data,
headers=unique_user.token,
headers=user.token,
)
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
assert str(new_event.user_id) == new_event_data["userId"]
@ -204,7 +252,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
assert new_event.message == new_event_data["eventMessage"]
# fetch the new event
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token)
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=user.token)
assert event_response.status_code == 200
event = RecipeTimelineEventOut.model_validate(event_response.json())
@ -218,7 +266,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
event_response = api_client.put(
api_routes.recipes_timeline_events_item_id(new_event.id),
json=updated_event_data,
headers=unique_user.token,
headers=user.token,
)
assert event_response.status_code == 200
@ -227,20 +275,27 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
assert updated_event.message == new_message
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_timeline_event_update_image(
api_client: TestClient, unique_user: TestUser, recipes: list[Recipe], test_image_jpg: str
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
test_image_jpg: str,
h2_user: TestUser,
use_other_household_user: bool,
):
user = h2_user if use_other_household_user else unique_user
# create an event
recipe = recipes[0]
new_event_data = {
"recipe_id": str(recipe.id),
"user_id": str(unique_user.user_id),
"user_id": str(user.user_id),
"subject": random_string(),
"message": random_string(),
"event_type": "info",
}
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=user.token)
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
assert new_event.image == TimelineEventImage.does_not_have_image.value
@ -249,7 +304,7 @@ def test_timeline_event_update_image(
api_routes.recipes_timeline_events_item_id_image(new_event.id),
files={"image": ("test_image_jpg.jpg", f, "image/jpeg")},
data={"extension": "jpg"},
headers=unique_user.token,
headers=user.token,
)
r.raise_for_status()
@ -258,7 +313,7 @@ def test_timeline_event_update_image(
event_response = api_client.get(
api_routes.recipes_timeline_events_item_id(new_event.id),
headers=unique_user.token,
headers=user.token,
)
assert event_response.status_code == 200
@ -269,23 +324,35 @@ def test_timeline_event_update_image(
assert updated_event.image == TimelineEventImage.has_image.value
def test_create_recipe_with_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_create_recipe_with_timeline_event(
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
h2_user: TestUser,
use_other_household_user: bool,
):
user = h2_user if use_other_household_user else unique_user
# make sure when the recipes fixture was created that all recipes have at least one event
for recipe in recipes:
params = {"queryFilter": f"recipe_id={recipe.id}"}
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=user.token)
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
assert events_pagination.items
def test_invalid_recipe_id(api_client: TestClient, unique_user: TestUser):
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_invalid_recipe_id(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, use_other_household_user: bool
):
user = h2_user if use_other_household_user else unique_user
new_event_data = {
"recipe_id": str(uuid4()),
"user_id": str(unique_user.user_id),
"user_id": str(user.user_id),
"subject": random_string(),
"event_type": "info",
"message": random_string(),
}
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=user.token)
assert event_response.status_code == 404