Repository: mealie-recipes/mealie
Pull request: feature: proper multi-tenant-support
Isolate the following tables to be filtered by group_id (possibly more)

## Accidentally committed to `mealie-next`
- [x] Foods
- [x] Units

## In this PR
- [x] Categories - General
- [x] Tags - General
- [x] Tools - General
- [x]  rewrite category/tag relation logic to be less cumbersome.
- [x] ensure all items has composite unique keys
- [x] test unique composite keys
- [x] Ensure recipe name collisions don't effect file storage
    - [x] Initial directory settings
    - [x] Ensure migrations from Mealie alpha still work
    - [x] Update URLs to use ID's on Frontend instead of slugs.
    - [ ] 
 Update CRUD actions to use ID's instead of slugs (excluding get) 
 - Things are working now, putting this off for another time.
    - [x] Update image and asset CRUD
    - [x] Update image and asset CRUD (if necessary)
- [x] Test Slug Routes
    - [x] Categories
    - [x] Tags
    - [x] Tools
- [x] Switch to UUID's for these items for easier export/import functionality
    - [x] Categories
    - [x] Tags
    - [x] Tools
    - [x] Units
    - [x] Food
- [x] Validate frontend
    - [x] Category CRUD 
    - [x] Tag CRUD
    - [x] Tools CRUD 

Problem statement:
[v1.0.0b] [Task] - Proper Multi-Tenant Support
### What is the problem this task addresses?

Currently several items are shared across groups. All items should be group independent and NOT shared.

### Proposed/Possible Solution(s)?

Isolate the following tables to be filtered by group_id (possibly more)

- [x] Foods
- [x] Units
- [x] Categories
- [x] Tags
- [x] Tools

### Add - Ons

- [x] Use UUIDs for group related items for easier import/export
    - [x] Cookbooks
    - [x] Mealplans
    - [x] Preferences
    - [x] Reports
    - [x] Shopping Lists
    - [x] Webhooks

Hints from issue discussion:

Commit message: initial refactor for multitenant tags/cats

Patch to review:
diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py
index d2fc83975ad..2cf9669933f 100644
--- a/mealie/db/models/group/group.py
+++ b/mealie/db/models/group/group.py
@@ -58,6 +58,9 @@ class Group(SqlAlchemyBase, BaseMixins):
     # Owned Models
     ingredient_units = orm.relationship("IngredientUnitModel", **common_args)
     ingredient_foods = orm.relationship("IngredientFoodModel", **common_args)
+    tools = orm.relationship("Tool", **common_args)
+    tags = orm.relationship("Tag", **common_args)
+    categories = orm.relationship("Category", **common_args)
 
     class Config:
         exclude = {
diff --git a/mealie/db/models/recipe/category.py b/mealie/db/models/recipe/category.py
index 84dec5b90e3..bb67931198d 100644
--- a/mealie/db/models/recipe/category.py
+++ b/mealie/db/models/recipe/category.py
@@ -42,9 +42,15 @@
 
 class Category(SqlAlchemyBase, BaseMixins):
     __tablename__ = "categories"
+    __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),)
+
+    # ID Relationships
+    group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False)
+    group = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id])
+
     id = sa.Column(sa.Integer, primary_key=True)
     name = sa.Column(sa.String, index=True, nullable=False)
-    slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
+    slug = sa.Column(sa.String, index=True, nullable=False)
     recipes = orm.relationship("RecipeModel", secondary=recipes2categories, back_populates="recipe_category")
 
     class Config:
@@ -55,7 +61,8 @@ def validate_name(self, key, name):
         assert name != ""
         return name
 
-    def __init__(self, name, **_) -> None:
+    def __init__(self, name, group_id, **_) -> None:
+        self.group_id = group_id
         self.name = name.strip()
         self.slug = slugify(name)
 
