Compare commits
8 Commits
520dae5adf
...
f11cca534f
| Author | SHA1 | Date | |
|---|---|---|---|
| f11cca534f | |||
| 822b34cd14 | |||
| 46f2ec45a3 | |||
| 90cff0c4d2 | |||
| b1eb9ed964 | |||
| 44b3f06474 | |||
| dbc78a1883 | |||
| 30ba53099c |
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE recipe ADD COLUMN hero_image_preview text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE recipe DROP COLUMN is_child_friendly;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
5
frontend/src/lib/api/schema.d.ts
vendored
5
frontend/src/lib/api/schema.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user