Compare commits

...

8 Commits

Author SHA1 Message Date
f11cca534f feat(recipe): compress hero image to 400px preview on save
Adds Thumbnailator-based ImageCompressor that resizes uploaded images
to a 400px-wide JPEG preview stored in hero_image_preview. The recipe
list uses the preview instead of the full image URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:14:35 +02:00
822b34cd14 feat(recipe-form): reject files > 5 MB and show Max. 5 MB hint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:11:57 +02:00
46f2ec45a3 feat(backend): limit multipart upload to 5 MB file / 6 MB request
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:09:14 +02:00
90cff0c4d2 feat(recipe): validate heroImageUrl content type before persisting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:08:45 +02:00
b1eb9ed964 feat(recipes): send null instead of undefined for blank serves/cookTimeMin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:06:39 +02:00
44b3f06474 feat(recipes): filter ingredients with quantity <= 0 before API submission
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:05:19 +02:00
dbc78a1883 test(recipe): cover null serves/cookTimeMin and capitalised effort rejection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:00:16 +02:00
30ba53099c refactor(recipes): drop is_child_friendly column and remove from all layers
V025 migration drops the column. Removed from Recipe entity, RecipeDetailResponse,
RecipeSummaryResponse, RecipeRepository JPQL, RecipeService, and RecipeController.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:56:57 +02:00
31 changed files with 430 additions and 74 deletions

View File

@@ -55,6 +55,16 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.21</version>
</dependency>
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-webp</artifactId>
<version>3.13.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -0,0 +1,60 @@
package com.recipeapp.recipe;
import net.coobird.thumbnailator.Thumbnails;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
@Component
public class ImageCompressor {
private static final Logger log = LoggerFactory.getLogger(ImageCompressor.class);
private static final int PREVIEW_WIDTH = 400;
private static final double PREVIEW_QUALITY = 0.6;
private static final String DATA_URI_PREFIX = "data:image/";
private static final String BASE64_MARKER = ";base64,";
private static final String OUTPUT_PREFIX = "data:image/jpeg;base64,";
public String compressToPreview(String dataUri) {
if (dataUri == null || dataUri.isBlank()) return null;
if (!dataUri.startsWith(DATA_URI_PREFIX)) return null;
int markerIdx = dataUri.indexOf(BASE64_MARKER);
if (markerIdx < 0) return null;
byte[] imageBytes;
try {
imageBytes = Base64.getDecoder().decode(dataUri.substring(markerIdx + BASE64_MARKER.length()));
} catch (IllegalArgumentException e) {
return null;
}
try {
BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes));
if (original == null) {
log.warn("ImageIO could not decode image — unsupported format (data URI prefix: {})",
dataUri.substring(0, Math.min(dataUri.indexOf(',') + 1, 40)));
return null;
}
int targetWidth = Math.min(original.getWidth(), PREVIEW_WIDTH);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Thumbnails.of(original)
.width(targetWidth)
.outputFormat("jpeg")
.outputQuality(PREVIEW_QUALITY)
.toOutputStream(bos);
return OUTPUT_PREFIX + Base64.getEncoder().encodeToString(bos.toByteArray());
} catch (Exception e) {
log.warn("Failed to generate image preview", e);
return null;
}
}
}

View File

@@ -29,7 +29,6 @@ public class RecipeController {
Principal principal,
@RequestParam(required = false) String search,
@RequestParam(required = false) String effort,
@RequestParam(required = false) Boolean isChildFriendly,
@RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin,
@RequestParam(required = false) String sort,
@RequestParam(defaultValue = "20") int limit,
@@ -37,9 +36,9 @@ public class RecipeController {
UUID householdId = householdResolver.resolve(principal.getName());
List<RecipeSummaryResponse> recipes = recipeService.listRecipes(
householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset);
householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
long total = recipeService.countRecipes(
householdId, search, effort, isChildFriendly, cookTimeMaxMin);
householdId, search, effort, cookTimeMaxMin);
var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total);
var meta = new ApiResponse.Meta(pagination);

View File

@@ -18,13 +18,12 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
@Query("""
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl)
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.heroImagePreview)
FROM Recipe r
WHERE r.household.id = :householdId
AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
ORDER BY r.createdAt DESC
""")
@@ -32,7 +31,6 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
@Param("householdId") UUID householdId,
@Param("search") String search,
@Param("effort") String effort,
@Param("isChildFriendly") Boolean isChildFriendly,
@Param("cookTimeMaxMin") Integer cookTimeMaxMin,
@Param("sort") String sort,
@Param("limit") int limit,
@@ -45,13 +43,11 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
""")
long countFiltered(
@Param("householdId") UUID householdId,
@Param("search") String search,
@Param("effort") String effort,
@Param("isChildFriendly") Boolean isChildFriendly,
@Param("cookTimeMaxMin") Integer cookTimeMaxMin);
}

View File

@@ -2,6 +2,7 @@ package com.recipeapp.recipe;
import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.household.entity.Household;
import com.recipeapp.recipe.dto.*;
@@ -22,31 +23,31 @@ public class RecipeService {
private final TagRepository tagRepository;
private final IngredientCategoryRepository ingredientCategoryRepository;
private final HouseholdRepository householdRepository;
private final ImageCompressor imageCompressor;
public RecipeService(RecipeRepository recipeRepository,
IngredientRepository ingredientRepository,
TagRepository tagRepository,
IngredientCategoryRepository ingredientCategoryRepository,
HouseholdRepository householdRepository) {
HouseholdRepository householdRepository,
ImageCompressor imageCompressor) {
this.recipeRepository = recipeRepository;
this.ingredientRepository = ingredientRepository;
this.tagRepository = tagRepository;
this.ingredientCategoryRepository = ingredientCategoryRepository;
this.householdRepository = householdRepository;
this.imageCompressor = imageCompressor;
}
@Transactional(readOnly = true)
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin,
String sort, int limit, int offset) {
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
cookTimeMaxMin, sort, limit, offset);
Integer cookTimeMaxMin, String sort, int limit, int offset) {
return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
}
@Transactional(readOnly = true)
public long countRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin) {
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
public long countRecipes(UUID householdId, String search, String effort, Integer cookTimeMaxMin) {
return recipeRepository.countFiltered(householdId, search, effort, cookTimeMaxMin);
}
@Transactional(readOnly = true)
@@ -60,11 +61,14 @@ public class RecipeService {
Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
validateHeroImageUrl(request.heroImageUrl());
Recipe recipe = new Recipe(household, request.name(),
request.serves() != null ? request.serves().shortValue() : 0,
request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0,
request.effort(), false);
request.effort());
recipe.setHeroImageUrl(request.heroImageUrl());
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
addIngredients(recipe, household, request.ingredients());
addSteps(recipe, request.steps());
@@ -79,11 +83,14 @@ public class RecipeService {
Recipe recipe = findRecipe(householdId, recipeId);
Household household = recipe.getHousehold();
validateHeroImageUrl(request.heroImageUrl());
recipe.setName(request.name());
recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0);
recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0);
recipe.setEffort(request.effort());
recipe.setHeroImageUrl(request.heroImageUrl());
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
recipe.getIngredients().clear();
recipe.getSteps().clear();
@@ -181,6 +188,18 @@ public class RecipeService {
return new IngredientCategoryResponse(category.getId(), category.getName());
}
// ── Image validation ──
private static final java.util.regex.Pattern ALLOWED_IMAGE_PATTERN =
java.util.regex.Pattern.compile("^data:image/(jpeg|jpg|png|gif|webp);base64,");
private void validateHeroImageUrl(String heroImageUrl) {
if (heroImageUrl == null || heroImageUrl.isBlank()) return;
if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).find()) {
throw new ValidationException("Ungültiger Bildtyp. Erlaubt sind: JPEG, PNG, GIF, WebP.");
}
}
// ── Private helpers ──
private Recipe findRecipe(UUID householdId, UUID recipeId) {
@@ -239,7 +258,7 @@ public class RecipeService {
return new RecipeDetailResponse(
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(),
recipe.getEffort(), recipe.getHeroImageUrl(),
ingredients, steps, tags);
}

View File

@@ -10,7 +10,6 @@ public record RecipeDetailResponse(
short serves,
short cookTimeMin,
String effort,
boolean isChildFriendly,
String heroImageUrl,
List<IngredientItem> ingredients,
List<StepItem> steps,

View File

@@ -8,6 +8,5 @@ public record RecipeSummaryResponse(
short serves,
short cookTimeMin,
String effort,
boolean isChildFriendly,
String heroImageUrl
String heroImagePreview
) {}

View File

@@ -33,12 +33,12 @@ public class Recipe {
@Column(nullable = false, length = 10)
private String effort;
@Column(name = "is_child_friendly", nullable = false)
private boolean isChildFriendly;
@Column(name = "hero_image_url", columnDefinition = "text")
private String heroImageUrl;
@Column(name = "hero_image_preview", columnDefinition = "text")
private String heroImagePreview;
@Column(name = "deleted_at")
private Instant deletedAt;
@@ -64,14 +64,12 @@ public class Recipe {
protected Recipe() {}
public Recipe(Household household, String name, short serves, short cookTimeMin,
String effort, boolean isChildFriendly) {
public Recipe(Household household, String name, short serves, short cookTimeMin, String effort) {
this.household = household;
this.name = name;
this.serves = serves;
this.cookTimeMin = cookTimeMin;
this.effort = effort;
this.isChildFriendly = isChildFriendly;
}
@PrePersist
@@ -95,10 +93,10 @@ public class Recipe {
public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; }
public String getEffort() { return effort; }
public void setEffort(String effort) { this.effort = effort; }
public boolean isChildFriendly() { return isChildFriendly; }
public void setChildFriendly(boolean childFriendly) { isChildFriendly = childFriendly; }
public String getHeroImageUrl() { return heroImageUrl; }
public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; }
public String getHeroImagePreview() { return heroImagePreview; }
public void setHeroImagePreview(String heroImagePreview) { this.heroImagePreview = heroImagePreview; }
public Instant getDeletedAt() { return deletedAt; }
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
public Instant getCreatedAt() { return createdAt; }

View File

@@ -19,5 +19,10 @@ spring:
enabled: true
locations: classpath:db/migration
servlet:
multipart:
max-file-size: 5MB
max-request-size: 6MB
server:
port: 8080

View File

@@ -0,0 +1 @@
ALTER TABLE recipe ADD COLUMN hero_image_preview text;

View File

@@ -0,0 +1 @@
ALTER TABLE recipe DROP COLUMN is_child_friendly;

View File

@@ -55,7 +55,7 @@ class PlanningServiceTest {
}
private Recipe testRecipe(Household household, String name) {
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
setId(r, Recipe.class, UUID.randomUUID());
return r;
}

View File

@@ -69,7 +69,7 @@ class SuggestionsTest {
}
private Recipe createRecipe(String name) {
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true);
var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
setId(r, Recipe.class, UUID.randomUUID());
return r;
}

View File

@@ -69,7 +69,7 @@ class VarietyScoreTest {
}
private Recipe createRecipe(String name) {
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true);
var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
setId(r, Recipe.class, UUID.randomUUID());
return r;
}

View File

@@ -0,0 +1,98 @@
package com.recipeapp.recipe;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import static org.assertj.core.api.Assertions.*;
class ImageCompressorTest {
private final ImageCompressor compressor = new ImageCompressor();
@Test
void compressToPreview_returnsJpegDataUri() throws Exception {
String dataUri = makePngDataUri(800, 600);
String result = compressor.compressToPreview(dataUri);
assertThat(result).startsWith("data:image/jpeg;base64,");
}
@Test
void compressToPreview_outputIsDecodableJpeg() throws Exception {
String dataUri = makePngDataUri(800, 600);
String result = compressor.compressToPreview(dataUri);
String base64 = result.substring("data:image/jpeg;base64,".length());
byte[] bytes = Base64.getDecoder().decode(base64);
BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes));
assertThat(img).isNotNull();
assertThat(img.getWidth()).isLessThanOrEqualTo(400);
}
@Test
void compressToPreview_preservesAspectRatio() throws Exception {
String dataUri = makePngDataUri(800, 400); // 2:1 ratio
String result = compressor.compressToPreview(dataUri);
String base64 = result.substring("data:image/jpeg;base64,".length());
BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
assertThat(img).isNotNull();
double ratio = (double) img.getWidth() / img.getHeight();
assertThat(ratio).isCloseTo(2.0, within(0.1));
}
@Test
void compressToPreview_doesNotUpscaleSmallImages() throws Exception {
String dataUri = makePngDataUri(200, 150); // smaller than 400px
String result = compressor.compressToPreview(dataUri);
String base64 = result.substring("data:image/jpeg;base64,".length());
BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
assertThat(img).isNotNull();
assertThat(img.getWidth()).isLessThanOrEqualTo(200);
}
@Test
void compressToPreview_returnsNullForNull() {
assertThat(compressor.compressToPreview(null)).isNull();
}
@Test
void compressToPreview_returnsNullForBlankString() {
assertThat(compressor.compressToPreview(" ")).isNull();
}
@Test
void compressToPreview_returnsNullForNonDataUri() {
assertThat(compressor.compressToPreview("https://example.com/image.jpg")).isNull();
}
@Test
void compressToPreview_returnsNullForInvalidBase64() {
assertThat(compressor.compressToPreview("data:image/jpeg;base64,!!!not-valid!!!")).isNull();
}
// ── helpers ──
private String makePngDataUri(int width, int height) throws Exception {
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
// draw gradient so PNG and JPEG both have non-trivial content
for (int x = 0; x < width; x++) {
g.setColor(new Color(x * 255 / width, (x * 128 / width + height / 2) % 256, 128));
g.drawLine(x, 0, x, height);
}
g.dispose();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageIO.write(img, "png", bos);
return "data:image/png;base64," + Base64.getEncoder().encodeToString(bos.toByteArray());
}
}

View File

@@ -47,13 +47,13 @@ class RecipeControllerTest {
@Test
void listRecipesShouldReturn200WithPagination() throws Exception {
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
(short) 4, (short) 45, "medium", true, null);
(short) 4, (short) 45, "medium", null);
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(),
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(),
isNull(), eq(20), eq(0)))
.thenReturn(List.of(summary));
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull()))
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull()))
.thenReturn(1L);
mockMvc.perform(get("/v1/recipes")
@@ -69,17 +69,16 @@ class RecipeControllerTest {
@Test
void listRecipesWithFiltersShouldPassParams() throws Exception {
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true),
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"),
eq(30), eq("-cookTimeMin"), eq(10), eq(5)))
.thenReturn(List.of());
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), eq(30)))
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(30)))
.thenReturn(0L);
mockMvc.perform(get("/v1/recipes")
.principal(() -> "sarah@example.com")
.param("search", "pasta")
.param("effort", "easy")
.param("isChildFriendly", "true")
.param("cookTimeMin.lte", "30")
.param("sort", "-cookTimeMin")
.param("limit", "10")
@@ -162,6 +161,19 @@ class RecipeControllerTest {
verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID);
}
@Test
void createRecipeWithCapitalisedEffortShouldReturn400() throws Exception {
var body = """
{"name":"Test","effort":"Easy","tagIds":["%s"],"ingredients":[{"quantity":1,"unit":"g","newIngredientName":"x","sortOrder":0}]}
""".formatted(UUID.randomUUID());
mockMvc.perform(post("/v1/recipes")
.principal(() -> "sarah@example.com")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
private RecipeCreateRequest sampleCreateRequest() {
var ingredientId = UUID.randomUUID();
return new RecipeCreateRequest(
@@ -175,7 +187,7 @@ class RecipeControllerTest {
private RecipeDetailResponse sampleDetail() {
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta");
return new RecipeDetailResponse(
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", null,
List.of(new RecipeDetailResponse.IngredientItem(
UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)),
List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")),

View File

@@ -27,6 +27,7 @@ class RecipeServiceTest {
@Mock private TagRepository tagRepository;
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
@Mock private HouseholdRepository householdRepository;
@Mock private ImageCompressor imageCompressor;
@InjectMocks private RecipeService recipeService;
@@ -43,7 +44,7 @@ class RecipeServiceTest {
}
private Recipe testRecipe(Household household) {
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true);
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium");
try {
var field = Recipe.class.getDeclaredField("id");
field.setAccessible(true);
@@ -525,6 +526,29 @@ class RecipeServiceTest {
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void createRecipeWithNullServesAndCookTimeShouldStoreZero() {
var household = testHousehold();
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> {
Recipe r = i.getArgument(0);
try {
var field = Recipe.class.getDeclaredField("id");
field.setAccessible(true);
field.set(r, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
return r;
});
var request = new RecipeCreateRequest("Soup", null, null, "easy", null,
List.of(), List.of(), List.of());
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
assertThat(result.serves()).isEqualTo((short) 0);
assertThat(result.cookTimeMin()).isEqualTo((short) 0);
}
// ── Tag/Category edge cases ──
@Test
@@ -547,6 +571,31 @@ class RecipeServiceTest {
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void createRecipeWithDisallowedImageTypeShouldThrowValidationException() {
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(testHousehold()));
var request = new RecipeCreateRequest(
"Test", null, null, "easy", "data:application/pdf;base64,abc",
List.of(), List.of(), List.of());
assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request))
.isInstanceOf(com.recipeapp.common.ValidationException.class);
}
@Test
void createRecipeWithAllowedImageTypeShouldNotThrow() {
var household = testHousehold();
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0));
var request = new RecipeCreateRequest(
"Test", null, null, "easy", "data:image/jpeg;base64,abc",
List.of(), List.of(), List.of());
assertThatNoException().isThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request));
}
@Test
void listTagsShouldReturnEmptyList() {
when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of());

View File

@@ -60,7 +60,7 @@ class ShoppingServiceTest {
}
private Recipe testRecipe(Household household, String name) {
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
setId(r, Recipe.class, UUID.randomUUID());
return r;
}

View File

@@ -552,7 +552,6 @@ export interface components {
/** Format: int32 */
cookTimeMin?: number;
effort: string;
isChildFriendly?: boolean;
heroImageUrl?: string;
ingredients: components["schemas"]["IngredientEntry"][];
steps?: components["schemas"]["StepEntry"][];
@@ -587,7 +586,6 @@ export interface components {
/** Format: int32 */
cookTimeMin?: number;
effort?: string;
isChildFriendly?: boolean;
heroImageUrl?: string;
ingredients?: components["schemas"]["IngredientItem"][];
steps?: components["schemas"]["StepItem"][];
@@ -934,8 +932,7 @@ export interface components {
/** Format: int32 */
cookTimeMin?: number;
effort?: string;
isChildFriendly?: boolean;
heroImageUrl?: string;
heroImagePreview?: string;
};
ApiResponseListAdminUserResponse: {
status?: string;

View File

@@ -3,30 +3,44 @@ import { render, screen } from '@testing-library/svelte';
import VarietyWarningCards from './VarietyWarningCards.svelte';
const warnings = [
{ title: 'Chicken zweimal diese Woche', explanation: 'Mo, Mi — erwäge einen Tausch.' },
{ title: 'Tomaten in 3 Gerichten', explanation: 'Mo, Di, Mi — sorge für Abwechslung.' }
{
title: 'Chicken zweimal diese Woche',
items: [
{ dayShort: 'Mo', recipeName: 'Chicken Tikka', slotId: 1 },
{ dayShort: 'Mi', recipeName: 'Chicken Curry', slotId: 3 }
]
},
{
title: 'Tomaten in 3 Gerichten',
items: [
{ dayShort: 'Mo', recipeName: 'Pasta Pomodoro', slotId: 1 },
{ dayShort: 'Di', recipeName: 'Tomatensuppe', slotId: 2 },
{ dayShort: 'Mi', recipeName: 'Pizza Margherita', slotId: 3 }
]
}
];
describe('VarietyWarningCards', () => {
it('renders one card per warning', () => {
render(VarietyWarningCards, { props: { warnings } });
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
const cards = screen.getAllByTestId('warning-card');
expect(cards.length).toBe(2);
});
it('renders warning titles', () => {
render(VarietyWarningCards, { props: { warnings } });
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
});
it('renders warning explanations', () => {
render(VarietyWarningCards, { props: { warnings } });
expect(screen.getByText(/erwäge einen Tausch/)).toBeTruthy();
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
expect(screen.getByText('Chicken Tikka')).toBeTruthy();
expect(screen.getByText('Chicken Curry')).toBeTruthy();
});
it('renders nothing when warnings is empty', () => {
render(VarietyWarningCards, { props: { warnings: [] } });
render(VarietyWarningCards, { props: { warnings: [], weekStart: '2026-04-07' } });
expect(screen.queryAllByTestId('warning-card').length).toBe(0);
});
});

View File

@@ -23,8 +23,8 @@
data-testid="image-area"
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
>
{#if recipe.heroImageUrl}
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
{#if recipe.heroImagePreview}
<img src={recipe.heroImagePreview} alt={recipe.name} class="w-full h-full object-cover" />
{:else}
<div
data-testid="image-placeholder"

View File

@@ -8,7 +8,7 @@ const mockRecipe = {
name: 'Spaghetti Bolognese',
cookTimeMin: 30,
effort: 'Easy',
heroImageUrl: undefined
heroImagePreview: undefined
};
describe('RecipeCard', () => {
@@ -27,18 +27,18 @@ describe('RecipeCard', () => {
expect(screen.getByText(/easy/i)).toBeInTheDocument();
});
it('shows placeholder when no heroImageUrl', () => {
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } });
it('shows placeholder when no heroImagePreview', () => {
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImagePreview: undefined } } });
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
});
it('shows image when heroImageUrl is provided', () => {
it('shows image when heroImagePreview is provided', () => {
render(RecipeCard, {
props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } }
props: { recipe: { ...mockRecipe, heroImagePreview: 'data:image/jpeg;base64,abc' } }
});
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/uploads/test.jpg');
expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc');
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
});

View File

@@ -61,10 +61,19 @@
);
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null);
let imageError = $state<string | null>(null);
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
function handleImageChange(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
if (file.size > MAX_IMAGE_BYTES) {
imageError = 'Datei zu groß. Maximal 5 MB erlaubt.';
(e.currentTarget as HTMLInputElement).value = '';
return;
}
imageError = null;
const reader = new FileReader();
reader.onload = () => {
heroImageUrl = reader.result as string;
@@ -196,6 +205,11 @@
/>
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
</label>
{#if imageError}
<p class="mt-[6px] text-[12px] text-[var(--color-error)]">{imageError}</p>
{:else}
<p class="mt-[6px] text-[11px] text-[var(--color-text-muted)]">Max. 5 MB</p>
{/if}
<input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
</div>

View File

@@ -29,7 +29,7 @@ const editProps = {
name: 'Spaghetti Bolognese',
serves: 4,
cookTimeMin: 30,
effort: 'Medium',
effort: 'medium',
heroImageUrl: undefined as string | undefined,
ingredients: [
{ name: 'Spaghetti', quantity: 200, unit: 'g' }
@@ -162,4 +162,31 @@ describe('RecipeForm', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows Max. 5 MB hint below upload button', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByText('Max. 5 MB')).toBeInTheDocument();
});
it('shows error when selected file exceeds 5 MB', async () => {
const user = userEvent.setup();
render(RecipeForm, { props: emptyProps });
const oversizedFile = new File(['x'.repeat(6 * 1024 * 1024)], 'big.jpg', { type: 'image/jpeg' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, oversizedFile);
expect(screen.getByText(/datei zu groß/i)).toBeInTheDocument();
});
it('does not show file size error for file within 5 MB', async () => {
const user = userEvent.setup();
render(RecipeForm, { props: emptyProps });
const okFile = new File(['x'.repeat(1 * 1024 * 1024)], 'small.jpg', { type: 'image/jpeg' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, okFile);
expect(screen.queryByText(/datei zu groß/i)).not.toBeInTheDocument();
});
});

View File

@@ -3,7 +3,7 @@ export type RecipeSummary = {
name: string;
cookTimeMin?: number;
effort?: string;
heroImageUrl?: string;
heroImagePreview?: string;
};
export type Tag = {

View File

@@ -20,7 +20,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
name: r.name!,
cookTimeMin: r.cookTimeMin,
effort: r.effort,
heroImageUrl: r.heroImageUrl
heroImagePreview: r.heroImagePreview
}));
const activePlan =

View File

@@ -72,15 +72,15 @@ export const actions: Actions = {
params: { path: { id: params.id } },
body: {
name: name.trim(),
serves: serves ? Number(serves) || undefined : undefined,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
serves: serves ? Number(serves) || null : null,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null,
effort,
heroImageUrl,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
.filter((ing) => ing.name?.trim())
.filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0)
.map((ing, i) => ({
newIngredientName: ing.name.trim(),
quantity: Number(ing.quantity) || 0,
quantity: Number(ing.quantity),
unit: ing.unit || '',
sortOrder: i
})),

View File

@@ -178,6 +178,19 @@ describe('edit recipe page — update action', () => {
}));
});
it('sends null for serves and cookTimeMin when fields are blank', async () => {
mockPut.mockResolvedValue({ error: undefined });
const fd = makeFormData({ serves: '', cookTimeMin: '' });
await actions.update({
request: { formData: async () => fd },
fetch: vi.fn(),
params: { id: 'r1' }
} as any).catch(() => {});
const body = mockPut.mock.calls[0][1].body;
expect(body.serves).toBeNull();
expect(body.cookTimeMin).toBeNull();
});
it('sends heroImageUrl in PUT body when provided', async () => {
mockPut.mockResolvedValue({ error: undefined });
const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' });
@@ -204,6 +217,25 @@ describe('edit recipe page — update action', () => {
}));
});
it('filters out ingredients with quantity <= 0 before PUT', async () => {
mockPut.mockResolvedValue({ error: undefined });
const fd = makeFormData({
ingredientsJson: JSON.stringify([
{ name: 'Spaghetti', quantity: 200, unit: 'g' },
{ name: 'Salt', quantity: 0, unit: 'tsp' },
{ name: 'Pepper', quantity: -1, unit: 'tsp' }
])
});
await actions.update({
request: { formData: async () => fd },
fetch: vi.fn(),
params: { id: 'r1' }
} as any).catch(() => {});
const body = mockPut.mock.calls[0][1].body;
expect(body.ingredients).toHaveLength(1);
expect(body.ingredients[0].newIngredientName).toBe('Spaghetti');
});
it('returns fail(500) when API returns error', async () => {
mockPut.mockResolvedValue({ error: { status: 500 } });
const result = await actions.update({

View File

@@ -44,15 +44,15 @@ export const actions: Actions = {
const { error: apiError } = await api.POST('/v1/recipes', {
body: {
name: name.trim(),
serves: serves ? Number(serves) || undefined : undefined,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
serves: serves ? Number(serves) || null : null,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null,
effort,
heroImageUrl,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
.filter((ing) => ing.name?.trim())
.filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0)
.map((ing, i) => ({
newIngredientName: ing.name.trim(),
quantity: Number(ing.quantity) || 0,
quantity: Number(ing.quantity),
unit: ing.unit || '',
sortOrder: i
})),

View File

@@ -145,6 +145,15 @@ describe('new recipe page — create action', () => {
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'easy' }) }));
});
it('sends null for serves and cookTimeMin when fields are blank', async () => {
mockPost.mockResolvedValue({ error: undefined });
const fd = makeFormData({ serves: '', cookTimeMin: '' });
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(() => {});
const body = mockPost.mock.calls[0][1].body;
expect(body.serves).toBeNull();
expect(body.cookTimeMin).toBeNull();
});
it('sends heroImageUrl in POST body when provided', async () => {
mockPost.mockResolvedValue({ error: undefined });
const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' });
@@ -163,6 +172,23 @@ describe('new recipe page — create action', () => {
}));
});
it('filters out ingredients with quantity <= 0 before POST', async () => {
mockPost.mockResolvedValue({ error: undefined });
const fd = makeFormData({
ingredientsJson: JSON.stringify([
{ name: 'Spaghetti', quantity: 200, unit: 'g' },
{ name: 'Salt', quantity: 0, unit: 'tsp' },
{ name: 'Pepper', quantity: -1, unit: 'tsp' }
])
});
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(
() => {}
);
const body = mockPost.mock.calls[0][1].body;
expect(body.ingredients).toHaveLength(1);
expect(body.ingredients[0].newIngredientName).toBe('Spaghetti');
});
it('returns fail(500) when API returns error', async () => {
mockPost.mockResolvedValue({ error: { status: 500 } });
const result = await actions.create({

View File

@@ -5,9 +5,9 @@ import Page from './+page.svelte';
const mockData = {
recipes: [
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
{ id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' }
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'easy' },
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'medium' },
{ id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'easy' }
],
activePlan: null
};