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> <artifactId>postgresql</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </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> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <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, Principal principal,
@RequestParam(required = false) String search, @RequestParam(required = false) String search,
@RequestParam(required = false) String effort, @RequestParam(required = false) String effort,
@RequestParam(required = false) Boolean isChildFriendly,
@RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin, @RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin,
@RequestParam(required = false) String sort, @RequestParam(required = false) String sort,
@RequestParam(defaultValue = "20") int limit, @RequestParam(defaultValue = "20") int limit,
@@ -37,9 +36,9 @@ public class RecipeController {
UUID householdId = householdResolver.resolve(principal.getName()); UUID householdId = householdResolver.resolve(principal.getName());
List<RecipeSummaryResponse> recipes = recipeService.listRecipes( List<RecipeSummaryResponse> recipes = recipeService.listRecipes(
householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset); householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
long total = recipeService.countRecipes( 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 pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total);
var meta = new ApiResponse.Meta(pagination); var meta = new ApiResponse.Meta(pagination);

View File

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

View File

@@ -2,6 +2,7 @@ package com.recipeapp.recipe;
import com.recipeapp.common.ConflictException; import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.HouseholdRepository; import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.Household;
import com.recipeapp.recipe.dto.*; import com.recipeapp.recipe.dto.*;
@@ -22,31 +23,31 @@ public class RecipeService {
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final IngredientCategoryRepository ingredientCategoryRepository; private final IngredientCategoryRepository ingredientCategoryRepository;
private final HouseholdRepository householdRepository; private final HouseholdRepository householdRepository;
private final ImageCompressor imageCompressor;
public RecipeService(RecipeRepository recipeRepository, public RecipeService(RecipeRepository recipeRepository,
IngredientRepository ingredientRepository, IngredientRepository ingredientRepository,
TagRepository tagRepository, TagRepository tagRepository,
IngredientCategoryRepository ingredientCategoryRepository, IngredientCategoryRepository ingredientCategoryRepository,
HouseholdRepository householdRepository) { HouseholdRepository householdRepository,
ImageCompressor imageCompressor) {
this.recipeRepository = recipeRepository; this.recipeRepository = recipeRepository;
this.ingredientRepository = ingredientRepository; this.ingredientRepository = ingredientRepository;
this.tagRepository = tagRepository; this.tagRepository = tagRepository;
this.ingredientCategoryRepository = ingredientCategoryRepository; this.ingredientCategoryRepository = ingredientCategoryRepository;
this.householdRepository = householdRepository; this.householdRepository = householdRepository;
this.imageCompressor = imageCompressor;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort, public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin, Integer cookTimeMaxMin, String sort, int limit, int offset) {
String sort, int limit, int offset) { return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
cookTimeMaxMin, sort, limit, offset);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public long countRecipes(UUID householdId, String search, String effort, public long countRecipes(UUID householdId, String search, String effort, Integer cookTimeMaxMin) {
Boolean isChildFriendly, Integer cookTimeMaxMin) { return recipeRepository.countFiltered(householdId, search, effort, cookTimeMaxMin);
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -60,11 +61,14 @@ public class RecipeService {
Household household = householdRepository.findById(householdId) Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found")); .orElseThrow(() -> new ResourceNotFoundException("Household not found"));
validateHeroImageUrl(request.heroImageUrl());
Recipe recipe = new Recipe(household, request.name(), Recipe recipe = new Recipe(household, request.name(),
request.serves() != null ? request.serves().shortValue() : 0, request.serves() != null ? request.serves().shortValue() : 0,
request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0, request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0,
request.effort(), false); request.effort());
recipe.setHeroImageUrl(request.heroImageUrl()); recipe.setHeroImageUrl(request.heroImageUrl());
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
addIngredients(recipe, household, request.ingredients()); addIngredients(recipe, household, request.ingredients());
addSteps(recipe, request.steps()); addSteps(recipe, request.steps());
@@ -79,11 +83,14 @@ public class RecipeService {
Recipe recipe = findRecipe(householdId, recipeId); Recipe recipe = findRecipe(householdId, recipeId);
Household household = recipe.getHousehold(); Household household = recipe.getHousehold();
validateHeroImageUrl(request.heroImageUrl());
recipe.setName(request.name()); recipe.setName(request.name());
recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0); recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0);
recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0); recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0);
recipe.setEffort(request.effort()); recipe.setEffort(request.effort());
recipe.setHeroImageUrl(request.heroImageUrl()); recipe.setHeroImageUrl(request.heroImageUrl());
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
recipe.getIngredients().clear(); recipe.getIngredients().clear();
recipe.getSteps().clear(); recipe.getSteps().clear();
@@ -181,6 +188,18 @@ public class RecipeService {
return new IngredientCategoryResponse(category.getId(), category.getName()); 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 helpers ──
private Recipe findRecipe(UUID householdId, UUID recipeId) { private Recipe findRecipe(UUID householdId, UUID recipeId) {
@@ -239,7 +258,7 @@ public class RecipeService {
return new RecipeDetailResponse( return new RecipeDetailResponse(
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(), recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(), recipe.getEffort(), recipe.getHeroImageUrl(),
ingredients, steps, tags); ingredients, steps, tags);
} }

View File

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

View File

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

View File

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

View File

@@ -19,5 +19,10 @@ spring:
enabled: true enabled: true
locations: classpath:db/migration locations: classpath:db/migration
servlet:
multipart:
max-file-size: 5MB
max-request-size: 6MB
server: server:
port: 8080 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) { 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()); setId(r, Recipe.class, UUID.randomUUID());
return r; return r;
} }

View File

@@ -69,7 +69,7 @@ class SuggestionsTest {
} }
private Recipe createRecipe(String name) { 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()); setId(r, Recipe.class, UUID.randomUUID());
return r; return r;
} }

View File

@@ -69,7 +69,7 @@ class VarietyScoreTest {
} }
private Recipe createRecipe(String name) { 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()); setId(r, Recipe.class, UUID.randomUUID());
return r; 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 @Test
void listRecipesShouldReturn200WithPagination() throws Exception { void listRecipesShouldReturn200WithPagination() throws Exception {
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese", 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(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))) isNull(), eq(20), eq(0)))
.thenReturn(List.of(summary)); .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); .thenReturn(1L);
mockMvc.perform(get("/v1/recipes") mockMvc.perform(get("/v1/recipes")
@@ -69,17 +69,16 @@ class RecipeControllerTest {
@Test @Test
void listRecipesWithFiltersShouldPassParams() throws Exception { void listRecipesWithFiltersShouldPassParams() throws Exception {
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); 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))) eq(30), eq("-cookTimeMin"), eq(10), eq(5)))
.thenReturn(List.of()); .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); .thenReturn(0L);
mockMvc.perform(get("/v1/recipes") mockMvc.perform(get("/v1/recipes")
.principal(() -> "sarah@example.com") .principal(() -> "sarah@example.com")
.param("search", "pasta") .param("search", "pasta")
.param("effort", "easy") .param("effort", "easy")
.param("isChildFriendly", "true")
.param("cookTimeMin.lte", "30") .param("cookTimeMin.lte", "30")
.param("sort", "-cookTimeMin") .param("sort", "-cookTimeMin")
.param("limit", "10") .param("limit", "10")
@@ -162,6 +161,19 @@ class RecipeControllerTest {
verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID); 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() { private RecipeCreateRequest sampleCreateRequest() {
var ingredientId = UUID.randomUUID(); var ingredientId = UUID.randomUUID();
return new RecipeCreateRequest( return new RecipeCreateRequest(
@@ -175,7 +187,7 @@ class RecipeControllerTest {
private RecipeDetailResponse sampleDetail() { private RecipeDetailResponse sampleDetail() {
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta"); var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta");
return new RecipeDetailResponse( 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( List.of(new RecipeDetailResponse.IngredientItem(
UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)), UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)),
List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")), List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")),

View File

@@ -27,6 +27,7 @@ class RecipeServiceTest {
@Mock private TagRepository tagRepository; @Mock private TagRepository tagRepository;
@Mock private IngredientCategoryRepository ingredientCategoryRepository; @Mock private IngredientCategoryRepository ingredientCategoryRepository;
@Mock private HouseholdRepository householdRepository; @Mock private HouseholdRepository householdRepository;
@Mock private ImageCompressor imageCompressor;
@InjectMocks private RecipeService recipeService; @InjectMocks private RecipeService recipeService;
@@ -43,7 +44,7 @@ class RecipeServiceTest {
} }
private Recipe testRecipe(Household household) { 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 { try {
var field = Recipe.class.getDeclaredField("id"); var field = Recipe.class.getDeclaredField("id");
field.setAccessible(true); field.setAccessible(true);
@@ -525,6 +526,29 @@ class RecipeServiceTest {
.isInstanceOf(ResourceNotFoundException.class); .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 ── // ── Tag/Category edge cases ──
@Test @Test
@@ -547,6 +571,31 @@ class RecipeServiceTest {
.isInstanceOf(ResourceNotFoundException.class); .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 @Test
void listTagsShouldReturnEmptyList() { void listTagsShouldReturnEmptyList() {
when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of()); when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of());

View File

@@ -60,7 +60,7 @@ class ShoppingServiceTest {
} }
private Recipe testRecipe(Household household, String name) { 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()); setId(r, Recipe.class, UUID.randomUUID());
return r; return r;
} }

View File

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

View File

@@ -3,30 +3,44 @@ import { render, screen } from '@testing-library/svelte';
import VarietyWarningCards from './VarietyWarningCards.svelte'; import VarietyWarningCards from './VarietyWarningCards.svelte';
const warnings = [ 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', () => { describe('VarietyWarningCards', () => {
it('renders one card per warning', () => { it('renders one card per warning', () => {
render(VarietyWarningCards, { props: { warnings } }); render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
const cards = screen.getAllByTestId('warning-card'); const cards = screen.getAllByTestId('warning-card');
expect(cards.length).toBe(2); expect(cards.length).toBe(2);
}); });
it('renders warning titles', () => { 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(/Chicken zweimal/)).toBeTruthy();
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy(); expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
}); });
it('renders warning explanations', () => { it('renders warning explanations', () => {
render(VarietyWarningCards, { props: { warnings } }); render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
expect(screen.getByText(/erwäge einen Tausch/)).toBeTruthy(); expect(screen.getByText('Chicken Tikka')).toBeTruthy();
expect(screen.getByText('Chicken Curry')).toBeTruthy();
}); });
it('renders nothing when warnings is empty', () => { 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); expect(screen.queryAllByTestId('warning-card').length).toBe(0);
}); });
}); });

View File

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

View File

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

View File

@@ -61,10 +61,19 @@
); );
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']); let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null); 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) { function handleImageChange(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0]; const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return; 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(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
heroImageUrl = reader.result as string; heroImageUrl = reader.result as string;
@@ -196,6 +205,11 @@
/> />
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'} {heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
</label> </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 ?? ''} /> <input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
</div> </div>

View File

@@ -29,7 +29,7 @@ const editProps = {
name: 'Spaghetti Bolognese', name: 'Spaghetti Bolognese',
serves: 4, serves: 4,
cookTimeMin: 30, cookTimeMin: 30,
effort: 'Medium', effort: 'medium',
heroImageUrl: undefined as string | undefined, heroImageUrl: undefined as string | undefined,
ingredients: [ ingredients: [
{ name: 'Spaghetti', quantity: 200, unit: 'g' } { name: 'Spaghetti', quantity: 200, unit: 'g' }
@@ -162,4 +162,31 @@ describe('RecipeForm', () => {
render(RecipeForm, { props: emptyProps }); render(RecipeForm, { props: emptyProps });
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); 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; name: string;
cookTimeMin?: number; cookTimeMin?: number;
effort?: string; effort?: string;
heroImageUrl?: string; heroImagePreview?: string;
}; };
export type Tag = { export type Tag = {

View File

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

View File

@@ -72,15 +72,15 @@ export const actions: Actions = {
params: { path: { id: params.id } }, params: { path: { id: params.id } },
body: { body: {
name: name.trim(), name: name.trim(),
serves: serves ? Number(serves) || undefined : undefined, serves: serves ? Number(serves) || null : null,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined, cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null,
effort, effort,
heroImageUrl, heroImageUrl,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) 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) => ({ .map((ing, i) => ({
newIngredientName: ing.name.trim(), newIngredientName: ing.name.trim(),
quantity: Number(ing.quantity) || 0, quantity: Number(ing.quantity),
unit: ing.unit || '', unit: ing.unit || '',
sortOrder: i 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 () => { it('sends heroImageUrl in PUT body when provided', async () => {
mockPut.mockResolvedValue({ error: undefined }); mockPut.mockResolvedValue({ error: undefined });
const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' }); 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 () => { it('returns fail(500) when API returns error', async () => {
mockPut.mockResolvedValue({ error: { status: 500 } }); mockPut.mockResolvedValue({ error: { status: 500 } });
const result = await actions.update({ const result = await actions.update({

View File

@@ -44,15 +44,15 @@ export const actions: Actions = {
const { error: apiError } = await api.POST('/v1/recipes', { const { error: apiError } = await api.POST('/v1/recipes', {
body: { body: {
name: name.trim(), name: name.trim(),
serves: serves ? Number(serves) || undefined : undefined, serves: serves ? Number(serves) || null : null,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined, cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null,
effort, effort,
heroImageUrl, heroImageUrl,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) 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) => ({ .map((ing, i) => ({
newIngredientName: ing.name.trim(), newIngredientName: ing.name.trim(),
quantity: Number(ing.quantity) || 0, quantity: Number(ing.quantity),
unit: ing.unit || '', unit: ing.unit || '',
sortOrder: i 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' }) })); 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 () => { it('sends heroImageUrl in POST body when provided', async () => {
mockPost.mockResolvedValue({ error: undefined }); mockPost.mockResolvedValue({ error: undefined });
const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' }); 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 () => { it('returns fail(500) when API returns error', async () => {
mockPost.mockResolvedValue({ error: { status: 500 } }); mockPost.mockResolvedValue({ error: { status: 500 } });
const result = await actions.create({ const result = await actions.create({

View File

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