feature: proper multi-tenant-support (#969)(WIP)

* update naming

* refactor tests to use shared structure

* shorten names

* add tools test case

* refactor to support multi-tenant

* set group_id on creation

* initial refactor for multitenant tags/cats

* spelling

* additional test case for same valued resources

* fix recipe update tests

* apply indexes to foreign keys

* fix performance regressions

* handle unknown exception

* utility decorator for function debugging

* migrate recipe_id to UUID

* GUID for recipes

* remove unused import

* move image functions into package

* move utilities to packages dir

* update import

* linter

* image image and asset routes

* update assets and images to use UUIDs

* fix migration base

* image asset test coverage

* use ids for categories and tag crud functions

* refactor recipe organizer test suite to reduce duplication

* add uuid serlization utility

* organizer base router

* slug routes testing and fixes

* fix postgres error

* adopt UUIDs

* move tags, categories, and tools under "organizers" umbrella

* update composite label

* generate ts types

* fix import error

* update frontend types

* fix type errors

* fix postgres errors

* fix #978

* add null check for title validation

* add note in docs on multi-tenancy
This commit is contained in:
Hayden 2022-02-13 12:23:42 -09:00 committed by GitHub
commit c617251f4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
157 changed files with 1866 additions and 1578 deletions

View file

@ -2,7 +2,8 @@ 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 import Recipe, RecipeCategory
from mealie.schema.recipe.recipe_category import CategorySave
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
@ -49,3 +50,23 @@ def recipe_ingredient_only(database: AllRepositories, unique_user: TestUser):
database.recipes.delete(model.slug)
except sqlalchemy.exc.NoResultFound: # Entry Deleted in Test
pass
@fixture(scope="function")
def recipe_categories(database: AllRepositories, unique_user: TestUser) -> list[RecipeCategory]:
models: list[RecipeCategory] = []
for _ in range(3):
category = CategorySave(
group_id=unique_user.group_id,
name=random_string(10),
)
model = database.categories.create(category)
models.append(model)
yield models
for model in models:
try:
database.categories.delete(model.id)
except sqlalchemy.exc.NoResultFound:
pass

View file

@ -1,107 +0,0 @@
from dataclasses import dataclass
import pytest
from fastapi.testclient import TestClient
from mealie.schema.static import recipe_keys
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/categories"
recipes = "/api/recipes"
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
def recipe(recipe_id: int) -> str:
return f"{Routes.recipes}/{recipe_id}"
@dataclass
class TestRecipeCategory:
id: int
name: str
slug: str
recipes: list
@pytest.fixture(scope="function")
def category(api_client: TestClient, unique_user: TestUser) -> TestRecipeCategory:
data = {"name": random_string(10)}
response = api_client.post(Routes.base, json=data, headers=unique_user.token)
assert response.status_code == 201
as_json = response.json()
yield TestRecipeCategory(
id=as_json["id"],
name=data["name"],
slug=as_json["slug"],
recipes=[],
)
try:
response = api_client.delete(Routes.item(response.json()["slug"]), headers=unique_user.token)
except Exception:
pass
def test_create_category(api_client: TestClient, unique_user: TestUser):
data = {"name": random_string(10)}
response = api_client.post(Routes.base, json=data, headers=unique_user.token)
assert response.status_code == 201
def test_read_category(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser):
response = api_client.get(Routes.item(category.slug), headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
assert as_json["id"] == category.id
assert as_json["name"] == category.name
def test_update_category(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser):
update_data = {
"id": category.id,
"name": random_string(10),
"slug": category.slug,
}
response = api_client.put(Routes.item(category.slug), json=update_data, headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
assert as_json["id"] == category.id
assert as_json["name"] == update_data["name"]
def test_delete_category(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser):
response = api_client.delete(Routes.item(category.slug), headers=unique_user.token)
assert response.status_code == 200
def test_recipe_category_association(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser):
# Setup Recipe
recipe_data = {"name": random_string(10)}
response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token)
slug = response.json()
assert response.status_code == 201
# Get Recipe Data
response = api_client.get(Routes.recipe(slug), headers=unique_user.token)
as_json = response.json()
as_json[recipe_keys.recipe_category] = [{"id": category.id, "name": category.name, "slug": category.slug}]
# Update Recipe
response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token)
assert response.status_code == 200
# Get Recipe Data
response = api_client.get(Routes.recipe(slug), headers=unique_user.token)
as_json = response.json()
assert as_json[recipe_keys.recipe_category][0]["slug"] == category.slug

View file

@ -0,0 +1,198 @@
import pytest
from fastapi.testclient import TestClient
from mealie.schema.static import recipe_keys
from tests.utils import routes
from tests.utils.factories import random_bool, random_string
from tests.utils.fixture_schemas import TestUser
# Test IDs to be used to identify the test cases - order matters!
test_ids = [
"category",
"tags",
"tools",
]
organizer_routes = [
(routes.RoutesCategory),
(routes.RoutesTags),
(routes.RoutesTools),
]
@pytest.mark.parametrize("route", organizer_routes, ids=test_ids)
def test_organizers_create_read(api_client: TestClient, unique_user: TestUser, route: routes.RoutesBase):
data = {"name": random_string(10)}
response = api_client.post(route.base, json=data, headers=unique_user.token)
assert response.status_code == 201
item_id = response.json()["id"]
response = api_client.get(route.item(item_id), headers=unique_user.token)
assert response.status_code == 200
item = response.json()
assert item["id"] == item_id
assert item["name"] == data["name"]
assert item["slug"] == data["name"]
response = api_client.delete(route.item(item_id), headers=unique_user.token)
assert response.status_code == 200
update_data = [
(routes.RoutesCategory, {"name": random_string(10)}),
(routes.RoutesTags, {"name": random_string(10)}),
(routes.RoutesTools, {"name": random_string(10), "onHand": random_bool()}),
]
@pytest.mark.parametrize("route, update_data", update_data, ids=test_ids)
def test_organizer_update(
api_client: TestClient,
unique_user: TestUser,
route: routes.RoutesBase,
update_data: dict,
):
data = {"name": random_string(10)}
response = api_client.post(route.base, json=data, headers=unique_user.token)
assert response.status_code == 201
item = response.json()
item_id = item["id"]
# Update the item if the key is presetn in the update_data
for key in update_data:
if key in item:
item[key] = update_data[key]
response = api_client.put(route.item(item_id), json=item, headers=unique_user.token)
assert response.status_code == 200
response = api_client.get(route.item(item_id), headers=unique_user.token)
item = response.json()
for key, value in update_data.items():
if key in item:
assert item[key] == value
@pytest.mark.parametrize("route", organizer_routes, ids=test_ids)
def test_organizer_delete(
api_client: TestClient,
unique_user: TestUser,
route: routes.RoutesBase,
):
data = {"name": random_string(10)}
response = api_client.post(route.base, json=data, headers=unique_user.token)
assert response.status_code == 201
item = response.json()
item_id = item["id"]
response = api_client.delete(route.item(item_id), headers=unique_user.token)
assert response.status_code == 200
response = api_client.get(route.item(item_id), headers=unique_user.token)
assert response.status_code == 404
association_data = [
(routes.RoutesCategory, recipe_keys.recipe_category),
(routes.RoutesTags, "tags"),
(routes.RoutesTools, "tools"),
]
@pytest.mark.parametrize("route, recipe_key", association_data, ids=test_ids)
def test_organizer_association(
api_client: TestClient,
unique_user: TestUser,
route: routes.RoutesBase,
recipe_key: str,
):
data = {"name": random_string(10)}
# Setup Organizer
response = api_client.post(route.base, json=data, headers=unique_user.token)
assert response.status_code == 201
item = response.json()
# Setup Recipe
recipe_data = {"name": random_string(10)}
response = api_client.post(routes.RoutesRecipe.base, json=recipe_data, headers=unique_user.token)
slug = response.json()
assert response.status_code == 201
# Get Recipe Data
response = api_client.get(routes.RoutesRecipe.item(slug), headers=unique_user.token)
as_json = response.json()
as_json[recipe_key] = [{"id": item["id"], "name": item["name"], "slug": item["slug"]}]
# Update Recipe
response = api_client.put(routes.RoutesRecipe.item(slug), json=as_json, headers=unique_user.token)
assert response.status_code == 200
# Get Recipe Data
response = api_client.get(routes.RoutesRecipe.item(slug), headers=unique_user.token)
as_json = response.json()
assert as_json[recipe_key][0]["slug"] == item["slug"]
# Cleanup
response = api_client.delete(routes.RoutesRecipe.item(slug), headers=unique_user.token)
assert response.status_code == 200
response = api_client.delete(route.item(item["id"]), headers=unique_user.token)
assert response.status_code == 200
@pytest.mark.parametrize("route, recipe_key", association_data, ids=test_ids)
def test_organizer_get_by_slug(
api_client: TestClient,
unique_user: TestUser,
route: routes.RoutesOrganizerBase,
recipe_key: str,
):
# Create Organizer
data = {"name": random_string(10)}
response = api_client.post(route.base, json=data, headers=unique_user.token)
assert response.status_code == 201
item = response.json()
# Create 10 Recipes
recipe_slugs = []
for _ in range(10):
# Setup Recipe
recipe_data = {"name": random_string(10)}
response = api_client.post(routes.RoutesRecipe.base, json=recipe_data, headers=unique_user.token)
assert response.status_code == 201
slug = response.json()
recipe_slugs.append(slug)
# Associate 10 Recipes to Organizer
for slug in recipe_slugs:
response = api_client.get(routes.RoutesRecipe.item(slug), headers=unique_user.token)
as_json = response.json()
as_json[recipe_key] = [{"id": item["id"], "name": item["name"], "slug": item["slug"]}]
response = api_client.put(routes.RoutesRecipe.item(slug), json=as_json, headers=unique_user.token)
assert response.status_code == 200
# Get Organizer by Slug
response = api_client.get(route.slug(item["slug"]), headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
assert as_json["slug"] == item["slug"]
recipes = as_json["recipes"]
# Check if Organizer is returned with 10 RecipeSummary
assert len(recipes) == len(recipe_slugs)
for recipe in recipes:
assert recipe["slug"] in recipe_slugs

View file

@ -1,106 +0,0 @@
from dataclasses import dataclass
import pytest
from fastapi.testclient import TestClient
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/tags"
recipes = "/api/recipes"
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
def recipe(recipe_id: int) -> str:
return f"{Routes.recipes}/{recipe_id}"
@dataclass
class TestRecipeTag:
id: int
name: str
slug: str
recipes: list
@pytest.fixture(scope="function")
def tag(api_client: TestClient, unique_user: TestUser) -> TestRecipeTag:
data = {"name": random_string(10)}
response = api_client.post(Routes.base, json=data, headers=unique_user.token)
assert response.status_code == 201
as_json = response.json()
yield TestRecipeTag(
id=as_json["id"],
name=data["name"],
slug=as_json["slug"],
recipes=[],
)
try:
response = api_client.delete(Routes.item(response.json()["slug"]), headers=unique_user.token)
except Exception:
pass
def test_create_tag(api_client: TestClient, unique_user: TestUser):
data = {"name": random_string(10)}
response = api_client.post(Routes.base, json=data, headers=unique_user.token)
assert response.status_code == 201
def test_read_tag(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser):
response = api_client.get(Routes.item(tag.slug), headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
assert as_json["id"] == tag.id
assert as_json["name"] == tag.name
def test_update_tag(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser):
update_data = {
"id": tag.id,
"name": random_string(10),
"slug": tag.slug,
}
response = api_client.put(Routes.item(tag.slug), json=update_data, headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
assert as_json["id"] == tag.id
assert as_json["name"] == update_data["name"]
def test_delete_tag(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser):
response = api_client.delete(Routes.item(tag.slug), headers=unique_user.token)
assert response.status_code == 200
def test_recipe_tag_association(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser):
# Setup Recipe
recipe_data = {"name": random_string(10)}
response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token)
slug = response.json()
assert response.status_code == 201
# Get Recipe Data
response = api_client.get(Routes.recipe(slug), headers=unique_user.token)
as_json = response.json()
as_json["tags"] = [{"id": tag.id, "name": tag.name, "slug": tag.slug}]
# Update Recipe
response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token)
assert response.status_code == 200
# Get Recipe Data
response = api_client.get(Routes.recipe(slug), headers=unique_user.token)
as_json = response.json()
assert as_json["tags"][0]["slug"] == tag.slug

View file

@ -1,110 +0,0 @@
from dataclasses import dataclass
import pytest
from fastapi.testclient import TestClient
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/tools"
recipes = "/api/recipes"
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
def recipe(recipe_id: int) -> str:
return f"{Routes.recipes}/{recipe_id}"
@dataclass
class TestRecipeTool:
id: int
name: str
slug: str
on_hand: bool
recipes: list
@pytest.fixture(scope="function")
def tool(api_client: TestClient, unique_user: TestUser) -> TestRecipeTool:
data = {"name": random_string(10)}
response = api_client.post(Routes.base, json=data, headers=unique_user.token)
assert response.status_code == 201
as_json = response.json()
yield TestRecipeTool(
id=as_json["id"],
name=data["name"],
slug=as_json["slug"],
on_hand=as_json["onHand"],
recipes=[],
)
try:
response = api_client.delete(Routes.item(response.json()["id"]), headers=unique_user.token)
except Exception:
pass
def test_create_tool(api_client: TestClient, unique_user: TestUser):
data = {"name": random_string(10)}
response = api_client.post(Routes.base, json=data, headers=unique_user.token)
assert response.status_code == 201
def test_read_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser):
response = api_client.get(Routes.item(tool.id), headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
assert as_json["id"] == tool.id
assert as_json["name"] == tool.name
def test_update_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser):
update_data = {
"id": tool.id,
"name": random_string(10),
"slug": tool.slug,
"on_hand": True,
}
response = api_client.put(Routes.item(tool.id), json=update_data, headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
assert as_json["id"] == tool.id
assert as_json["name"] == update_data["name"]
def test_delete_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser):
response = api_client.delete(Routes.item(tool.id), headers=unique_user.token)
assert response.status_code == 200
def test_recipe_tool_association(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser):
# Setup Recipe
recipe_data = {"name": random_string(10)}
response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token)
slug = response.json()
assert response.status_code == 201
# Get Recipe Data
response = api_client.get(Routes.recipe(slug), headers=unique_user.token)
as_json = response.json()
as_json["tools"] = [{"id": tool.id, "name": tool.name, "slug": tool.slug}]
# Update Recipe
response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token)
assert response.status_code == 200
# Get Recipe Data
response = api_client.get(Routes.recipe(slug), headers=unique_user.token)
as_json = response.json()
assert as_json["tools"][0]["id"] == tool.id

View file

@ -48,6 +48,7 @@ def test_create_mealplan_with_recipe(api_client: TestClient, unique_user: TestUs
new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).dict(by_alias=True)
new_plan["date"] = date.today().strftime("%Y-%m-%d")
new_plan["recipeId"] = str(recipe_id)
response = api_client.post(Routes.base, json=new_plan, headers=unique_user.token)
response_json = response.json()

View file

@ -1,38 +0,0 @@
from fastapi.testclient import TestClient
from mealie.repos.all_repositories import AllRepositories
from tests.utils.assertion_helpers import assert_ignore_keys
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/groups/categories"
@staticmethod
def item(item_id: int | str) -> str:
return f"{Routes.base}/{item_id}"
def test_group_mealplan_set_preferences(api_client: TestClient, unique_user: TestUser, database: AllRepositories):
# Create Categories
categories = [{"name": x} for x in ["Breakfast", "Lunch", "Dinner"]]
created = []
for category in categories:
create = database.categories.create(category)
created.append(create.dict())
# Set Category Preferences
response = api_client.put(Routes.base, json=created, headers=unique_user.token)
assert response.status_code == 200
# Get Category Preferences
response = api_client.get(Routes.base, headers=unique_user.token)
assert response.status_code == 200
as_dict = response.json()
assert len(as_dict) == len(categories)
for api_data, expected in zip(as_dict, created):
assert_ignore_keys(api_data, expected, ["id", "recipes"])

View file

@ -7,6 +7,7 @@ from pydantic import UUID4
from mealie.repos.all_repositories import AllRepositories
from mealie.schema.meal_plan.plan_rules import PlanRulesOut, PlanRulesSave
from mealie.schema.recipe.recipe import RecipeCategory
from mealie.schema.recipe.recipe_category import CategorySave
from tests import utils
from tests.utils.fixture_schemas import TestUser
@ -20,9 +21,12 @@ class Routes:
@pytest.fixture(scope="function")
def category(database: AllRepositories):
def category(
database: AllRepositories,
unique_user: TestUser,
):
slug = utils.random_string(length=10)
model = database.categories.create(RecipeCategory(slug=slug, name=slug))
model = database.categories.create(CategorySave(group_id=unique_user.group_id, slug=slug, name=slug))
yield model
@ -61,7 +65,7 @@ def test_group_mealplan_rules_create(
"categories": [category.dict()],
}
response = api_client.post(Routes.base, json=payload, headers=unique_user.token)
response = api_client.post(Routes.base, json=utils.jsonify(payload), headers=unique_user.token)
assert response.status_code == 201
# Validate the response data

View file

@ -121,7 +121,7 @@ def test_shopping_lists_add_recipe(
assert len(refs) == 1
assert refs[0]["recipeId"] == recipe.id
assert refs[0]["recipeId"] == str(recipe.id)
def test_shopping_lists_remove_recipe(
@ -198,4 +198,4 @@ def test_shopping_lists_remove_recipe_multiple_quantity(
refs = as_json["recipeReferences"]
assert len(refs) == 1
assert refs[0]["recipeId"] == recipe.id
assert refs[0]["recipeId"] == str(recipe.id)

View file

@ -7,7 +7,8 @@ from fastapi.testclient import TestClient
from mealie.core.dependencies.dependencies import validate_file_token
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe_bulk_actions import ExportTypes
from mealie.schema.recipe.recipe_category import TagIn
from mealie.schema.recipe.recipe_category import CategorySave, TagSave
from tests import utils
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
@ -20,8 +21,8 @@ class Routes:
bulk_delete = "api/recipes/bulk-actions/delete"
bulk_export = "api/recipes/bulk-actions/export"
bulk_export_download = bulk_export + "/download"
bulk_export_purge = bulk_export + "/purge"
bulk_export_download = f"{bulk_export}/download"
bulk_export_purge = f"{bulk_export}/purge"
@pytest.fixture(scope="function")
@ -53,12 +54,12 @@ def test_bulk_tag_recipes(
tags = []
for _ in range(3):
tag_name = random_string()
tag = database.tags.create(TagIn(name=tag_name))
tag = database.tags.create(TagSave(group_id=unique_user.group_id, name=tag_name))
tags.append(tag.dict())
payload = {"recipes": ten_slugs, "tags": tags}
response = api_client.post(Routes.bulk_tag, json=payload, headers=unique_user.token)
response = api_client.post(Routes.bulk_tag, json=utils.jsonify(payload), headers=unique_user.token)
assert response.status_code == 200
# Validate Recipes are Tagged
@ -79,12 +80,12 @@ def test_bulk_categorize_recipes(
categories = []
for _ in range(3):
cat_name = random_string()
cat = database.tags.create(TagIn(name=cat_name))
cat = database.categories.create(CategorySave(group_id=unique_user.group_id, name=cat_name))
categories.append(cat.dict())
payload = {"recipes": ten_slugs, "categories": categories}
response = api_client.post(Routes.bulk_categorize, json=payload, headers=unique_user.token)
response = api_client.post(Routes.bulk_categorize, json=utils.jsonify(payload), headers=unique_user.token)
assert response.status_code == 200
# Validate Recipes are Categorized
@ -140,7 +141,7 @@ def test_bulk_export_recipes(api_client: TestClient, unique_user: TestUser, ten_
assert validate_file_token(response_data["fileToken"]) == Path(export_path)
# Use Export Token to donwload export
response = api_client.get("/api/utils/download?token=" + response_data["fileToken"])
response = api_client.get(f'/api/utils/download?token={response_data["fileToken"]}')
assert response.status_code == 200

View file

@ -1,5 +1,6 @@
import pytest
from fastapi.testclient import TestClient
from pydantic import UUID4
from mealie.schema.recipe.recipe import Recipe
from tests.utils.factories import random_string
@ -32,11 +33,11 @@ def unique_recipe(api_client: TestClient, unique_user: TestUser):
return Recipe(**recipe_response.json())
def random_comment(recipe_id: int) -> dict:
def random_comment(recipe_id: UUID4) -> dict:
if recipe_id is None:
raise ValueError("recipe_id is required")
return {
"recipeId": recipe_id,
"recipeId": str(recipe_id),
"text": random_string(length=50),
}
@ -49,7 +50,7 @@ def test_create_comment(api_client: TestClient, unique_recipe: Recipe, unique_us
response_data = response.json()
assert response_data["recipeId"] == unique_recipe.id
assert response_data["recipeId"] == str(unique_recipe.id)
assert response_data["text"] == create_data["text"]
assert response_data["userId"] == unique_user.user_id
@ -60,7 +61,7 @@ def test_create_comment(api_client: TestClient, unique_recipe: Recipe, unique_us
response_data = response.json()
assert len(response_data) == 1
assert response_data[0]["recipeId"] == unique_recipe.id
assert response_data[0]["recipeId"] == str(unique_recipe.id)
assert response_data[0]["text"] == create_data["text"]
assert response_data[0]["userId"] == unique_user.user_id
@ -83,7 +84,7 @@ def test_update_comment(api_client: TestClient, unique_recipe: Recipe, unique_us
response_data = response.json()
assert response_data["recipeId"] == unique_recipe.id
assert response_data["recipeId"] == str(unique_recipe.id)
assert response_data["text"] == update_data["text"]
assert response_data["userId"] == unique_user.user_id

View file

@ -10,8 +10,10 @@ from recipe_scrapers._abstract import AbstractScraper
from recipe_scrapers._schemaorg import SchemaOrg
from slugify import slugify
from mealie.services.scraper import scraper
from mealie.schema.recipe.recipe import RecipeCategory
from mealie.services.recipe.recipe_data_service import RecipeDataService
from mealie.services.scraper.scraper_strategies import RecipeScraperOpenGraph
from tests import utils
from tests.utils.app_routes import AppRoutes
from tests.utils.fixture_schemas import TestUser
from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases
@ -73,8 +75,8 @@ def test_create_by_url(
)
# Skip image downloader
monkeypatch.setattr(
scraper,
"download_image_for_recipe",
RecipeDataService,
"scrape_image",
lambda *_: "TEST_IMAGE",
)
@ -88,7 +90,11 @@ def test_create_by_url(
@pytest.mark.parametrize("recipe_data", recipe_test_data)
def test_read_update(
api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser
api_client: TestClient,
api_routes: AppRoutes,
recipe_data: RecipeSiteTestCase,
unique_user: TestUser,
recipe_categories: list[RecipeCategory],
):
recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug)
response = api_client.get(recipe_url, headers=unique_user.token)
@ -103,14 +109,9 @@ def test_read_update(
recipe["notes"] = test_notes
test_categories = [
{"name": "one", "slug": "one"},
{"name": "two", "slug": "two"},
{"name": "three", "slug": "three"},
]
recipe["recipeCategory"] = test_categories
recipe["recipeCategory"] = [x.dict() for x in recipe_categories]
response = api_client.put(recipe_url, json=recipe, headers=unique_user.token)
response = api_client.put(recipe_url, json=utils.jsonify(recipe), headers=unique_user.token)
assert response.status_code == 200
assert json.loads(response.text).get("slug") == recipe_data.expected_slug
@ -121,10 +122,10 @@ def test_read_update(
assert recipe["notes"] == test_notes
assert len(recipe["recipeCategory"]) == len(test_categories)
assert len(recipe["recipeCategory"]) == len(recipe_categories)
test_name = [x["name"] for x in test_categories]
for cats in zip(recipe["recipeCategory"], test_categories):
test_name = [x.name for x in recipe_categories]
for cats in zip(recipe["recipeCategory"], recipe_categories):
assert cats[0]["name"] in test_name

View file

@ -0,0 +1,64 @@
import filecmp
from fastapi.testclient import TestClient
from slugify import slugify
from mealie.schema.recipe.recipe import Recipe
from tests import data
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
def test_recipe_assets_create(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
recipe = recipe_ingredient_only
payload = {
"slug": recipe.slug,
"name": random_string(10),
"icon": random_string(10),
"extension": "jpg",
}
file_payload = {
"file": data.images_test_image_1.read_bytes(),
}
response = api_client.post(
f"/api/recipes/{recipe.slug}/assets",
data=payload,
files=file_payload,
headers=unique_user.token,
)
assert response.status_code == 200
# Ensure asset was created
asset_path = recipe.asset_dir / str(slugify(payload["name"]) + "." + payload["extension"])
assert asset_path.exists()
assert filecmp.cmp(asset_path, data.images_test_image_1)
# Ensure asset data is included in recipe
response = api_client.get(f"/api/recipes/{recipe.slug}", headers=unique_user.token)
recipe_respons = response.json()
assert recipe_respons["assets"][0]["name"] == payload["name"]
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
data_payload = {"extension": "jpg"}
file_payload = {"image": data.images_test_image_1.read_bytes()}
response = api_client.put(
f"/api/recipes/{recipe_ingredient_only.slug}/image",
data=data_payload,
files=file_payload,
headers=unique_user.token,
)
assert response.status_code == 200
image_version = response.json()["image"]
# Get Recipe check for version
response = api_client.get(f"/api/recipes/{recipe_ingredient_only.slug}", headers=unique_user.token)
recipe_respons = response.json()
assert recipe_respons["image"] == image_version

View file

@ -89,7 +89,7 @@ def test_recipe_share_tokens_create_and_get_one(
recipe = database.recipes.get_one(slug)
payload = {
"recipe_id": recipe.id,
"recipeId": str(recipe.id),
}
response = api_client.post(Routes.base, json=payload, headers=unique_user.token)
@ -99,7 +99,7 @@ def test_recipe_share_tokens_create_and_get_one(
assert response.status_code == 200
response_data = response.json()
assert response_data["recipe"]["id"] == recipe.id
assert response_data["recipe"]["id"] == str(recipe.id)
def test_recipe_share_tokens_delete_one(

View file

@ -0,0 +1,35 @@
from abc import ABC, abstractmethod
from typing import Tuple
from fastapi import Response
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
class ABCMultiTenantTestCase(ABC):
def __init__(self, database: AllRepositories, client: TestClient) -> None:
self.database = database
self.client = client
self.items = []
@abstractmethod
def seed_action(repos: AllRepositories, group_id: str) -> set[int] | set[str]:
...
def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[int], set[int]]:
pass
@abstractmethod
def get_all(token: str) -> Response:
...
@abstractmethod
def cleanup(self) -> None:
...
def __enter__(self):
pass
def __exit__(self, exc_type, exc_val, exc_tb):
self.cleanup()

View file

@ -0,0 +1,53 @@
from typing import Tuple
from requests import Response
from mealie.schema.recipe.recipe import RecipeCategory
from mealie.schema.recipe.recipe_category import CategorySave
from tests import utils
from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase
from tests.utils import routes
class CategoryTestCase(ABCMultiTenantTestCase):
items: list[RecipeCategory]
def seed_action(self, group_id: str) -> set[int]:
category_ids: set[int] = set()
for _ in range(10):
category = self.database.categories.create(
CategorySave(
group_id=group_id,
name=utils.random_string(10),
)
)
self.items.append(category)
category_ids.add(str(category.id))
return category_ids
def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[str], set[str]]:
g1_item_ids = set()
g2_item_ids = set()
for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]:
for _ in range(10):
name = utils.random_string(10)
category = self.database.categories.create(
CategorySave(
group_id=group_id,
name=name,
)
)
item_ids.add(str(category.id))
self.items.append(category)
return g1_item_ids, g2_item_ids
def get_all(self, token: str) -> Response:
return self.client.get(routes.RoutesCategory.base, headers=token)
def cleanup(self) -> None:
for item in self.items:
self.database.categories.delete(item.id)

View file

@ -0,0 +1,52 @@
from typing import Tuple
from requests import Response
from mealie.schema.recipe.recipe_ingredient import IngredientFood, SaveIngredientFood
from tests import utils
from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase
from tests.utils import routes
class FoodsTestCase(ABCMultiTenantTestCase):
items: list[IngredientFood]
def seed_action(self, group_id: str) -> set[int]:
food_ids: set[int] = set()
for _ in range(10):
food = self.database.ingredient_foods.create(
SaveIngredientFood(
group_id=group_id,
name=utils.random_string(10),
)
)
food_ids.add(str(food.id))
self.items.append(food)
return food_ids
def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[str], set[str]]:
g1_item_ids = set()
g2_item_ids = set()
for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]:
for _ in range(10):
name = utils.random_string(10)
food = self.database.ingredient_foods.create(
SaveIngredientFood(
group_id=group_id,
name=name,
)
)
item_ids.add(str(food.id))
self.items.append(food)
return g1_item_ids, g2_item_ids
def get_all(self, token: str) -> Response:
return self.client.get(routes.RoutesFoods.base, headers=token)
def cleanup(self) -> None:
for item in self.items:
self.database.ingredient_foods.delete(item.id)

View file

@ -0,0 +1,53 @@
from typing import Tuple
from requests import Response
from mealie.schema.recipe.recipe import RecipeTag
from mealie.schema.recipe.recipe_category import TagSave
from tests import utils
from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase
from tests.utils import routes
class TagsTestCase(ABCMultiTenantTestCase):
items: list[RecipeTag]
def seed_action(self, group_id: str) -> set[int]:
tag_ids: set[int] = set()
for _ in range(10):
tag = self.database.tags.create(
TagSave(
group_id=group_id,
name=utils.random_string(10),
)
)
tag_ids.add(str(tag.id))
self.items.append(tag)
return tag_ids
def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[str], set[str]]:
g1_item_ids = set()
g2_item_ids = set()
for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]:
for _ in range(10):
name = utils.random_string(10)
category = self.database.tags.create(
TagSave(
group_id=group_id,
name=name,
)
)
item_ids.add(str(category.id))
self.items.append(category)
return g1_item_ids, g2_item_ids
def get_all(self, token: str) -> Response:
return self.client.get(routes.RoutesTags.base, headers=token)
def cleanup(self) -> None:
for item in self.items:
self.database.tags.delete(item.id)

View file

@ -0,0 +1,53 @@
from typing import Tuple
from requests import Response
from mealie.schema.recipe.recipe import RecipeTool
from mealie.schema.recipe.recipe_tool import RecipeToolSave
from tests import utils
from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase
from tests.utils import routes
class ToolsTestCase(ABCMultiTenantTestCase):
items: list[RecipeTool]
def seed_action(self, group_id: str) -> set[int]:
tool_ids: set[int] = set()
for _ in range(10):
tool = self.database.tools.create(
RecipeToolSave(
group_id=group_id,
name=utils.random_string(10),
)
)
tool_ids.add(str(tool.id))
self.items.append(tool)
return tool_ids
def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[int], set[int]]:
g1_item_ids = set()
g2_item_ids = set()
for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]:
for _ in range(10):
name = utils.random_string(10)
tool = self.database.tools.create(
RecipeToolSave(
group_id=group_id,
name=name,
)
)
item_ids.add(str(tool.id))
self.items.append(tool)
return g1_item_ids, g2_item_ids
def get_all(self, token: str) -> Response:
return self.client.get(routes.RoutesTools.base, headers=token)
def cleanup(self) -> None:
for item in self.items:
self.database.tools.delete(item.id)

View file

@ -0,0 +1,52 @@
from typing import Tuple
from requests import Response
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, SaveIngredientUnit
from tests import utils
from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase
from tests.utils import routes
class UnitsTestCase(ABCMultiTenantTestCase):
items: list[IngredientUnit]
def seed_action(self, group_id: str) -> set[int]:
unit_ids: set[int] = set()
for _ in range(10):
unit = self.database.ingredient_units.create(
SaveIngredientUnit(
group_id=group_id,
name=utils.random_string(10),
)
)
unit_ids.add(str(unit.id))
self.items.append(unit)
return unit_ids
def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[str], set[str]]:
g1_item_ids = set()
g2_item_ids = set()
for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]:
for _ in range(10):
name = utils.random_string(10)
food = self.database.ingredient_units.create(
SaveIngredientUnit(
group_id=group_id,
name=name,
)
)
item_ids.add(str(food.id))
self.items.append(food)
return g1_item_ids, g2_item_ids
def get_all(self, token: str) -> Response:
return self.client.get(routes.RoutesUnits.base, headers=token)
def cleanup(self) -> None:
for item in self.items:
self.database.ingredient_units.delete(item.id)

View file

@ -1,79 +0,0 @@
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood, SaveIngredientUnit
from tests import utils
from tests.fixtures.fixture_multitenant import MultiTenant
from tests.utils import routes
def test_foods_are_private_by_group(
api_client: TestClient, multitenants: MultiTenant, database: AllRepositories
) -> None:
user1 = multitenants.user_one
user2 = multitenants.user_two
# Bootstrap foods for user1
food_ids: set[int] = set()
for _ in range(10):
food = database.ingredient_foods.create(
SaveIngredientFood(
group_id=user1.group_id,
name=utils.random_string(10),
)
)
food_ids.add(food.id)
expected_results = [
(user1.token, food_ids),
(user2.token, []),
]
for token, expected_food_ids in expected_results:
response = api_client.get(routes.RoutesFoods.base, headers=token)
assert response.status_code == 200
data = response.json()
assert len(data) == len(expected_food_ids)
if len(data) > 0:
for food in data:
assert food["id"] in expected_food_ids
def test_units_are_private_by_group(
api_client: TestClient, multitenants: MultiTenant, database: AllRepositories
) -> None:
user1 = multitenants.user_one
user2 = multitenants.user_two
# Bootstrap foods for user1
unit_ids: set[int] = set()
for _ in range(10):
food = database.ingredient_units.create(
SaveIngredientUnit(
group_id=user1.group_id,
name=utils.random_string(10),
)
)
unit_ids.add(food.id)
expected_results = [
(user1.token, unit_ids),
(user2.token, []),
]
for token, expected_unit_ids in expected_results:
response = api_client.get(routes.RoutesUnits.base, headers=token)
assert response.status_code == 200
data = response.json()
assert len(data) == len(expected_unit_ids)
if len(data) > 0:
for food in data:
assert food["id"] in expected_unit_ids

View file

@ -0,0 +1,95 @@
from typing import Type
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from tests.fixtures.fixture_multitenant import MultiTenant
from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase
from tests.multitenant_tests.case_categories import CategoryTestCase
from tests.multitenant_tests.case_foods import FoodsTestCase
from tests.multitenant_tests.case_tags import TagsTestCase
from tests.multitenant_tests.case_tools import ToolsTestCase
from tests.multitenant_tests.case_units import UnitsTestCase
all_cases = [
UnitsTestCase,
FoodsTestCase,
ToolsTestCase,
TagsTestCase,
CategoryTestCase,
]
@pytest.mark.parametrize("test_case", all_cases)
def test_multitenant_cases_get_all(
api_client: TestClient,
multitenants: MultiTenant,
database: AllRepositories,
test_case: Type[ABCMultiTenantTestCase],
):
"""
This test will run all the multitenant test cases and validate that they return only the data for their group.
When requesting all resources.
"""
user1 = multitenants.user_one
user2 = multitenants.user_two
test_case = test_case(database, api_client)
with test_case:
expected_ids = test_case.seed_action(user1.group_id)
expected_results = [
(user1.token, expected_ids),
(user2.token, []),
]
for token, item_ids in expected_results:
response = test_case.get_all(token)
assert response.status_code == 200
data = response.json()
assert len(data) == len(item_ids)
if len(data) > 0:
for item in data:
assert item["id"] in item_ids
@pytest.mark.parametrize("test_case", all_cases)
def test_multitenant_cases_same_named_resources(
api_client: TestClient,
multitenants: MultiTenant,
database: AllRepositories,
test_case: Type[ABCMultiTenantTestCase],
):
"""
This test is used to ensure that the same resource can be created with the same values in different tenants.
i.e. the same category can exist in multiple groups. This is important to validate that the compound unique constraints
are operating in SQLAlchemy correctly.
"""
user1 = multitenants.user_one
user2 = multitenants.user_two
test_case = test_case(database, api_client)
with test_case:
expected_ids, expected_ids2 = test_case.seed_multi(user1.group_id, user2.group_id)
expected_results = [
(user1.token, expected_ids),
(user2.token, expected_ids2),
]
for token, item_ids in expected_results:
response = test_case.get_all(token)
assert response.status_code == 200
data = response.json()
assert len(data) == len(item_ids)
if len(data) > 0:
for item in data:
assert item["id"] in item_ids

View file

@ -0,0 +1,9 @@
from mealie.repos.repository_factory import AllRepositories
from tests.fixtures.fixture_multitenant import MultiTenant
def test_multitenant_recipe_data_storage(
multitenants: MultiTenant,
database: AllRepositories,
):
pass

View file

@ -1,6 +1,7 @@
from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.schema.recipe.recipe import Recipe, RecipeCategory
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_category import CategorySave
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
@ -10,9 +11,9 @@ def test_recipe_repo_get_by_categories_basic(database: AllRepositories, unique_u
slug1, slug2, slug3 = [random_string(10) for _ in range(3)]
categories = [
RecipeCategory(name=slug1, slug=slug1),
RecipeCategory(name=slug2, slug=slug2),
RecipeCategory(name=slug3, slug=slug3),
CategorySave(group_id=unique_user.group_id, name=slug1, slug=slug1),
CategorySave(group_id=unique_user.group_id, name=slug2, slug=slug2),
CategorySave(group_id=unique_user.group_id, name=slug3, slug=slug3),
]
created_categories = []
@ -67,8 +68,8 @@ def test_recipe_repo_get_by_categories_multi(database: AllRepositories, unique_u
slug1, slug2 = [random_string(10) for _ in range(2)]
categories = [
RecipeCategory(name=slug1, slug=slug1),
RecipeCategory(name=slug2, slug=slug2),
CategorySave(group_id=unique_user.group_id, name=slug1, slug=slug1),
CategorySave(group_id=unique_user.group_id, name=slug2, slug=slug2),
]
created_categories = []

View file

@ -1,6 +1,6 @@
import pytest
from mealie.utils.fs_stats import pretty_size
from mealie.pkgs.stats.fs_stats import pretty_size
@pytest.mark.parametrize(

View file

@ -1,4 +1,5 @@
from datetime import date
from uuid import uuid4
import pytest
@ -13,9 +14,10 @@ def test_create_plan_with_title():
def test_create_plan_with_slug():
entry = CreatePlanEntry(date=date.today(), recipe_id=123)
uuid = uuid4()
entry = CreatePlanEntry(date=date.today(), recipe_id=uuid)
assert entry.recipe_id == 123
assert entry.recipe_id == uuid
assert entry.title == ""

View file

@ -2,4 +2,5 @@ from .app_routes import *
from .assertion_helpers import *
from .factories import *
from .fixture_schemas import *
from .jsonify import *
from .user_login import *

6
tests/utils/jsonify.py Normal file
View file

@ -0,0 +1,6 @@
from fastapi.encoders import jsonable_encoder
def jsonify(data):
return jsonable_encoder(data)

View file

@ -1,7 +1,7 @@
from pydantic import UUID4
class _RoutesBase:
class RoutesBase:
prefix = "/api"
base = f"{prefix}/"
@ -13,9 +13,31 @@ class _RoutesBase:
return f"{cls.base}/{item_id}"
class RoutesFoods(_RoutesBase):
class RoutesFoods(RoutesBase):
base = "/api/foods"
class RoutesUnits(_RoutesBase):
class RoutesUnits(RoutesBase):
base = "/api/units"
class RoutesOrganizerBase(RoutesBase):
@classmethod
def slug(cls, slug: str) -> str:
return f"{cls.base}/slug/{slug}"
class RoutesTools(RoutesOrganizerBase):
base = "/api/organizers/tools"
class RoutesTags(RoutesOrganizerBase):
base = "/api/organizers/tags"
class RoutesCategory(RoutesOrganizerBase):
base = "/api/organizers/categories"
class RoutesRecipe(RoutesBase):
base = "/api/recipes"