diff --git a/mealie/db/models/recipe/tag.py b/mealie/db/models/recipe/tag.py
index b94f8491889..d4b41d62b72 100644
--- a/mealie/db/models/recipe/tag.py
+++ b/mealie/db/models/recipe/tag.py
@@ -26,9 +26,15 @@
 
 class Tag(SqlAlchemyBase, BaseMixins):
     __tablename__ = "tags"
+    __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),)
+
+    # ID Relationships
+    group_id = sa.Column(guid.GUID, sa.ForeignKey("groups.id"), nullable=False)
+    group = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id])
+
     id = sa.Column(sa.Integer, primary_key=True)
     name = sa.Column(sa.String, index=True, nullable=False)
-    slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
+    slug = sa.Column(sa.String, index=True, nullable=False)
     recipes = orm.relationship("RecipeModel", secondary=recipes2tags, back_populates="tags")
 
     class Config:
@@ -39,7 +45,8 @@ def validate_name(self, key, name):
         assert name != ""
         return name
 
-    def __init__(self, name, **_) -> None:
+    def __init__(self, name, group_id, **_) -> None:
+        self.group_id = group_id
         self.name = name.strip()
         self.slug = slugify(self.name)
 
diff --git a/mealie/db/models/recipe/tool.py b/mealie/db/models/recipe/tool.py
index eb243b95b62..1e0d3faede4 100644
--- a/mealie/db/models/recipe/tool.py
+++ b/mealie/db/models/recipe/tool.py
@@ -3,6 +3,7 @@
 
 from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
 from mealie.db.models._model_utils import auto_init
+from mealie.db.models._model_utils.guid import GUID
 
 recipes_to_tools = Table(
     "recipes_to_tools",
@@ -14,6 +15,11 @@
 
 class Tool(SqlAlchemyBase, BaseMixins):
     __tablename__ = "tools"
+
+    # ID Relationships
+    group_id = Column(GUID, ForeignKey("groups.id"), nullable=False)
+    group = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id])
+
     name = Column(String, index=True, unique=True, nullable=False)
     slug = Column(String, index=True, unique=True, nullable=False)
     on_hand = Column(Boolean, default=False)
diff --git a/mealie/routes/categories/categories.py b/mealie/routes/categories/categories.py
index 3d33d2a9dbb..d1f396bc787 100644
--- a/mealie/routes/categories/categories.py
+++ b/mealie/routes/categories/categories.py
@@ -5,8 +5,9 @@
 
 from mealie.routes._base import BaseUserController, controller
 from mealie.routes._base.mixins import CrudMixins
+from mealie.schema import mapper
 from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse
-from mealie.schema.recipe.recipe_category import CategoryBase
+from mealie.schema.recipe.recipe_category import CategoryBase, CategorySave
 
 router = APIRouter(prefix="/categories", tags=["Categories: CRUD"])
 
@@ -24,32 +25,37 @@ class Config:
 class RecipeCategoryController(BaseUserController):
     # =========================================================================
     # CRUD Operations
+    @cached_property
+    def repo(self):
+        return self.repos.categories.by_group(self.group_id)
 
     @cached_property
     def mixins(self):
-        return CrudMixins(self.repos.categories, self.deps.logger)
+        return CrudMixins(self.repo, self.deps.logger)
 
     @router.get("", response_model=list[CategorySummary])
     def get_all(self):
         """Returns a list of available categories in the database"""
-        return self.repos.categories.get_all_limit_columns(fields=["slug", "name"])
+        return self.repo.get_all()
 
     @router.post("", status_code=201)
     def create_one(self, category: CategoryIn):
         """Creates a Category in the database"""
-        return self.mixins.create_one(category)
+        save_data = mapper.cast(category, CategorySave, group_id=self.group_id)
+        return self.mixins.create_one(save_data)
 
     @router.get("/{slug}", response_model=RecipeCategoryResponse)
     def get_all_recipes_by_category(self, slug: str):
         """Returns a list of recipes associated with the provided category."""
