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:
parent
9a82a172cb
commit
c617251f4c
157 changed files with 1866 additions and 1578 deletions
35
tests/multitenant_tests/case_abc.py
Normal file
35
tests/multitenant_tests/case_abc.py
Normal 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()
|
53
tests/multitenant_tests/case_categories.py
Normal file
53
tests/multitenant_tests/case_categories.py
Normal 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)
|
52
tests/multitenant_tests/case_foods.py
Normal file
52
tests/multitenant_tests/case_foods.py
Normal 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)
|
53
tests/multitenant_tests/case_tags.py
Normal file
53
tests/multitenant_tests/case_tags.py
Normal 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)
|
53
tests/multitenant_tests/case_tools.py
Normal file
53
tests/multitenant_tests/case_tools.py
Normal 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)
|
52
tests/multitenant_tests/case_units.py
Normal file
52
tests/multitenant_tests/case_units.py
Normal 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)
|
|
@ -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
|
95
tests/multitenant_tests/test_multitenant_cases.py
Normal file
95
tests/multitenant_tests/test_multitenant_cases.py
Normal 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
|
9
tests/multitenant_tests/test_recipe_data_storage.py
Normal file
9
tests/multitenant_tests/test_recipe_data_storage.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue