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

@ -21,31 +21,23 @@
<v-spacer></v-spacer>
<div v-if="!open" class="custom-btn-group ma-1">
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :recipe-id="recipe.id" show-always />
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
<RecipeFavoriteBadge v-if="loggedIn" class="ml-1" color="info" button-style :recipe-id="recipe.id" show-always />
<RecipeTimelineBadge v-if="loggedIn" button-style class="ml-1" :slug="recipe.slug" :recipe-name="recipe.name" />
<div v-if="loggedIn">
<v-tooltip v-if="!locked" bottom color="info">
<v-tooltip v-if="canEdit" bottom color="info">
<template #activator="{ on, attrs }">
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
<v-btn fab small class="ml-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
<v-icon> {{ $globals.icons.edit }} </v-icon>
</v-btn>
</template>
<span>{{ $t("general.edit") }}</span>
</v-tooltip>
<v-tooltip v-else bottom color="info">
<template #activator="{ on, attrs }">
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on">
<v-icon> {{ $globals.icons.lock }} </v-icon>
</v-btn>
</template>
<span> {{ $t("recipe.locked-by-owner") }} </span>
</v-tooltip>
</div>
<RecipeTimerMenu
fab
color="info"
class="mr-1"
class="ml-1"
/>
<RecipeContextMenu
@ -72,6 +64,7 @@
share: loggedIn,
recipeActions: true,
}"
class="ml-1"
@print="$emit('print')"
/>
</div>
@ -135,7 +128,7 @@ export default defineComponent({
required: true,
type: String,
},
locked: {
canEdit: {
type: Boolean,
default: false,
},

View File

@ -45,7 +45,7 @@
:recipe="recipe"
:slug="recipe.slug"
:recipe-scale="recipeScale"
:locked="isOwnGroup && user.id !== recipe.userId && recipe.settings.locked"
:can-edit="canEditRecipe"
:name="recipe.name"
:logged-in="isOwnGroup"
:open="isEditMode"
@ -64,6 +64,7 @@
<script lang="ts">
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useRecipePermissions } from "~/composables/recipes";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
@ -99,6 +100,7 @@ export default defineComponent({
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const { isOwnGroup } = useLoggedInState();
const { canEditRecipe } = useRecipePermissions(props.recipe, user);
function printRecipe() {
window.print();
@ -125,6 +127,7 @@ export default defineComponent({
setMode,
toggleEditMode,
recipeImage,
canEditRecipe,
imageKey,
user,
PageMode,

View File

@ -4,3 +4,4 @@ export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-rec
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
export { useNutritionLabels } from "./use-recipe-nutrition";
export { useTools } from "./use-recipe-tools";
export { useRecipePermissions } from "./use-recipe-permissions";

View File

@ -0,0 +1,81 @@
import { describe, test, expect } from "vitest";
import { useRecipePermissions } from "./use-recipe-permissions";
import { Recipe } from "~/lib/api/types/recipe";
import { UserOut } from "~/lib/api/types/user";
describe("test use recipe permissions", () => {
const commonUserId = "my-user-id";
const commonGroupId = "my-group-id";
const commonHouseholdId = "my-household-id";
const createRecipe = (overrides: Partial<Recipe>, isLocked = false): Recipe => ({
id: "my-recipe-id",
userId: commonUserId,
groupId: commonGroupId,
householdId: commonHouseholdId,
settings: {
locked: isLocked,
},
...overrides,
});
const createUser = (overrides: Partial<UserOut>): UserOut => ({
id: commonUserId,
groupId: commonGroupId,
groupSlug: "my-group",
group: "my-group",
householdId: commonHouseholdId,
householdSlug: "my-household",
household: "my-household",
email: "bender.rodriguez@example.com",
cacheKey: "1234",
...overrides,
});
test("when user is null, cannot edit", () => {
const result = useRecipePermissions(createRecipe({}), null);
expect(result.canEditRecipe.value).toBe(false);
});
test("when user is recipe owner, can edit", () => {
const result = useRecipePermissions(createRecipe({}), createUser({}));
expect(result.canEditRecipe.value).toBe(true);
});
test("when user is not recipe owner, is correct group and household, and recipe is unlocked, can edit", () => {
const result = useRecipePermissions(
createRecipe({}),
createUser({ id: "other-user-id" }),
);
expect(result.canEditRecipe.value).toBe(true);
});
test("when user is not recipe owner, and user is other group, cannot edit", () => {
const result = useRecipePermissions(
createRecipe({}),
createUser({ id: "other-user-id", groupId: "other-group-id"}),
);
expect(result.canEditRecipe.value).toBe(false);
});
test("when user is not recipe owner, and user is other household, cannot edit", () => {
const result = useRecipePermissions(
createRecipe({}),
createUser({ id: "other-user-id", householdId: "other-household-id" }),
);
expect(result.canEditRecipe.value).toBe(false);
});
test("when user is not recipe owner, and recipe is locked, cannot edit", () => {
const result = useRecipePermissions(
createRecipe({}, true),
createUser({ id: "other-user-id"}),
);
expect(result.canEditRecipe.value).toBe(false);
});
test("when user is recipe owner, and recipe is locked, can edit", () => {
const result = useRecipePermissions(createRecipe({}, true), createUser({}));
expect(result.canEditRecipe.value).toBe(true);
});
});

View File

@ -0,0 +1,34 @@
import { computed } from "@nuxtjs/composition-api";
import { Recipe } from "~/lib/api/types/recipe";
import { UserOut } from "~/lib/api/types/user";
export function useRecipePermissions(recipe: Recipe, user: UserOut | null) {
const canEditRecipe = computed(() => {
// Check recipe owner
if (!user?.id) {
return false;
}
if (user.id === recipe.userId) {
return true;
}
// Check group and household
if (user.groupId !== recipe.groupId) {
return false;
}
if (user.householdId !== recipe.householdId) {
return false;
}
// Check recipe
if (recipe.settings?.locked) {
return false;
}
return true;
});
return {
canEditRecipe,
}
}

View File

@ -84,8 +84,10 @@ export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
};
export const useRecipes = (
all = false, fetchRecipes = true,
all = false,
fetchRecipes = true,
loadFood = false,
queryFilter: string | null = null,
publicGroupSlug: string | null = null
) => {
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
@ -108,7 +110,7 @@ export const useRecipes = (
})();
async function refreshRecipes() {
const { data } = await api.recipes.getAll(page, perPage, { loadFood, orderBy: "created_at" });
const { data } = await api.recipes.getAll(page, perPage, { loadFood, orderBy: "created_at", queryFilter });
if (data) {
recipes.value = data.items;
}

View File

@ -27,8 +27,7 @@ export default defineComponent({
async function fetchHousehold() {
const { data } = await api.households.getCurrentUserHousehold();
if (data) {
// TODO: once users are able to fetch other households' recipes, remove the household filter
queryFilter.value = `recipe.group_id="${data.groupId}" AND recipe.household_id="${data.id}"`;
queryFilter.value = `recipe.group_id="${data.groupId}"`;
groupName.value = data.group;
}

View File

@ -178,10 +178,8 @@ export default defineComponent({
components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData, RecipeSettingsSwitches },
scrollToTop: true,
setup() {
const { getAllRecipes, refreshRecipes } = useRecipes(true, true);
const { $globals, i18n } = useContext();
const { $auth, $globals, i18n } = useContext();
const { getAllRecipes, refreshRecipes } = useRecipes(true, true, false, `householdId=${$auth.user?.householdId || ""}`);
const selected = ref<Recipe[]>([]);
function resetAll() {

View File

@ -32,6 +32,7 @@ from mealie.core.dependencies import (
from mealie.core.security import create_recipe_slug_token
from mealie.db.models.household.cookbook import CookBook
from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.routes._base import BaseCrudController, controller
@ -94,9 +95,13 @@ class JSONBytes(JSONResponse):
class BaseRecipeController(BaseCrudController):
@cached_property
def repo(self) -> RepositoryRecipes:
def recipes(self) -> RepositoryRecipes:
return self.repos.recipes
@cached_property
def group_recipes(self) -> RepositoryRecipes:
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
@cached_property
def cookbooks_repo(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return self.repos.cookbooks
@ -107,7 +112,7 @@ class BaseRecipeController(BaseCrudController):
@cached_property
def mixins(self):
return HttpRepo[CreateRecipe, Recipe, Recipe](self.repo, self.logger)
return HttpRepo[CreateRecipe, Recipe, Recipe](self.recipes, self.logger)
class FormatResponse(BaseModel):
@ -331,8 +336,9 @@ class RecipeController(BaseRecipeController):
if cookbook_data is None:
raise HTTPException(status_code=404, detail="cookbook not found")
# we use the repo by user so we can sort favorites correctly
pagination_response = self.repos.recipes.by_user(self.user.id).page_all(
# We use "group_recipes" here so we can return all recipes regardless of household. The query filter can include
# a household_id to filter by household. We use the "by_user" so we can sort favorites correctly.
pagination_response = self.group_recipes.by_user(self.user.id).page_all(
pagination=q,
cookbook=cookbook_data,
categories=categories,
@ -362,7 +368,7 @@ class RecipeController(BaseRecipeController):
def get_one(self, slug: str = Path(..., description="A recipe's slug or id")):
"""Takes in a recipe's slug or id and returns all data for a recipe"""
try:
recipe = self.service.get_one_by_slug_or_id(slug)
recipe = self.service.get_one(slug)
except Exception as e:
self.handle_exceptions(e)
return None
@ -534,7 +540,7 @@ class RecipeController(BaseRecipeController):
data_service = RecipeDataService(recipe.id)
data_service.write_image(image, extension)
new_version = self.repo.update_image(slug, extension)
new_version = self.recipes.update_image(slug, extension)
return UpdateImageResponse(image=new_version)
@router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"])

View File

@ -4,6 +4,7 @@ from functools import cached_property
from fastapi import Depends, File, Form, HTTPException
from pydantic import UUID4
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import BaseCrudController, controller
from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
@ -31,8 +32,8 @@ class RecipeTimelineEventsController(BaseCrudController):
return self.repos.recipe_timeline_events
@cached_property
def recipes_repo(self):
return self.repos.recipes
def group_recipes(self):
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
@cached_property
def mixins(self):
@ -57,7 +58,7 @@ class RecipeTimelineEventsController(BaseCrudController):
# if the user id is not specified, use the currently-authenticated user
data.user_id = data.user_id or self.user.id
recipe = self.recipes_repo.get_one(data.recipe_id, "id")
recipe = self.group_recipes.get_one(data.recipe_id, "id")
if not recipe:
raise HTTPException(status_code=404, detail="recipe not found")
@ -87,7 +88,7 @@ class RecipeTimelineEventsController(BaseCrudController):
@events_router.put("/{item_id}", response_model=RecipeTimelineEventOut)
def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate):
event = self.mixins.patch_one(data, item_id)
recipe = self.recipes_repo.get_one(event.recipe_id, "id")
recipe = self.group_recipes.get_one(event.recipe_id, "id")
if recipe:
self.publish_event(
event_type=EventTypes.recipe_updated,
@ -114,7 +115,7 @@ class RecipeTimelineEventsController(BaseCrudController):
except FileNotFoundError:
pass
recipe = self.recipes_repo.get_one(event.recipe_id, "id")
recipe = self.group_recipes.get_one(event.recipe_id, "id")
if recipe:
self.publish_event(
event_type=EventTypes.recipe_updated,
@ -144,7 +145,7 @@ class RecipeTimelineEventsController(BaseCrudController):
if event.image != TimelineEventImage.has_image.value:
event.image = TimelineEventImage.has_image
event = self.mixins.patch_one(event.cast(RecipeTimelineEventUpdate), event.id)
recipe = self.recipes_repo.get_one(event.recipe_id, "id")
recipe = self.group_recipes.get_one(event.recipe_id, "id")
if recipe:
self.publish_event(
event_type=EventTypes.recipe_updated,

View File

@ -1,8 +1,10 @@
from functools import cached_property
from uuid import UUID
from fastapi import HTTPException, status
from pydantic import UUID4
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed
@ -14,6 +16,10 @@ router = UserAPIRouter()
@controller(router)
class UserRatingsController(BaseUserController):
@cached_property
def group_recipes(self):
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
def get_recipe_or_404(self, slug_or_id: str | UUID):
"""Fetches a recipe by slug or id, or raises a 404 error if not found."""
if isinstance(slug_or_id, str):
@ -22,11 +28,10 @@ class UserRatingsController(BaseUserController):
except ValueError:
pass
recipes_repo = self.repos.recipes
if isinstance(slug_or_id, UUID):
recipe = recipes_repo.get_one(slug_or_id, key="id")
recipe = self.group_recipes.get_one(slug_or_id, key="id")
else:
recipe = recipes_repo.get_one(slug_or_id, key="slug")
recipe = self.group_recipes.get_one(slug_or_id, key="slug")
if not recipe:
raise HTTPException(

View File

@ -16,6 +16,7 @@ from mealie.core.config import get_app_settings
from mealie.core.dependencies.dependencies import get_temporary_path
from mealie.lang.providers import Translator
from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.schema.household.household import HouseholdInDB
@ -46,6 +47,9 @@ class RecipeServiceBase(BaseService):
if repos.household_id != user.household_id != household.id:
raise Exception("household ids do not match")
self.group_recipes = get_repositories(repos.session, group_id=repos.group_id, household_id=None).recipes
"""Recipes repo without a Household filter"""
self.translator = translator
self.t = translator.t
@ -54,7 +58,7 @@ class RecipeServiceBase(BaseService):
class RecipeService(RecipeServiceBase):
def _get_recipe(self, data: str | UUID, key: str | None = None) -> Recipe:
recipe = self.repos.recipes.get_one(data, key)
recipe = self.group_recipes.get_one(data, key)
if recipe is None:
raise exceptions.NoEntryFound("Recipe not found.")
return recipe
@ -62,7 +66,18 @@ class RecipeService(RecipeServiceBase):
def can_update(self, recipe: Recipe) -> bool:
if recipe.settings is None:
raise exceptions.UnexpectedNone("Recipe Settings is None")
return recipe.settings.locked is False or self.user.id == recipe.user_id
# Check if this user owns the recipe
if self.user.id == recipe.user_id:
return True
# Check if this user has permission to edit this recipe
if self.household.id != recipe.household_id:
return False
if recipe.settings.locked:
return False
return True
def can_lock_unlock(self, recipe: Recipe) -> bool:
return recipe.user_id == self.user.id
@ -120,7 +135,7 @@ class RecipeService(RecipeServiceBase):
return Recipe(**additional_attrs)
def get_one_by_slug_or_id(self, slug_or_id: str | UUID) -> Recipe | None:
def get_one(self, slug_or_id: str | UUID) -> Recipe | None:
if isinstance(slug_or_id, str):
try:
slug_or_id = UUID(slug_or_id)
@ -393,9 +408,10 @@ class RecipeService(RecipeServiceBase):
return new_data
def update_last_made(self, slug: str, timestamp: datetime) -> Recipe:
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked,
# or if the user belongs to a different household
recipe = self._get_recipe(slug)
return self.repos.recipes.patch(recipe.slug, {"last_made": timestamp})
return self.group_recipes.patch(recipe.slug, {"last_made": timestamp})
def delete_one(self, slug) -> Recipe:
recipe = self._get_recipe(slug)

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