-        category_obj = self.repos.categories.get(slug)
+        category_obj = self.repo.get(slug)
         category_obj = RecipeCategoryResponse.from_orm(category_obj)
         return category_obj
 
     @router.put("/{slug}", response_model=RecipeCategoryResponse)
     def update_one(self, slug: str, update_data: CategoryIn):
         """Updates an existing Tag in the database"""
-        return self.mixins.update_one(update_data, slug)
+        save_data = mapper.cast(update_data, CategorySave, group_id=self.group_id)
+        return self.mixins.update_one(save_data, slug)
 
     @router.delete("/{slug}")
     def delete_one(self, slug: str):
diff --git a/mealie/routes/tags/__init__.py b/mealie/routes/tags/__init__.py
index b405931b9a6..08ee8c20337 100644
--- a/mealie/routes/tags/__init__.py
+++ b/mealie/routes/tags/__init__.py
@@ -3,7 +3,9 @@
 from fastapi import APIRouter, HTTPException, status
 
 from mealie.routes._base import BaseUserController, controller
+from mealie.schema import mapper
 from mealie.schema.recipe import RecipeTagResponse, TagIn
+from mealie.schema.recipe.recipe_category import TagSave
 
 router = APIRouter(prefix="/tags", tags=["Tags: CRUD"])
 
@@ -12,12 +14,12 @@
 class TagController(BaseUserController):
     @cached_property
     def repo(self):
-        return self.repos.tags
+        return self.repos.tags.by_group(self.group_id)
 
     @router.get("")
-    async def get_all_recipe_tags(self):
+    async def get_all(self):
         """Returns a list of available tags in the database"""
-        return self.repo.get_all_limit_columns(["slug", "name"])
+        return self.repo.get_all()
 
     @router.get("/empty")
     def get_empty_tags(self):
@@ -32,12 +34,14 @@ def get_all_recipes_by_tag(self, tag_slug: str):
     @router.post("", status_code=201)
     def create_recipe_tag(self, tag: TagIn):
         """Creates a Tag in the database"""
-        return self.repo.create(tag)
+        save_data = mapper.cast(tag, TagSave, group_id=self.group_id)
+        return self.repo.create(save_data)
 
     @router.put("/{tag_slug}", response_model=RecipeTagResponse)
     def update_recipe_tag(self, tag_slug: str, new_tag: TagIn):
         """Updates an existing Tag in the database"""
-        return self.repo.update(tag_slug, new_tag)
+        save_data = mapper.cast(new_tag, TagSave, group_id=self.group_id)
+        return self.repo.update(tag_slug, save_data)
 
     @router.delete("/{tag_slug}")
     def delete_recipe_tag(self, tag_slug: str):
diff --git a/mealie/routes/tools/__init__.py b/mealie/routes/tools/__init__.py
index 212669206e5..4cf3d8e1bb2 100644
--- a/mealie/routes/tools/__init__.py
+++ b/mealie/routes/tools/__init__.py
@@ -5,9 +5,10 @@
 from mealie.routes._base.abc_controller import BaseUserController
 from mealie.routes._base.controller import controller
 from mealie.routes._base.mixins import CrudMixins
+from mealie.schema import mapper
 from mealie.schema.query import GetAll
 from mealie.schema.recipe.recipe import RecipeTool
-from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse
+from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse, RecipeToolSave
 
 router = APIRouter(prefix="/tools", tags=["Recipes: Tools"])
 
@@ -16,7 +17,7 @@
 class RecipeToolController(BaseUserController):
     @cached_property
     def repo(self):
-        return self.repos.tools
+        return self.repos.tools.by_group(self.group_id)
 
     @property
     def mixins(self) -> CrudMixins:
@@ -28,7 +29,8 @@ def get_all(self, q: GetAll = Depends(GetAll)):
 
     @router.post("", response_model=RecipeTool, status_code=201)
     def create_one(self, data: RecipeToolCreate):
-        return self.mixins.create_one(data)
+        save_data = mapper.cast(data, RecipeToolSave, group_id=self.group_id)
+        return self.mixins.create_one(save_data)
 
     @router.get("/{item_id}", response_model=RecipeTool)
     def get_one(self, item_id: int):
diff --git a/mealie/schema/recipe/recipe_category.py b/mealie/schema/recipe/recipe_category.py
index 1e239c44e23..b0e915fd5df 100644
--- a/mealie/schema/recipe/recipe_category.py
+++ b/mealie/schema/recipe/recipe_category.py
@@ -1,4 +1,5 @@
 from fastapi_camelcase import CamelModel
+from pydantic import UUID4
 from pydantic.utils import GetterDict
 
 
@@ -6,6 +7,10 @@ class CategoryIn(CamelModel):
     name: str
 
 
+class CategorySave(CategoryIn):
+    group_id: UUID4
+
+
 class CategoryBase(CategoryIn):
     id: int
     slug: str
@@ -20,6 +25,14 @@ def getter_dict(_cls, name_orm):
             }
 
 
+class CategoryOut(CategoryBase):
+    id: int
+    slug: str
+
+    class Config:
+        orm_mode = True
+
+
 class RecipeCategoryResponse(CategoryBase):
     recipes: "list[Recipe]" = []
 
@@ -32,10 +45,22 @@ class TagIn(CategoryIn):
     pass
 
 
+class TagSave(TagIn):
+    group_id: UUID4
+
+
 class TagBase(CategoryBase):
     pass
 
 
+class TagOut(TagSave):
+    id: int
+    slug: str
+
+    class Config:
+        orm_mode = True
+
+
 class RecipeTagResponse(RecipeCategoryResponse):
     pass
 
diff --git a/mealie/schema/recipe/recipe_tool.py b/mealie/schema/recipe/recipe_tool.py
index 41cd486baab..ce5963a2acb 100644
--- a/mealie/schema/recipe/recipe_tool.py
+++ b/mealie/schema/recipe/recipe_tool.py
@@ -1,6 +1,7 @@
 from typing import List
 
 from fastapi_camelcase import CamelModel
+from pydantic import UUID4
 
 
 class RecipeToolCreate(CamelModel):
@@ -8,6 +9,10 @@ class RecipeToolCreate(CamelModel):
     on_hand: bool = False
 
 
+class RecipeToolSave(RecipeToolCreate):
+    group_id: UUID4
+
+
 class RecipeTool(RecipeToolCreate):
     id: int
     slug: str
diff --git a/mealie/services/migrations/_migration_base.py b/mealie/services/migrations/_migration_base.py
index 86e418bcc5a..3f6ddc83ac5 100644
--- a/mealie/services/migrations/_migration_base.py
+++ b/mealie/services/migrations/_migration_base.py
@@ -29,6 +29,8 @@ class BaseMigrator(BaseService):
     report_id: int
     report: ReportOut
 
+    helpers: DatabaseMigrationHelpers
+
     def __init__(
         self, archive: Path, db: AllRepositories, session, user_id: UUID4, group_id: UUID, add_migration_tag: bool
     ):
@@ -114,6 +116,12 @@ def import_recipes_to_database(self, validated_recipes: list[Recipe]) -> list[Tu
             recipe.user_id = self.user_id
             recipe.group_id = self.group_id
 
+            if recipe.tags:
+                recipe.tags = self.helpers.get_or_set_tags(x.name for x in recipe.tags)
+
+            if recipe.recipe_category:
+                recipe.recipe_category = self.helpers.get_or_set_category(x.name for x in recipe.recipe_category)
+
             if self.add_migration_tag:
                 recipe.tags.append(migration_tag)
 
@@ -181,16 +189,6 @@ def clean_recipe_dictionary(self, recipe_dict: dict) -> Recipe:
         """
         recipe_dict = self.rewrite_alias(recipe_dict)
 
-        # Temporary hold out of recipe_dict
-        # temp_categories = recipe_dict["recipeCategory"]
-        # temp_tools = recipe_dict["tools"]
-        # temp_tasg = recipe_dict["tags"]
-
         recipe_dict = cleaner.clean(recipe_dict, url=recipe_dict.get("org_url", None))
 
-        # Reassign after cleaning
-        # recipe_dict["recipeCategory"] = temp_categories
-        # recipe_dict["tools"] = temp_tools
-        # recipe_dict["tags"] = temp_tasg
-
         return Recipe(**recipe_dict)
diff --git a/mealie/services/migrations/utils/database_helpers.py b/mealie/services/migrations/utils/database_helpers.py
index e2a9b650506..88368891297 100644
--- a/mealie/services/migrations/utils/database_helpers.py
+++ b/mealie/services/migrations/utils/database_helpers.py
@@ -8,6 +8,7 @@
 from mealie.repos.repository_factory import RepositoryGeneric
 from mealie.schema.recipe import RecipeCategory
 from mealie.schema.recipe.recipe import RecipeTag
+from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagOut, TagSave
 
 T = TypeVar("T", bound=BaseModel)
 
@@ -19,7 +20,9 @@ def __init__(self, db: AllRepositories, session: Session, group_id: int, user_id
         self.session = session
         self.db = db
 
-    def _get_or_set_generic(self, accessor: RepositoryGeneric, items: list[str], out_model: T) -> list[T]:
+    def _get_or_set_generic(
+        self, accessor: RepositoryGeneric, items: list[str], create_model: T, out_model: T
+    ) -> list[T]:
         """
         Utility model for getting or setting categories or tags. This will only work for those two cases.
 
@@ -30,22 +33,32 @@ def _get_or_set_generic(self, accessor: RepositoryGeneric, items: list[str], out
         for item_name in items:
             slug_lookup = slugify(item_name)
 
-            item_model = accessor.get_one(slug_lookup, "slug", override_schema=out_model)
+            item_model = accessor.get_one(value=slug_lookup, key="slug", override_schema=out_model)
 
             if not item_model:
                 item_model = accessor.create(
-                    out_model(
+                    create_model(
+                        group_id=self.group_id,
                         name=item_name,
                         slug=slug_lookup,
                     )
                 )
 
             items_out.append(item_model.dict())
-
         return items_out
 
     def get_or_set_category(self, categories: list[str]) -> list[RecipeCategory]:
-        return self._get_or_set_generic(self.db.categories, categories, RecipeCategory)
+        return self._get_or_set_generic(
+            self.db.categories.by_group(self.group_id),
+            categories,
+            CategorySave,
+            CategoryOut,
+        )
 
     def get_or_set_tags(self, tags: list[str]) -> list[RecipeTag]:
-        return self._get_or_set_generic(self.db.tags, tags, RecipeTag)
+        return self._get_or_set_generic(
+            self.db.tags.by_group(self.group_id),
+            tags,
+            TagSave,
+            TagOut,
+        )
diff --git a/mealie/services/migrations/utils/migration_helpers.py b/mealie/services/migrations/utils/migration_helpers.py
index 526a796f55f..87891ce83a9 100644
--- a/mealie/services/migrations/utils/migration_helpers.py
+++ b/mealie/services/migrations/utils/migration_helpers.py
@@ -26,8 +26,7 @@ def yaml(yaml_file: Path) -> dict:
         with open(yaml_file, "r") as f:
             contents = f.read().split("---")
             recipe_data = {}
-            for _, document in enumerate(contents):
-
+            for document in contents:
                 # Check if None or Empty String
                 if document is None or document == "":
                     continue
diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py b/tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py
deleted file mode 100644
index bb20aed8af5..00000000000
--- a/tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py
+++ /dev/null
@@ -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"])
diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py b/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py
index 74cda06ff8e..2108e861e7c 100644
--- a/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py
+++ b/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py
@@ -7,6 +7,7 @@
 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 @@ def item(item_id: UUID4) -> str:
 
 
 @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
 
diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py b/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py
index 25cc15fe006..6dd1e09184d 100644
--- a/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py
+++ b/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py
@@ -7,7 +7,7 @@
 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.utils.factories import random_string
 from tests.utils.fixture_schemas import TestUser
 
@@ -53,7 +53,7 @@ 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}
@@ -79,7 +79,7 @@ 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}
diff --git a/tests/multitenant_tests/case_abc.py b/tests/multitenant_tests/case_abc.py
new file mode 100644
index 00000000000..84ec6ebaf6f
--- /dev/null
+++ b/tests/multitenant_tests/case_abc.py
@@ -0,0 +1,20 @@
+from abc import ABC, abstractmethod
+
+from fastapi import Response
+from fastapi.testclient import TestClient
+
+from mealie.repos.repository_factory import AllRepositories
+
+
+class ABCMultiTenanatTestCase(ABC):
+    def __init__(self, database: AllRepositories, client: TestClient) -> None:
+        self.database = database
+        self.client = client
+
+    @abstractmethod
+    def seed_action(repos: AllRepositories, group_id: str) -> set[int] | set[str]:
+        ...
+
+    @abstractmethod
+    def get_all(token: str) -> Response:
+        ...
diff --git a/tests/multitenant_tests/case_categories.py b/tests/multitenant_tests/case_categories.py
new file mode 100644
index 00000000000..ddb201a3f77
--- /dev/null
+++ b/tests/multitenant_tests/case_categories.py
@@ -0,0 +1,25 @@
+from requests import Response
+
+from mealie.schema.recipe.recipe_category import CategorySave
+from tests import utils
+from tests.multitenant_tests.case_abc import ABCMultiTenanatTestCase
+from tests.utils import routes
+
+
+class CategoryTestCase(ABCMultiTenanatTestCase):
+    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),
+                )
+            )
+
+            category_ids.add(category.id)
+
+        return category_ids
+
+    def get_all(self, token: str) -> Response:
+        return self.client.get(routes.RoutesCategory.base, headers=token)
diff --git a/tests/multitenant_tests/case_foods.py b/tests/multitenant_tests/case_foods.py
new file mode 100644
index 00000000000..c3052d7f958
--- /dev/null
+++ b/tests/multitenant_tests/case_foods.py
@@ -0,0 +1,25 @@
+from requests import Response
+
+from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood
+from tests import utils
+from tests.multitenant_tests.case_abc import ABCMultiTenanatTestCase
+from tests.utils import routes
+
+
+class FoodsTestCase(ABCMultiTenanatTestCase):
+    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(food.id)
+
+        return food_ids
+
+    def get_all(self, token: str) -> Response:
+        return self.client.get(routes.RoutesFoods.base, headers=token)
diff --git a/tests/multitenant_tests/case_tags.py b/tests/multitenant_tests/case_tags.py
new file mode 100644
index 00000000000..360eab315c1
--- /dev/null
+++ b/tests/multitenant_tests/case_tags.py
@@ -0,0 +1,25 @@
+from requests import Response
+
+from mealie.schema.recipe.recipe_category import TagSave
+from tests import utils
+from tests.multitenant_tests.case_abc import ABCMultiTenanatTestCase
+from tests.utils import routes
+
+
+class TagsTestCase(ABCMultiTenanatTestCase):
+    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(tag.id)
+
+        return tag_ids
+
+    def get_all(self, token: str) -> Response:
+        return self.client.get(routes.RoutesTags.base, headers=token)
diff --git a/tests/multitenant_tests/case_tools.py b/tests/multitenant_tests/case_tools.py
new file mode 100644
index 00000000000..368d912b5bb
--- /dev/null
+++ b/tests/multitenant_tests/case_tools.py
@@ -0,0 +1,25 @@
+from requests import Response
+
+from mealie.schema.recipe.recipe_tool import RecipeToolSave
+from tests import utils
+from tests.multitenant_tests.case_abc import ABCMultiTenanatTestCase
+from tests.utils import routes
+
+
+class ToolsTestCase(ABCMultiTenanatTestCase):
+    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(tool.id)
+
+        return tool_ids
+
+    def get_all(self, token: str) -> Response:
+        return self.client.get(routes.RoutesTools.base, headers=token)
diff --git a/tests/multitenant_tests/case_units.py b/tests/multitenant_tests/case_units.py
new file mode 100644
index 00000000000..94c69accf6a
--- /dev/null
+++ b/tests/multitenant_tests/case_units.py
@@ -0,0 +1,25 @@
+from requests import Response
+
+from mealie.schema.recipe.recipe_ingredient import SaveIngredientUnit
+from tests import utils
+from tests.multitenant_tests.case_abc import ABCMultiTenanatTestCase
+from tests.utils import routes
+
+
+class UnitsTestCase(ABCMultiTenanatTestCase):
+    def seed_action(self, group_id: str) -> set[int]:
+        unit_ids: set[int] = set()
+        for _ in range(10):
+            food = self.database.ingredient_units.create(
+                SaveIngredientUnit(
+                    group_id=group_id,
+                    name=utils.random_string(10),
+                )
+            )
+
+            unit_ids.add(food.id)
+
+        return unit_ids
+
+    def get_all(self, token: str) -> Response:
+        return self.client.get(routes.RoutesUnits.base, headers=token)
diff --git a/tests/multitenant_tests/test_ingredient_food.py b/tests/multitenant_tests/test_ingredient_food.py
deleted file mode 100644
index 600477cf914..00000000000
--- a/tests/multitenant_tests/test_ingredient_food.py
+++ /dev/null
@@ -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
diff --git a/tests/multitenant_tests/test_multitenant_cases.py b/tests/multitenant_tests/test_multitenant_cases.py
new file mode 100644
index 00000000000..8dc252ba0b8
--- /dev/null
+++ b/tests/multitenant_tests/test_multitenant_cases.py
@@ -0,0 +1,53 @@
+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 ABCMultiTenanatTestCase
+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[ABCMultiTenanatTestCase],
+):
+    user1 = multitenants.user_one
+    user2 = multitenants.user_two
+
+    test_case = test_case(database, api_client)
+
+    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
diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py
index daae1958819..1434ccbc4b6 100644
--- a/tests/unit_tests/repository_tests/test_recipe_repository.py
+++ b/tests/unit_tests/repository_tests/test_recipe_repository.py
@@ -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 = []
diff --git a/tests/utils/routes.py b/tests/utils/routes.py
index e474b803ff0..1470ebe7b1d 100644
--- a/tests/utils/routes.py
+++ b/tests/utils/routes.py
@@ -19,3 +19,15 @@ class RoutesFoods(_RoutesBase):
 
 class RoutesUnits(_RoutesBase):
     base = "/api/units"
+
+
+class RoutesTools(_RoutesBase):
+    base = "/api/tools"
+
+
+class RoutesTags(_RoutesBase):
+    base = "/api/tags"
+
+
+class RoutesCategory(_RoutesBase):
+    base = "/api/categories"


Code review comments:
diff_hunk: @@ -0,0 +1,20 @@
+from abc import ABC, abstractmethod
+
+from fastapi import Response
+from fastapi.testclient import TestClient
+
+from mealie.repos.repository_factory import AllRepositories
+
+
+class ABCMultiTenanatTestCase(ABC):
path: tests/multitenant_tests/case_abc.py
text: @user1:
This should probably be `ABCMultiTenantTestCase`? 😅 

@author:
Ha! That would be better I think 👍