From 7dc6bbfe33be8facba9afd4b8ea0faf20ae460ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 00:38:56 +0000 Subject: [PATCH 1/2] Fix kitchen recipe list not showing uploaded thumbnails Recipes with uploaded thumbnails were correctly displayed on the detail page but not in the kitchen shared recipes list. The list endpoint was reading image_url directly from the recipe JSON blob, which only holds the original scraped image, while ignoring the user_recipe.thumbnail_key column where uploaded thumbnails are stored. Fix propagates thumbnail_key through KitchenRecipe and resolves it via StorageBackend in the route handlers for both list and share endpoints, matching the existing pattern used by the main /me/recipes list. https://claude.ai/code/session_01N4wWtQZGRSU79mLwfhKxEK --- .../database/kitchen_repositories.py | 3 + .../src/kitchen_mate/routes/kitchens.py | 8 +- apps/kitchen_mate/tests/test_kitchens.py | 192 ++++++++++++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 apps/kitchen_mate/tests/test_kitchens.py diff --git a/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py b/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py index d07d783..333af8f 100644 --- a/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py +++ b/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py @@ -69,6 +69,7 @@ class KitchenRecipe(BaseModel): shared_at: datetime title: str image_url: str | None + thumbnail_key: str | None tags: list[str] | None @@ -405,6 +406,7 @@ async def share_recipe_to_kitchen( shared_at=now, title=recipe_data.get("title", "Untitled"), image_url=recipe_data.get("image"), + thumbnail_key=user_recipe.thumbnail_key, tags=tags_data, ) @@ -457,6 +459,7 @@ async def get_kitchen_recipes( shared_at=kr.shared_at, title=recipe_data.get("title", "Untitled"), image_url=recipe_data.get("image"), + thumbnail_key=user_recipe.thumbnail_key, tags=tags_data, ) ) diff --git a/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py b/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py index 35554ca..673e648 100644 --- a/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py +++ b/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py @@ -8,6 +8,7 @@ from kitchen_mate.auth import User, get_user from kitchen_mate.config import Settings, get_settings +from kitchen_mate.storage import StorageBackend, get_storage from kitchen_mate.database.kitchen_repositories import ( add_or_invite_member, create_kitchen, @@ -215,6 +216,7 @@ async def share_recipe( kitchen_id: str, body: ShareToKitchenRequest, user: Annotated[User, Depends(get_user)], + storage: Annotated[StorageBackend, Depends(get_storage)], ) -> KitchenRecipeResponse: """Share a recipe with a kitchen (any member).""" await _require_member(kitchen_id, user) @@ -224,6 +226,7 @@ async def share_recipe( except ValueError as e: raise HTTPException(status_code=409, detail=str(e)) + image_url = storage.get_url(kr.thumbnail_key) if kr.thumbnail_key else kr.image_url return KitchenRecipeResponse( id=kr.id, kitchen_id=kr.kitchen_id, @@ -231,7 +234,7 @@ async def share_recipe( shared_by=kr.shared_by, shared_at=kr.shared_at.isoformat(), title=kr.title, - image_url=kr.image_url, + image_url=image_url, tags=kr.tags, ) @@ -244,6 +247,7 @@ async def share_recipe( async def list_kitchen_recipes( kitchen_id: str, user: Annotated[User, Depends(get_user)], + storage: Annotated[StorageBackend, Depends(get_storage)], cursor: str | None = None, limit: int = 50, ) -> ListKitchenRecipesResponse: @@ -266,7 +270,7 @@ async def list_kitchen_recipes( shared_by=r.shared_by, shared_at=r.shared_at.isoformat(), title=r.title, - image_url=r.image_url, + image_url=storage.get_url(r.thumbnail_key) if r.thumbnail_key else r.image_url, tags=r.tags, ) for r in recipes diff --git a/apps/kitchen_mate/tests/test_kitchens.py b/apps/kitchen_mate/tests/test_kitchens.py new file mode 100644 index 0000000..36e3ab4 --- /dev/null +++ b/apps/kitchen_mate/tests/test_kitchens.py @@ -0,0 +1,192 @@ +"""Tests for kitchen endpoints.""" + +from __future__ import annotations + +import asyncio +import tempfile +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Generator + +import pytest +from fastapi.testclient import TestClient +from jose import jwt + +from kitchen_mate.config import Settings, get_settings +from kitchen_mate.database import ( + close_database, + create_tables, + init_database, + save_user_recipe, + store_recipe, +) +from kitchen_mate.main import app +from kitchen_mate.schemas import Parser +from kitchen_mate.storage import StorageBackend, get_storage +from recipe_clipper.models import Ingredient, Recipe + +if TYPE_CHECKING: + pass + + +def create_test_jwt(user_id: str, email: str, secret: str) -> str: + payload = { + "sub": user_id, + "email": email, + "aud": "authenticated", + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + "iat": datetime.now(timezone.utc), + } + return jwt.encode(payload, secret, algorithm="HS256") + + +class FakeStorage: + def __init__(self) -> None: + self.uploaded: list[str] = [] + self.deleted: list[str] = [] + + async def upload(self, key: str, content: bytes, content_type: str) -> None: + self.uploaded.append(key) + + def get_url(self, key: str) -> str: + return f"https://storage.test/{key}" + + async def delete(self, key: str) -> None: + self.deleted.append(key) + + +@pytest.fixture +def kitchen_client() -> Generator[tuple[TestClient, str, FakeStorage], None, None]: + """Test client in multi-tenant mode with fake storage and a real database.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + + jwt_secret = "test-secret-key-at-least-32-characters-long" + test_settings = Settings( + cache_enabled=True, + cache_db_path=db_path, + supabase_jwt_secret=jwt_secret, + supabase_url=None, + ) + fake_storage = FakeStorage() + app.dependency_overrides[get_settings] = lambda: test_settings + app.dependency_overrides[get_storage] = lambda: fake_storage + + with TestClient(app) as test_client: + asyncio.run(init_database(db_path)) + asyncio.run(create_tables()) + yield test_client, jwt_secret, fake_storage + + app.dependency_overrides.clear() + asyncio.run(close_database()) + + +@pytest.fixture +def sample_recipe() -> Recipe: + return Recipe( + title="Test Pasta", + ingredients=[Ingredient(name="pasta", amount="200", unit="g")], + instructions=["Boil pasta"], + image="https://example.com/pasta-original.jpg", + ) + + +def test_kitchen_recipe_list_uses_thumbnail_when_present( + kitchen_client: tuple[TestClient, str, FakeStorage], + sample_recipe: Recipe, +) -> None: + """Kitchen recipe list should show the uploaded thumbnail URL, not the original recipe image.""" + client, jwt_secret, fake_storage = kitchen_client + user_id = "user-kitchen-thumb-test" + email = "kitchen-thumb@example.com" + token = create_test_jwt(user_id, email, jwt_secret) + cookies = {"access_token": token} + + # Register the user + client.get("/api/auth/me", cookies=cookies) + + cached = asyncio.run( + store_recipe( + "https://example.com/pasta", + sample_recipe, + "abc123hash", + Parser.recipe_scrapers, + ) + ) + thumbnail_key = f"users/{user_id}/recipes/some-id/thumbnail.jpg" + saved, _ = asyncio.run( + save_user_recipe( + user_id=user_id, + recipe_id=cached.id, + recipe_data=sample_recipe, + thumbnail_key=thumbnail_key, + ) + ) + + # Create a kitchen + response = client.post("/api/kitchens", json={"name": "Test Kitchen"}, cookies=cookies) + assert response.status_code == 201 + kitchen_id = response.json()["id"] + + # Share the recipe — the response should already resolve the thumbnail + response = client.post( + f"/api/kitchens/{kitchen_id}/recipes", + json={"user_recipe_id": saved.id}, + cookies=cookies, + ) + assert response.status_code == 201 + assert response.json()["image_url"] == fake_storage.get_url(thumbnail_key) + + # List kitchen recipes — this was the broken path before the fix + response = client.get(f"/api/kitchens/{kitchen_id}/recipes", cookies=cookies) + assert response.status_code == 200 + recipes = response.json()["recipes"] + assert len(recipes) == 1 + assert recipes[0]["image_url"] == fake_storage.get_url(thumbnail_key) + assert recipes[0]["image_url"] != str(sample_recipe.image) + + +def test_kitchen_recipe_list_falls_back_to_original_image( + kitchen_client: tuple[TestClient, str, FakeStorage], + sample_recipe: Recipe, +) -> None: + """Kitchen recipe list should fall back to original recipe image when no thumbnail is set.""" + client, jwt_secret, fake_storage = kitchen_client + user_id = "user-kitchen-noThumb-test" + email = "kitchen-nothumb@example.com" + token = create_test_jwt(user_id, email, jwt_secret) + cookies = {"access_token": token} + + client.get("/api/auth/me", cookies=cookies) + + cached = asyncio.run( + store_recipe( + "https://example.com/pasta", + sample_recipe, + "abc123hash", + Parser.recipe_scrapers, + ) + ) + saved, _ = asyncio.run( + save_user_recipe( + user_id=user_id, + recipe_id=cached.id, + recipe_data=sample_recipe, + ) + ) + + response = client.post("/api/kitchens", json={"name": "Test Kitchen 2"}, cookies=cookies) + assert response.status_code == 201 + kitchen_id = response.json()["id"] + + response = client.post( + f"/api/kitchens/{kitchen_id}/recipes", + json={"user_recipe_id": saved.id}, + cookies=cookies, + ) + assert response.status_code == 201 + + response = client.get(f"/api/kitchens/{kitchen_id}/recipes", cookies=cookies) + assert response.status_code == 200 + recipes = response.json()["recipes"] + assert len(recipes) == 1 + assert recipes[0]["image_url"] == str(sample_recipe.image) From dabd33d27b3c8be4ef557a65aee4b094bcf080e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 00:40:07 +0000 Subject: [PATCH 2/2] Fix unused import lint error in test_kitchens.py https://claude.ai/code/session_01N4wWtQZGRSU79mLwfhKxEK --- apps/kitchen_mate/tests/test_kitchens.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/kitchen_mate/tests/test_kitchens.py b/apps/kitchen_mate/tests/test_kitchens.py index 36e3ab4..20c5040 100644 --- a/apps/kitchen_mate/tests/test_kitchens.py +++ b/apps/kitchen_mate/tests/test_kitchens.py @@ -21,7 +21,7 @@ ) from kitchen_mate.main import app from kitchen_mate.schemas import Parser -from kitchen_mate.storage import StorageBackend, get_storage +from kitchen_mate.storage import get_storage from recipe_clipper.models import Ingredient, Recipe if TYPE_CHECKING: