mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-21 14:03:32 -07:00
Feature/shopping lists second try (#927)
* generate types * use generated types * ui updates * init button link for common styles * add links * setup label views * add delete confirmation * reset when not saved * link label to foods and auto set when adding to shopping list * generate types * use inheritence to manage exception handling * fix schema generation and add test for open_api generation * add header to api docs * move list consilidation to service * split list and list items controller * shopping list/list item tests - PARTIAL * enable recipe add/remove in shopping lists * generate types * linting * init global utility components * update types and add list item api * fix import cycle and database error * add container and border classes * new recipe list component * fix tests * breakout item editor * refactor item editor * update bulk actions * update input / color contrast * type generation * refactor controller dependencies * include food/unit editor * remove console.logs * fix and update type generation * fix incorrect type for column * fix postgres error * fix delete by variable * auto remove refs * fix typo
This commit is contained in:
parent
f794208862
commit
92cf97e401
66 changed files with 2556 additions and 685 deletions
1
tests/fixtures/__init__.py
vendored
1
tests/fixtures/__init__.py
vendored
|
@ -2,4 +2,5 @@ from .fixture_admin import *
|
|||
from .fixture_database import *
|
||||
from .fixture_recipe import *
|
||||
from .fixture_routes import *
|
||||
from .fixture_shopping_lists import *
|
||||
from .fixture_users import *
|
||||
|
|
33
tests/fixtures/fixture_recipe.py
vendored
33
tests/fixtures/fixture_recipe.py
vendored
|
@ -1,5 +1,11 @@
|
|||
import sqlalchemy
|
||||
from pytest import fixture
|
||||
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
|
||||
from tests.utils.factories import random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
from tests.utils.recipe_data import get_raw_no_image, get_raw_recipe, get_recipe_test_cases
|
||||
|
||||
|
||||
|
@ -16,3 +22,30 @@ def raw_recipe_no_image():
|
|||
@fixture(scope="session")
|
||||
def recipe_store():
|
||||
return get_recipe_test_cases()
|
||||
|
||||
|
||||
@fixture(scope="function")
|
||||
def recipe_ingredient_only(database: AllRepositories, unique_user: TestUser):
|
||||
# Create a recipe
|
||||
recipe = Recipe(
|
||||
user_id=unique_user.user_id,
|
||||
group_id=unique_user.group_id,
|
||||
name=random_string(10),
|
||||
recipe_ingredient=[
|
||||
RecipeIngredient(note="Ingredient 1"),
|
||||
RecipeIngredient(note="Ingredient 2"),
|
||||
RecipeIngredient(note="Ingredient 3"),
|
||||
RecipeIngredient(note="Ingredient 4"),
|
||||
RecipeIngredient(note="Ingredient 5"),
|
||||
RecipeIngredient(note="Ingredient 6"),
|
||||
],
|
||||
)
|
||||
|
||||
model = database.recipes.create(recipe)
|
||||
|
||||
yield model
|
||||
|
||||
try:
|
||||
database.recipes.delete(model.slug)
|
||||
except sqlalchemy.exc.NoResultFound: # Entry Deleted in Test
|
||||
pass
|
||||
|
|
85
tests/fixtures/fixture_shopping_lists.py
vendored
Normal file
85
tests/fixtures/fixture_shopping_lists.py
vendored
Normal file
|
@ -0,0 +1,85 @@
|
|||
import pytest
|
||||
import sqlalchemy
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListItemCreate, ShoppingListOut, ShoppingListSave
|
||||
from tests.utils.factories import random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
def create_item(list_id: UUID4) -> dict:
|
||||
return {
|
||||
"shopping_list_id": str(list_id),
|
||||
"checked": False,
|
||||
"position": 0,
|
||||
"is_food": False,
|
||||
"note": random_string(10),
|
||||
"quantity": 1,
|
||||
"unit_id": None,
|
||||
"unit": None,
|
||||
"food_id": None,
|
||||
"food": None,
|
||||
"recipe_id": None,
|
||||
"label_id": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def shopping_lists(database: AllRepositories, unique_user: TestUser):
|
||||
|
||||
models: list[ShoppingListOut] = []
|
||||
|
||||
for _ in range(3):
|
||||
model = database.group_shopping_lists.create(
|
||||
ShoppingListSave(name=random_string(10), group_id=unique_user.group_id),
|
||||
)
|
||||
|
||||
models.append(model)
|
||||
|
||||
yield models
|
||||
|
||||
for model in models:
|
||||
try:
|
||||
database.group_shopping_lists.delete(model.id)
|
||||
except Exception: # Entry Deleted in Test
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def shopping_list(database: AllRepositories, unique_user: TestUser):
|
||||
|
||||
model = database.group_shopping_lists.create(
|
||||
ShoppingListSave(name=random_string(10), group_id=unique_user.group_id),
|
||||
)
|
||||
|
||||
yield model
|
||||
|
||||
try:
|
||||
database.group_shopping_lists.delete(model.id)
|
||||
except Exception: # Entry Deleted in Test
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def list_with_items(database: AllRepositories, unique_user: TestUser):
|
||||
list_model = database.group_shopping_lists.create(
|
||||
ShoppingListSave(name=random_string(10), group_id=unique_user.group_id),
|
||||
)
|
||||
|
||||
for _ in range(10):
|
||||
database.group_shopping_list_item.create(
|
||||
ShoppingListItemCreate(
|
||||
**create_item(list_model.id),
|
||||
)
|
||||
)
|
||||
|
||||
# refresh model
|
||||
list_model = database.group_shopping_lists.get(list_model.id)
|
||||
|
||||
yield list_model
|
||||
|
||||
try:
|
||||
database.group_shopping_lists.delete(list_model.id)
|
||||
except sqlalchemy.exc.NoResultFound: # Entry Deleted in Test
|
||||
pass
|
6
tests/integration_tests/test_openapi.py
Normal file
6
tests/integration_tests/test_openapi.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_openapi_returns_json(api_client: TestClient):
|
||||
response = api_client.get("openapi.json")
|
||||
assert response.status_code == 200
|
|
@ -0,0 +1,199 @@
|
|||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut
|
||||
from tests import utils
|
||||
from tests.utils.factories import random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
class Routes:
|
||||
shopping = "/api/groups/shopping"
|
||||
items = shopping + "/items"
|
||||
|
||||
def item(item_id: str) -> str:
|
||||
return f"{Routes.items}/{item_id}"
|
||||
|
||||
def shopping_list(list_id: str) -> str:
|
||||
return f"{Routes.shopping}/lists/{list_id}"
|
||||
|
||||
|
||||
def create_item(list_id: UUID4) -> dict:
|
||||
return {
|
||||
"shopping_list_id": str(list_id),
|
||||
"checked": False,
|
||||
"position": 0,
|
||||
"is_food": False,
|
||||
"note": random_string(10),
|
||||
"quantity": 1,
|
||||
"unit_id": None,
|
||||
"unit": None,
|
||||
"food_id": None,
|
||||
"food": None,
|
||||
"recipe_id": None,
|
||||
"label_id": None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_list_items(list_items: list[ShoppingListItemOut]) -> list:
|
||||
as_dict = []
|
||||
for item in list_items:
|
||||
item_dict = item.dict(by_alias=True)
|
||||
item_dict["shoppingListId"] = str(item.shopping_list_id)
|
||||
item_dict["id"] = str(item.id)
|
||||
as_dict.append(item_dict)
|
||||
|
||||
return as_dict
|
||||
|
||||
|
||||
def test_shopping_list_items_create_one(
|
||||
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
|
||||
) -> None:
|
||||
item = create_item(shopping_list.id)
|
||||
|
||||
response = api_client.post(Routes.items, json=item, headers=unique_user.token)
|
||||
as_json = utils.assert_derserialize(response, 201)
|
||||
|
||||
# Test Item is Getable
|
||||
created_item_id = as_json["id"]
|
||||
response = api_client.get(Routes.item(created_item_id), headers=unique_user.token)
|
||||
as_json = utils.assert_derserialize(response, 200)
|
||||
|
||||
# Ensure List Id is Set
|
||||
assert as_json["shoppingListId"] == str(shopping_list.id)
|
||||
|
||||
# Test Item In List
|
||||
response = api_client.get(Routes.shopping_list(shopping_list.id), headers=unique_user.token)
|
||||
response_list = utils.assert_derserialize(response, 200)
|
||||
|
||||
assert len(response_list["listItems"]) == 1
|
||||
|
||||
# Check Item Id's
|
||||
assert response_list["listItems"][0]["id"] == created_item_id
|
||||
|
||||
|
||||
def test_shopping_list_items_get_one(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
list_with_items: ShoppingListOut,
|
||||
) -> None:
|
||||
|
||||
for _ in range(3):
|
||||
item = random.choice(list_with_items.list_items)
|
||||
|
||||
response = api_client.get(Routes.item(item.id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_shopping_list_items_get_one_404(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
response = api_client.get(Routes.item(uuid4()), headers=unique_user.token)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_shopping_list_items_update_one(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
list_with_items: ShoppingListOut,
|
||||
) -> None:
|
||||
for _ in range(3):
|
||||
item = random.choice(list_with_items.list_items)
|
||||
|
||||
item.note = random_string(10)
|
||||
|
||||
update_data = create_item(list_with_items.id)
|
||||
update_data["id"] = str(item.id)
|
||||
|
||||
response = api_client.put(Routes.item(item.id), json=update_data, headers=unique_user.token)
|
||||
item_json = utils.assert_derserialize(response, 200)
|
||||
assert item_json["note"] == update_data["note"]
|
||||
|
||||
|
||||
def test_shopping_list_items_delete_one(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
list_with_items: ShoppingListOut,
|
||||
) -> None:
|
||||
item = random.choice(list_with_items.list_items)
|
||||
|
||||
# Delete Item
|
||||
response = api_client.delete(Routes.item(item.id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Validate Get Item Returns 404
|
||||
response = api_client.get(Routes.item(item.id), headers=unique_user.token)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_shopping_list_items_update_many(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
assert True
|
||||
|
||||
|
||||
def test_shopping_list_items_update_many_reorder(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
list_with_items: ShoppingListOut,
|
||||
) -> None:
|
||||
list_items = list_with_items.list_items
|
||||
|
||||
# reorder list in random order
|
||||
random.shuffle(list_items)
|
||||
|
||||
# update List posiitons and serialize
|
||||
as_dict = []
|
||||
for i, item in enumerate(list_items):
|
||||
item.position = i
|
||||
item_dict = item.dict(by_alias=True)
|
||||
item_dict["shoppingListId"] = str(list_with_items.id)
|
||||
item_dict["id"] = str(item.id)
|
||||
as_dict.append(item_dict)
|
||||
|
||||
# update list
|
||||
response = api_client.put(Routes.items, json=as_dict, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
# retrieve list and check positions against list
|
||||
response = api_client.get(Routes.shopping_list(list_with_items.id), headers=unique_user.token)
|
||||
response_list = utils.assert_derserialize(response, 200)
|
||||
|
||||
for i, item in enumerate(response_list["listItems"]):
|
||||
assert item["position"] == i
|
||||
assert item["id"] == str(list_items[i].id)
|
||||
|
||||
|
||||
def test_shopping_list_items_update_many_consolidates_common_items(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
list_with_items: ShoppingListOut,
|
||||
) -> None:
|
||||
list_items = list_with_items.list_items
|
||||
|
||||
master_note = random_string(10)
|
||||
|
||||
# set quantity and note to trigger consolidation
|
||||
for li in list_items:
|
||||
li.quantity = 1
|
||||
li.note = master_note
|
||||
|
||||
# update list
|
||||
response = api_client.put(Routes.items, json=serialize_list_items(list_items), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
# retrieve list and check positions against list
|
||||
response = api_client.get(Routes.shopping_list(list_with_items.id), headers=unique_user.token)
|
||||
response_list = utils.assert_derserialize(response, 200)
|
||||
|
||||
assert len(response_list["listItems"]) == 1
|
||||
assert response_list["listItems"][0]["quantity"] == len(list_items)
|
||||
assert response_list["listItems"][0]["note"] == master_note
|
||||
|
||||
|
||||
def test_shopping_list_items_update_many_remove_recipe_with_other_items(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
list_with_items: ShoppingListOut,
|
||||
) -> None:
|
||||
# list_items = list_with_items.list_items
|
||||
pass
|
|
@ -0,0 +1,201 @@
|
|||
import random
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListOut
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from tests import utils
|
||||
from tests.utils.factories import random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
class Routes:
|
||||
base = "/api/groups/shopping/lists"
|
||||
|
||||
def item(item_id: str) -> str:
|
||||
return f"{Routes.base}/{item_id}"
|
||||
|
||||
def add_recipe(item_id: str, recipe_id: str) -> str:
|
||||
return f"{Routes.item(item_id)}/recipe/{recipe_id}"
|
||||
|
||||
|
||||
def test_shopping_lists_get_all(api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]):
|
||||
all_lists = api_client.get(Routes.base, headers=unique_user.token)
|
||||
assert all_lists.status_code == 200
|
||||
all_lists = all_lists.json()
|
||||
|
||||
assert len(all_lists) == len(shopping_lists)
|
||||
|
||||
known_ids = [str(model.id) for model in shopping_lists]
|
||||
|
||||
for list_ in all_lists:
|
||||
assert list_["id"] in known_ids
|
||||
|
||||
|
||||
def test_shopping_lists_create_one(api_client: TestClient, unique_user: TestUser):
|
||||
payload = {
|
||||
"name": random_string(10),
|
||||
}
|
||||
|
||||
response = api_client.post(Routes.base, json=payload, headers=unique_user.token)
|
||||
response_list = utils.assert_derserialize(response, 201)
|
||||
|
||||
assert response_list["name"] == payload["name"]
|
||||
assert response_list["groupId"] == str(unique_user.group_id)
|
||||
|
||||
|
||||
def test_shopping_lists_get_one(api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]):
|
||||
shopping_list = shopping_lists[0]
|
||||
|
||||
response = api_client.get(Routes.item(shopping_list.id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_list = response.json()
|
||||
|
||||
assert response_list["id"] == str(shopping_list.id)
|
||||
assert response_list["name"] == shopping_list.name
|
||||
assert response_list["groupId"] == str(shopping_list.group_id)
|
||||
|
||||
|
||||
def test_shopping_lists_update_one(
|
||||
api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]
|
||||
):
|
||||
sample_list = random.choice(shopping_lists)
|
||||
|
||||
payload = {
|
||||
"name": random_string(10),
|
||||
"id": str(sample_list.id),
|
||||
"groupId": str(sample_list.group_id),
|
||||
"listItems": [],
|
||||
}
|
||||
|
||||
response = api_client.put(Routes.item(sample_list.id), json=payload, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_list = response.json()
|
||||
|
||||
assert response_list["id"] == str(sample_list.id)
|
||||
assert response_list["name"] == payload["name"]
|
||||
assert response_list["groupId"] == str(sample_list.group_id)
|
||||
|
||||
|
||||
def test_shopping_lists_delete_one(
|
||||
api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]
|
||||
):
|
||||
sample_list = random.choice(shopping_lists)
|
||||
|
||||
response = api_client.delete(Routes.item(sample_list.id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_shopping_lists_add_recipe(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
shopping_lists: list[ShoppingListOut],
|
||||
recipe_ingredient_only: Recipe,
|
||||
):
|
||||
sample_list = random.choice(shopping_lists)
|
||||
|
||||
recipe = recipe_ingredient_only
|
||||
|
||||
response = api_client.post(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get List and Check for Ingredients
|
||||
|
||||
response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token)
|
||||
as_json = utils.assert_derserialize(response, 200)
|
||||
|
||||
assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)
|
||||
|
||||
known_ingredients = [ingredient.note for ingredient in recipe.recipe_ingredient]
|
||||
|
||||
for item in as_json["listItems"]:
|
||||
assert item["note"] in known_ingredients
|
||||
|
||||
# Check Recipe Reference was added with quantity 1
|
||||
refs = item["recipeReferences"]
|
||||
|
||||
assert len(refs) == 1
|
||||
|
||||
assert refs[0]["recipeId"] == recipe.id
|
||||
|
||||
|
||||
def test_shopping_lists_remove_recipe(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
shopping_lists: list[ShoppingListOut],
|
||||
recipe_ingredient_only: Recipe,
|
||||
):
|
||||
sample_list = random.choice(shopping_lists)
|
||||
|
||||
recipe = recipe_ingredient_only
|
||||
|
||||
response = api_client.post(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get List and Check for Ingredients
|
||||
response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token)
|
||||
as_json = utils.assert_derserialize(response, 200)
|
||||
|
||||
assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)
|
||||
|
||||
known_ingredients = [ingredient.note for ingredient in recipe.recipe_ingredient]
|
||||
|
||||
for item in as_json["listItems"]:
|
||||
assert item["note"] in known_ingredients
|
||||
|
||||
# Remove Recipe
|
||||
response = api_client.delete(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token)
|
||||
|
||||
# Get List and Check for Ingredients
|
||||
response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token)
|
||||
as_json = utils.assert_derserialize(response, 200)
|
||||
assert len(as_json["listItems"]) == 0
|
||||
assert len(as_json["recipeReferences"]) == 0
|
||||
|
||||
|
||||
def test_shopping_lists_remove_recipe_multiple_quantity(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
shopping_lists: list[ShoppingListOut],
|
||||
recipe_ingredient_only: Recipe,
|
||||
):
|
||||
sample_list = random.choice(shopping_lists)
|
||||
|
||||
recipe = recipe_ingredient_only
|
||||
|
||||
for _ in range(3):
|
||||
response = api_client.post(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token)
|
||||
as_json = utils.assert_derserialize(response, 200)
|
||||
|
||||
assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)
|
||||
|
||||
known_ingredients = [ingredient.note for ingredient in recipe.recipe_ingredient]
|
||||
|
||||
for item in as_json["listItems"]:
|
||||
assert item["note"] in known_ingredients
|
||||
|
||||
# Remove Recipe
|
||||
response = api_client.delete(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token)
|
||||
|
||||
# Get List and Check for Ingredients
|
||||
response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token)
|
||||
as_json = utils.assert_derserialize(response, 200)
|
||||
|
||||
# All Items Should Still Exists
|
||||
assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)
|
||||
|
||||
# Quantity Should Equal 2 Start with 3 remove 1)
|
||||
for item in as_json["listItems"]:
|
||||
assert item["quantity"] == 2.0
|
||||
|
||||
refs = as_json["recipeReferences"]
|
||||
assert len(refs) == 1
|
||||
assert refs[0]["recipeId"] == recipe.id
|
|
@ -1,4 +1,5 @@
|
|||
from .app_routes import *
|
||||
from .assertion_helpers import *
|
||||
from .factories import *
|
||||
from .fixture_schemas import *
|
||||
from .user_login import *
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
from requests import Response
|
||||
|
||||
|
||||
def assert_ignore_keys(dict1: dict, dict2: dict, ignore_keys: list = None) -> None:
|
||||
"""
|
||||
Itterates through a list of keys and checks if they are in the the provided ignore_keys list,
|
||||
|
@ -15,3 +18,8 @@ def assert_ignore_keys(dict1: dict, dict2: dict, ignore_keys: list = None) -> No
|
|||
continue
|
||||
else:
|
||||
assert value == dict2[key]
|
||||
|
||||
|
||||
def assert_derserialize(response: Response, expected_status_code=200) -> dict:
|
||||
assert response.status_code == expected_status_code
|
||||
return response.json()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue