1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-06 05:55:23 +02:00

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
parent 9a82a172cb
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

@ -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