Compare commits
251 Commits
feat/issue
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 32bfcd5c16 | |||
| 9a644b5640 | |||
| df0d453b69 | |||
| 26256ef492 | |||
| ccfc72ab38 | |||
| 230ee5a067 | |||
| 0b182a33fd | |||
| 73af11e84b | |||
| 0ab1ba0b1b | |||
| 44fd398701 | |||
| 6aed303627 | |||
| c5ec3396b2 | |||
| 6950b3d8db | |||
| 92f25e56fc | |||
| b577b7a0f8 | |||
| 69d695b2c4 | |||
| 43227b2265 | |||
| a6683d06bb | |||
| ed0f3c21fe | |||
| dbf2951f09 | |||
| d6bfd2cb46 | |||
| 9ccd367d74 | |||
| 6aef12fa3c | |||
| 27b7058d31 | |||
| 60d84c0c94 | |||
| 9d3be84a0c | |||
| 2ad75cc1b7 | |||
| eb5699577b | |||
| 05476ecaab | |||
| c40b0fe095 | |||
| 4e67ff4258 | |||
| df3b774f0c | |||
| 1b5704c8b5 | |||
| b04f2c51d2 | |||
| d1e4b6c49e | |||
| 27163e3d72 | |||
| 5904102b1a | |||
| d66120b191 | |||
| 98c8aa9610 | |||
| af275642b0 | |||
| dde78baa84 | |||
| 6e559d9f9d | |||
| ef39a97f57 | |||
| 824bb9445f | |||
| b0fc9f55c1 | |||
| 2ed5186ac8 | |||
| 48802a04f7 | |||
| 0b3d062ed1 | |||
| 109b41b434 | |||
| 3f9fb900c4 | |||
| 33cccd3d63 | |||
| cfbde18435 | |||
| 4835231f6d | |||
| 3f9bd2b226 | |||
| 9423cd673c | |||
| 4c87d9c134 | |||
| e5c361fe42 | |||
| a8a781f1e9 | |||
| b0800ca4f3 | |||
| 66447a7ea0 | |||
| 7f4413852d | |||
| eb3f6fad25 | |||
| fc682bfc54 | |||
| 38528a50e5 | |||
| a43a8ec33f | |||
| 8679ebc6e3 | |||
| 0ae1767649 | |||
| d54ac6a37a | |||
| d901310897 | |||
| ed4cdbf230 | |||
| 75228058a6 | |||
| b919a716f5 | |||
| 389500c1dd | |||
| 8709e85d80 | |||
| 358edb9a12 | |||
| f97cf49bd0 | |||
| 2cebf504f2 | |||
| d20cd53be2 | |||
| 2b7a7cceec | |||
| f37f20d34e | |||
| f2071ca5d8 | |||
| 16e1539ac0 | |||
| e5cdce164a | |||
| 73b4fb84e7 | |||
| 932155c559 | |||
| a5bb5d45a3 | |||
| b2a798d90e | |||
| 23c821937f | |||
| 9df6d6f0c6 | |||
| ebaf42d83d | |||
| 56e6143fd2 | |||
| ed769b18a4 | |||
| f11cca534f | |||
| 822b34cd14 | |||
| 46f2ec45a3 | |||
| 90cff0c4d2 | |||
| b1eb9ed964 | |||
| 44b3f06474 | |||
| dbc78a1883 | |||
| 30ba53099c | |||
| 520dae5adf | |||
| f139dce82c | |||
| 0596fddcd3 | |||
| 008c725813 | |||
| 1739b70d54 | |||
| 3b829325f2 | |||
| d139e5e28c | |||
| c9d6564fbe | |||
| ba79cff4e7 | |||
| 55285e7d5d | |||
| 055ae11fa3 | |||
| bf18f2bd84 | |||
| da21a12222 | |||
| e9dc04b2a5 | |||
| 8dfc3df06b | |||
| ea070b4760 | |||
| aecdf249d6 | |||
| e4345350ad | |||
| 56decf155d | |||
| 1de4b15e34 | |||
| ccec0baa99 | |||
| 9928591b48 | |||
| 89a549a1c8 | |||
| c24281dd4c | |||
| 8051fcbe22 | |||
| b45ab0fd46 | |||
| 2bbc3762e2 | |||
| a751b0758a | |||
| 8234c2f162 | |||
| 257808016d | |||
| cd7f4a1ea0 | |||
| b673a466e9 | |||
| e3066ec3e5 | |||
| bd1604fc1d | |||
| c297403506 | |||
| fa4a4c9ef7 | |||
| 116e400a91 | |||
| 6dd0b7ac93 | |||
| 49ed75a989 | |||
| 813ddf8214 | |||
| 7359eba946 | |||
| 16162d80f4 | |||
| 148f6a7b5b | |||
| f4503b0220 | |||
| f4648cc382 | |||
| 081b8dcaf0 | |||
| f33302e012 | |||
| 06bf567b90 | |||
| 1de9dfc314 | |||
| 77cdccb26c | |||
| 1611ddabf6 | |||
| f55d938b32 | |||
| cb921b3c0f | |||
| 8686f9eb9f | |||
| f7a239655a | |||
| 539ca5d231 | |||
| 0a9e8032cf | |||
| f84a647b8d | |||
| e17e8d4630 | |||
| 482597bb6a | |||
| 387d0705a4 | |||
| ab66269131 | |||
| 59366b6e9c | |||
| 4549e9a7fd | |||
| b6ad64ea53 | |||
| 7e97d2dc58 | |||
| d008a17735 | |||
| f0bbb3b009 | |||
| b4fa3ca23e | |||
| 9482ecbf36 | |||
| 278fda7d90 | |||
| 8e3256d960 | |||
| 30722d9bcc | |||
| dd9a86d4e9 | |||
| c8c2605f31 | |||
| 1b2a02881d | |||
| 8756bf93d9 | |||
| dac83c70ea | |||
| 5b8d336d21 | |||
| e5d96cd85a | |||
| ea7113ec53 | |||
| 4333dc0d84 | |||
| cbafe783e9 | |||
| 178c888635 | |||
| f5adc051e8 | |||
| 90c9ea1894 | |||
| ba41f6984b | |||
| 25c575c167 | |||
| 36ae82af5d | |||
| 7175b56833 | |||
| a52b0a9d24 | |||
| f6265efa92 | |||
| 3cd9154550 | |||
| be43fe94b6 | |||
| e3afe1b4f2 | |||
| eb5ee1ab5a | |||
| 9d210befa1 | |||
| 40a6a0e92d | |||
| 40ee4dad53 | |||
| 741141168b | |||
| 6cc79836d5 | |||
| 5ac8f1768f | |||
| 7bdadbe962 | |||
| 2151dff4db | |||
| e831480860 | |||
| 92922533ac | |||
| 16b70bd818 | |||
| 5325f4827e | |||
| c26c2e1973 | |||
| 93e8bf9e41 | |||
| 7e254fc280 | |||
| 3be9f502c6 | |||
| 2f690eb3cb | |||
| 2253c76287 | |||
| e12fb72fc2 | |||
| 693ec2b997 | |||
| e73a84af5f | |||
| 27e09a77d6 | |||
| 6d76da5542 | |||
| 8e82213d1e | |||
| cb15143c30 | |||
| 9adf786b8f | |||
| 1bf929280b | |||
| 75c860a62b | |||
| 8ad636f825 | |||
| 7c07bc443b | |||
| 05e47c3dac | |||
| 5d2bb9e84e | |||
| e3f8d8ad73 | |||
| 0511a735a5 | |||
| 33f3b30cb4 | |||
| e4d3008139 | |||
| 6505cb4251 | |||
| 3d49e6b7bf | |||
| 4e2b0b5727 | |||
| 2cef8a1169 | |||
| fcf0f297bb | |||
| 0256b4360b | |||
| 00c48a7c96 | |||
| ce860d68e4 | |||
| b39d04acce | |||
| c7e56a173d | |||
| 86a25eb038 | |||
| a34c6f30f2 | |||
| 9bb6293d9f | |||
| 47c748145d | |||
| a25286e385 | |||
| a733e8dd66 | |||
| 35ed6ca878 | |||
| dc99459a2e | |||
| 021d308a71 |
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -31,3 +31,6 @@ build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Local dev config (may contain secrets / local DB credentials) ###
|
||||
src/main/resources/application-dev.yml
|
||||
|
||||
@@ -7,6 +7,7 @@ COPY src src
|
||||
RUN ./mvnw package -DskipTests -B
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
RUN apk add --no-cache libwebp
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -55,6 +55,16 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.coobird</groupId>
|
||||
<artifactId>thumbnailator</artifactId>
|
||||
<version>0.4.21</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.twelvemonkeys.imageio</groupId>
|
||||
<artifactId>imageio-webp</artifactId>
|
||||
<version>3.13.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -7,15 +7,10 @@ import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/auth")
|
||||
@@ -32,7 +27,7 @@ public class AuthController {
|
||||
@Valid @RequestBody SignupRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UserResponse user = authService.signup(request);
|
||||
authenticateInSession(user.email(), "user", httpRequest);
|
||||
authService.authenticateInSession(user.email(), "user", httpRequest);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
@@ -41,30 +36,10 @@ public class AuthController {
|
||||
@Valid @RequestBody LoginRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UserResponse user = authService.login(request);
|
||||
// Session fixation protection: invalidate old session before creating new one
|
||||
var oldSession = httpRequest.getSession(false);
|
||||
if (oldSession != null) {
|
||||
oldSession.invalidate();
|
||||
}
|
||||
authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
|
||||
authService.authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
|
||||
return ResponseEntity.ok(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an authenticated Spring Security context and stores it in the HTTP session
|
||||
* so that subsequent requests from the same session are recognised as authenticated.
|
||||
* We do this manually because we are not using Spring Security's built-in form login.
|
||||
*/
|
||||
private void authenticateInSession(String email, String role, HttpServletRequest request) {
|
||||
var auth = UsernamePasswordAuthenticationToken.authenticated(
|
||||
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(auth);
|
||||
SecurityContextHolder.setContext(context);
|
||||
request.getSession(true).setAttribute(
|
||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
||||
HttpSession session = httpRequest.getSession(false);
|
||||
|
||||
@@ -7,10 +7,18 @@ import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdMemberRepository;
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
@@ -82,6 +90,24 @@ public class AuthService {
|
||||
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes an authenticated Spring Security session for the given user.
|
||||
* Invalidates any existing session first (session fixation protection).
|
||||
*/
|
||||
public void authenticateInSession(String email, String role, HttpServletRequest request) {
|
||||
var oldSession = request.getSession(false);
|
||||
if (oldSession != null) {
|
||||
oldSession.invalidate();
|
||||
}
|
||||
var auth = UsernamePasswordAuthenticationToken.authenticated(
|
||||
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(auth);
|
||||
SecurityContextHolder.setContext(context);
|
||||
request.getSession(true).setAttribute(
|
||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(UserAccount user) {
|
||||
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
|
||||
.map(member -> UserResponse.withHousehold(
|
||||
|
||||
@@ -24,11 +24,13 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/v1/invites/**").permitAll()
|
||||
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.sessionManagement(session -> session
|
||||
.sessionFixation().changeSessionId()
|
||||
.maximumSessions(1));
|
||||
|
||||
return http.build();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
public class ForbiddenException extends RuntimeException {
|
||||
public ForbiddenException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,12 @@ public class GlobalExceptionHandler {
|
||||
.body(ApiError.of("CONFLICT", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ForbiddenException.class)
|
||||
public ResponseEntity<ApiError> handleForbidden(ForbiddenException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiError.of("FORBIDDEN", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ValidationException.class)
|
||||
public ResponseEntity<ApiError> handleBusinessValidation(ValidationException ex) {
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
@Component
|
||||
public class HouseholdRoleInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final HouseholdResolver householdResolver;
|
||||
|
||||
public HouseholdRoleInterceptor(HouseholdResolver householdResolver) {
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
RequiresHouseholdRole annotation = handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class);
|
||||
if (annotation == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null) {
|
||||
throw new ForbiddenException("Not authenticated");
|
||||
}
|
||||
|
||||
String actualRole = householdResolver.resolveRole(auth.getName());
|
||||
if (!annotation.value().equals(actualRole)) {
|
||||
throw new ForbiddenException("Insufficient permissions");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RequiresHouseholdRole {
|
||||
String value();
|
||||
}
|
||||
20
backend/src/main/java/com/recipeapp/common/WebMvcConfig.java
Normal file
20
backend/src/main/java/com/recipeapp/common/WebMvcConfig.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final HouseholdRoleInterceptor householdRoleInterceptor;
|
||||
|
||||
public WebMvcConfig(HouseholdRoleInterceptor householdRoleInterceptor) {
|
||||
this.householdRoleInterceptor = householdRoleInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(householdRoleInterceptor);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.recipeapp.household;
|
||||
|
||||
import com.recipeapp.auth.AuthService;
|
||||
import com.recipeapp.common.ApiResponse;
|
||||
import com.recipeapp.household.dto.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -9,15 +11,19 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1")
|
||||
public class HouseholdController {
|
||||
|
||||
private final HouseholdService householdService;
|
||||
private final AuthService authService;
|
||||
|
||||
public HouseholdController(HouseholdService householdService) {
|
||||
public HouseholdController(HouseholdService householdService, AuthService authService) {
|
||||
this.householdService = householdService;
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/households")
|
||||
@@ -40,17 +46,49 @@ public class HouseholdController {
|
||||
return ResponseEntity.ok(members);
|
||||
}
|
||||
|
||||
@GetMapping("/households/mine/invites")
|
||||
public ResponseEntity<ApiResponse<InviteResponse>> getActiveInvite(Principal principal) {
|
||||
Optional<InviteResponse> invite = householdService.getActiveInvite(principal.getName());
|
||||
return invite
|
||||
.map(r -> ResponseEntity.ok(ApiResponse.success(r)))
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PostMapping("/households/mine/invites")
|
||||
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
|
||||
InviteResponse response = householdService.createInvite(principal.getName());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@DeleteMapping("/households/mine/members/{userId}")
|
||||
public ResponseEntity<Void> removeMember(Principal principal, @PathVariable UUID userId) {
|
||||
householdService.removeMember(principal.getName(), userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PatchMapping("/households/mine/members/{userId}")
|
||||
public ResponseEntity<ApiResponse<MemberResponse>> changeMemberRole(
|
||||
Principal principal,
|
||||
@PathVariable UUID userId,
|
||||
@Valid @RequestBody ChangeRoleRequest request) {
|
||||
MemberResponse response = householdService.changeMemberRole(principal.getName(), userId, request.role());
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@GetMapping("/invites/{code}")
|
||||
public ResponseEntity<ApiResponse<InviteInfoResponse>> getInviteInfo(@PathVariable String code) {
|
||||
InviteInfoResponse response = householdService.getInviteInfo(code);
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@PostMapping("/invites/{code}/accept")
|
||||
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
||||
Principal principal,
|
||||
@PathVariable String code) {
|
||||
AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code);
|
||||
@PathVariable String code,
|
||||
@Valid @RequestBody AcceptInviteRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
AcceptInviteResponse response = householdService.acceptInvite(
|
||||
code, request.name(), request.email(), request.password());
|
||||
authService.authenticateInSession(request.email(), "user", httpRequest);
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ import java.util.UUID;
|
||||
|
||||
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
|
||||
Optional<HouseholdInvite> findByInviteCode(String inviteCode);
|
||||
Optional<HouseholdInvite> findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,6 @@ import java.util.UUID;
|
||||
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
||||
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
||||
Optional<HouseholdMember> findByHouseholdIdAndUserId(UUID householdId, UUID userId);
|
||||
long countByHouseholdIdAndRole(UUID householdId, String role);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.dto.*;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.household.entity.HouseholdInvite;
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
@@ -17,12 +18,15 @@ import com.recipeapp.recipe.TagRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.IngredientCategory;
|
||||
import com.recipeapp.recipe.entity.Tag;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class HouseholdService {
|
||||
@@ -35,6 +39,10 @@ public class HouseholdService {
|
||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Value("${app.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
@@ -46,7 +54,8 @@ public class HouseholdService {
|
||||
IngredientRepository ingredientRepository,
|
||||
IngredientCategoryRepository ingredientCategoryRepository,
|
||||
TagRepository tagRepository,
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.householdMemberRepository = householdMemberRepository;
|
||||
@@ -55,6 +64,7 @@ public class HouseholdService {
|
||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -91,42 +101,121 @@ public class HouseholdService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemberResponse changeMemberRole(String requesterEmail, UUID targetUserId, String newRole) {
|
||||
HouseholdMember requester = findMembership(requesterEmail);
|
||||
UUID householdId = requester.getHousehold().getId();
|
||||
|
||||
HouseholdMember target = householdMemberRepository
|
||||
.findByHouseholdIdAndUserId(householdId, targetUserId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
|
||||
|
||||
if (target.getRole().equals(newRole)) {
|
||||
return toMemberResponse(target);
|
||||
}
|
||||
|
||||
if ("member".equals(newRole) && "planner".equals(target.getRole())) {
|
||||
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
|
||||
if (plannerCount <= 1) {
|
||||
throw new ConflictException("Cannot degrade the last planner");
|
||||
}
|
||||
}
|
||||
|
||||
target.setRole(newRole);
|
||||
return toMemberResponse(householdMemberRepository.save(target));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeMember(String requesterEmail, UUID targetUserId) {
|
||||
HouseholdMember requester = findMembership(requesterEmail);
|
||||
UUID householdId = requester.getHousehold().getId();
|
||||
|
||||
HouseholdMember target = householdMemberRepository
|
||||
.findByHouseholdIdAndUserId(householdId, targetUserId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
|
||||
|
||||
if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) {
|
||||
throw new ConflictException("Planner cannot remove yourself");
|
||||
}
|
||||
|
||||
if ("planner".equals(target.getRole())) {
|
||||
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
|
||||
if (plannerCount <= 1) {
|
||||
throw new ConflictException("Cannot remove the last planner");
|
||||
}
|
||||
}
|
||||
|
||||
householdMemberRepository.delete(target);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<InviteResponse> getActiveInvite(String userEmail) {
|
||||
HouseholdMember member = findMembership(userEmail);
|
||||
return householdInviteRepository
|
||||
.findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId())
|
||||
.filter(invite -> invite.getExpiresAt().isAfter(Instant.now()))
|
||||
.map(this::toInviteResponse);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InviteResponse createInvite(String userEmail) {
|
||||
HouseholdMember member = findMembership(userEmail);
|
||||
Household household = member.getHousehold();
|
||||
|
||||
householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId())
|
||||
.ifPresent(existing -> {
|
||||
existing.setInvalidatedAt(Instant.now());
|
||||
householdInviteRepository.saveAndFlush(existing);
|
||||
});
|
||||
|
||||
String code = generateInviteCode();
|
||||
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
||||
|
||||
HouseholdInvite invite = householdInviteRepository.save(
|
||||
new HouseholdInvite(household, code, expiresAt));
|
||||
HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt);
|
||||
invite.setInvitedBy(member.getUser());
|
||||
householdInviteRepository.save(invite);
|
||||
|
||||
return new InviteResponse(
|
||||
invite.getInviteCode(),
|
||||
"https://yourapp.com/join/" + invite.getInviteCode(),
|
||||
invite.getExpiresAt());
|
||||
return toInviteResponse(invite);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public InviteInfoResponse getInviteInfo(String code) {
|
||||
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
|
||||
|
||||
if ("used".equals(invite.getStatus())
|
||||
|| invite.getInvalidatedAt() != null
|
||||
|| invite.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ResourceNotFoundException("Invite not found or invalid");
|
||||
}
|
||||
|
||||
String inviterName = invite.getInvitedBy() != null
|
||||
? invite.getInvitedBy().getDisplayName()
|
||||
: invite.getHousehold().getCreatedBy().getDisplayName();
|
||||
|
||||
return new InviteInfoResponse(invite.getHousehold().getName(), inviterName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
|
||||
UserAccount user = findUser(userEmail);
|
||||
|
||||
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
|
||||
throw new ConflictException("User is already in a household");
|
||||
public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) {
|
||||
if (userAccountRepository.existsByEmailIgnoreCase(email)) {
|
||||
throw new ConflictException("Email already registered");
|
||||
}
|
||||
|
||||
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
|
||||
|
||||
if ("used".equals(invite.getStatus())) {
|
||||
throw new ConflictException("Invite code already used");
|
||||
}
|
||||
if (invite.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ValidationException("Invite code has expired");
|
||||
if ("used".equals(invite.getStatus())
|
||||
|| invite.getInvalidatedAt() != null
|
||||
|| invite.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ResourceNotFoundException("Invite not found or invalid");
|
||||
}
|
||||
|
||||
UserAccount user = userAccountRepository.save(
|
||||
new UserAccount(email, name, passwordEncoder.encode(rawPassword)));
|
||||
|
||||
invite.setStatus("used");
|
||||
invite.setInvalidatedAt(Instant.now());
|
||||
householdInviteRepository.save(invite);
|
||||
|
||||
Household household = invite.getHousehold();
|
||||
@@ -204,4 +293,11 @@ public class HouseholdService {
|
||||
member.getRole(),
|
||||
member.getJoinedAt());
|
||||
}
|
||||
|
||||
private InviteResponse toInviteResponse(HouseholdInvite invite) {
|
||||
return new InviteResponse(
|
||||
invite.getInviteCode(),
|
||||
baseUrl + "/join/" + invite.getInviteCode(),
|
||||
invite.getExpiresAt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.recipeapp.household.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record AcceptInviteRequest(
|
||||
@NotBlank String name,
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(min = 8) String password
|
||||
) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.recipeapp.household.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
public record ChangeRoleRequest(
|
||||
@NotBlank
|
||||
@Pattern(regexp = "planner|member", message = "role must be 'planner' or 'member'")
|
||||
String role
|
||||
) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.recipeapp.household.dto;
|
||||
|
||||
public record InviteInfoResponse(
|
||||
String householdName,
|
||||
String inviterName
|
||||
) {}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.recipeapp.household.entity;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
@@ -16,6 +17,10 @@ public class HouseholdInvite {
|
||||
@JoinColumn(name = "household_id", nullable = false)
|
||||
private Household household;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "invited_by")
|
||||
private UserAccount invitedBy;
|
||||
|
||||
@Column(name = "invite_code", nullable = false, unique = true, length = 20)
|
||||
private String inviteCode;
|
||||
|
||||
@@ -25,6 +30,9 @@ public class HouseholdInvite {
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "invalidated_at")
|
||||
private Instant invalidatedAt;
|
||||
|
||||
protected HouseholdInvite() {}
|
||||
|
||||
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
|
||||
@@ -35,8 +43,12 @@ public class HouseholdInvite {
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public UserAccount getInvitedBy() { return invitedBy; }
|
||||
public void setInvitedBy(UserAccount invitedBy) { this.invitedBy = invitedBy; }
|
||||
public String getInviteCode() { return inviteCode; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public Instant getExpiresAt() { return expiresAt; }
|
||||
public Instant getInvalidatedAt() { return invalidatedAt; }
|
||||
public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; }
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class PlanningService {
|
||||
|
||||
private static final double MAX_VARIETY_SCORE = 10.0;
|
||||
|
||||
private final WeekPlanRepository weekPlanRepository;
|
||||
private final WeekPlanSlotRepository weekPlanSlotRepository;
|
||||
private final CookingLogRepository cookingLogRepository;
|
||||
@@ -135,6 +137,8 @@ public class PlanningService {
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
|
||||
|
||||
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
|
||||
|
||||
Set<String> lowerTagFilters = tagFilters.stream()
|
||||
@@ -145,11 +149,13 @@ public class PlanningService {
|
||||
.filter(r -> !usedRecipeIds.contains(r.getId()))
|
||||
.filter(r -> matchesAllTags(r, lowerTagFilters))
|
||||
.map(candidate -> {
|
||||
double score = simulateVarietyScore(
|
||||
double simulatedScore = simulateVarietyScore(
|
||||
plan, candidate, slotDate, config, recentlyCookedIds);
|
||||
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score);
|
||||
double scoreDelta = simulatedScore - currentScore;
|
||||
boolean hasConflict = scoreDelta < 0;
|
||||
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict);
|
||||
})
|
||||
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore()))
|
||||
.sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
|
||||
.limit(limit)
|
||||
.toList();
|
||||
|
||||
@@ -166,36 +172,65 @@ public class PlanningService {
|
||||
|
||||
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
|
||||
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
||||
// Build a simulated slot list: existing slots + candidate on slotDate
|
||||
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
|
||||
for (WeekPlanSlot slot : plan.getSlots()) {
|
||||
if (!slot.getSlotDate().equals(slotDate)) {
|
||||
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
|
||||
}
|
||||
}
|
||||
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
|
||||
return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds);
|
||||
}
|
||||
|
||||
private double computeCurrentScore(WeekPlan plan, VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
||||
List<SimulatedSlot> currentSlots = plan.getSlots().stream()
|
||||
.map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate()))
|
||||
.toList();
|
||||
return currentSlots.isEmpty() ? MAX_VARIETY_SCORE
|
||||
: scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds);
|
||||
}
|
||||
|
||||
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public VarietyPreviewResponse getVarietyPreview(UUID householdId, UUID planId, UUID recipeId, LocalDate date) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
Recipe candidate = findRecipe(recipeId, householdId);
|
||||
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
Set<UUID> recentlyCookedIds = cookingLogRepository
|
||||
.findByHouseholdIdAndCookedOnAfter(householdId,
|
||||
plan.getWeekStart().minusDays(config.getHistoryDays()))
|
||||
.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
|
||||
double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds);
|
||||
|
||||
return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore);
|
||||
}
|
||||
|
||||
private double scoreFromSimulatedSlots(List<SimulatedSlot> slots, VarietyScoreConfig config,
|
||||
Set<UUID> recentlyCookedIds) {
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (SimulatedSlot slot : slots) {
|
||||
for (Tag tag : slot.recipe.getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()).add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long tagRepeatCount = tagDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
long tagRepeatCount = tagDays.values().stream().filter(this::hasConsecutiveDays).count();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (SimulatedSlot slot : slots) {
|
||||
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
@@ -203,34 +238,35 @@ public class PlanningService {
|
||||
}
|
||||
}
|
||||
}
|
||||
long ingredientOverlapCount = ingredientDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
long ingredientOverlapCount = ingredientDays.values().stream().filter(this::hasConsecutiveDays).count();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
long recentRepeatCount = simulatedSlots.stream()
|
||||
long recentRepeatCount = slots.stream()
|
||||
.map(s -> s.recipe.getId())
|
||||
.distinct()
|
||||
.filter(recentlyCookedIds::contains)
|
||||
.count();
|
||||
|
||||
// 4. Duplicate recipes within the simulated plan
|
||||
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
|
||||
// 4. Duplicate recipes within the plan
|
||||
Map<UUID, Long> recipeCounts = slots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
|
||||
long duplicatePenaltyCount = recipeCounts.values().stream()
|
||||
.filter(c -> c > 1)
|
||||
.mapToLong(c -> c - 1)
|
||||
.sum();
|
||||
|
||||
double score = 10.0;
|
||||
score -= tagRepeatCount * wTagRepeat;
|
||||
score -= ingredientOverlapCount * wIngredientOverlap;
|
||||
score -= recentRepeatCount * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
return Math.max(0, Math.min(10, score));
|
||||
return applyPenalties(tagRepeatCount, ingredientOverlapCount, recentRepeatCount, duplicatePenaltyCount, config);
|
||||
}
|
||||
|
||||
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
|
||||
private double applyPenalties(long tagRepeats, long ingredientOverlaps, long recentRepeats,
|
||||
long duplicates, VarietyScoreConfig config) {
|
||||
double score = MAX_VARIETY_SCORE;
|
||||
score -= tagRepeats * config.getWTagRepeat().doubleValue();
|
||||
score -= ingredientOverlaps * config.getWIngredientOverlap().doubleValue();
|
||||
score -= recentRepeats * config.getWRecentRepeat().doubleValue();
|
||||
score -= duplicates * config.getWPlanDuplicate().doubleValue();
|
||||
return Math.max(0, Math.min(MAX_VARIETY_SCORE, score));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
|
||||
@@ -246,10 +282,6 @@ public class PlanningService {
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
int historyDays = config.getHistoryDays();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
@@ -317,13 +349,7 @@ public class PlanningService {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
double score = 10.0;
|
||||
score -= tagRepeats.size() * wTagRepeat;
|
||||
score -= overlaps.size() * wIngredientOverlap;
|
||||
score -= recentRepeats.size() * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
score = Math.max(0, Math.min(10, score));
|
||||
double score = applyPenalties(tagRepeats.size(), overlaps.size(), recentRepeats.size(), duplicatePenaltyCount, config);
|
||||
|
||||
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.recipeapp.planning;
|
||||
|
||||
import com.recipeapp.common.RequiresHouseholdRole;
|
||||
import com.recipeapp.planning.dto.*;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -40,6 +41,7 @@ public class WeekPlanController {
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/slots")
|
||||
@RequiresHouseholdRole("planner")
|
||||
public ResponseEntity<SlotResponse> addSlot(
|
||||
Principal principal,
|
||||
@PathVariable UUID id,
|
||||
@@ -50,6 +52,7 @@ public class WeekPlanController {
|
||||
}
|
||||
|
||||
@PatchMapping("/{planId}/slots/{slotId}")
|
||||
@RequiresHouseholdRole("planner")
|
||||
public SlotResponse updateSlot(
|
||||
Principal principal,
|
||||
@PathVariable UUID planId,
|
||||
@@ -61,6 +64,7 @@ public class WeekPlanController {
|
||||
|
||||
@DeleteMapping("/{planId}/slots/{slotId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequiresHouseholdRole("planner")
|
||||
public void deleteSlot(
|
||||
Principal principal,
|
||||
@PathVariable UUID planId,
|
||||
@@ -92,4 +96,15 @@ public class WeekPlanController {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return planningService.getVarietyScore(householdId, id);
|
||||
}
|
||||
|
||||
@GetMapping("/{planId}/variety-preview")
|
||||
@RequiresHouseholdRole("member")
|
||||
public VarietyPreviewResponse getVarietyPreview(
|
||||
Principal principal,
|
||||
@PathVariable UUID planId,
|
||||
@RequestParam UUID recipeId,
|
||||
@RequestParam LocalDate date) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return planningService.getVarietyPreview(householdId, planId, recipeId, date);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public record SuggestionResponse(List<SuggestionItem> suggestions) {
|
||||
|
||||
public record SuggestionItem(
|
||||
SlotResponse.SlotRecipe recipe,
|
||||
double simulatedScore
|
||||
double scoreDelta,
|
||||
boolean hasConflict
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.recipeapp.planning.dto;
|
||||
|
||||
public record VarietyPreviewResponse(
|
||||
double currentScore,
|
||||
double projectedScore,
|
||||
double scoreDelta
|
||||
) {}
|
||||
@@ -24,6 +24,10 @@ public class HouseholdResolver {
|
||||
return findMembership(userEmail).getUser().getId();
|
||||
}
|
||||
|
||||
public String resolveRole(String userEmail) {
|
||||
return findMembership(userEmail).getRole();
|
||||
}
|
||||
|
||||
private HouseholdMember findMembership(String userEmail) {
|
||||
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
|
||||
|
||||
@@ -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,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) String effort,
|
||||
@RequestParam(required = false) Boolean isChildFriendly,
|
||||
@RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin,
|
||||
@RequestParam(required = false) String sort,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@@ -37,9 +36,9 @@ public class RecipeController {
|
||||
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
List<RecipeSummaryResponse> recipes = recipeService.listRecipes(
|
||||
householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset);
|
||||
householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
|
||||
long total = recipeService.countRecipes(
|
||||
householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
||||
householdId, search, effort, cookTimeMaxMin);
|
||||
|
||||
var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total);
|
||||
var meta = new ApiResponse.Meta(pagination);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.recipe.dto.RecipeSummaryResponse;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
@@ -17,22 +16,19 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
||||
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
|
||||
|
||||
@Query("""
|
||||
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
|
||||
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl)
|
||||
FROM Recipe r
|
||||
SELECT r FROM Recipe r
|
||||
LEFT JOIN FETCH r.tags
|
||||
WHERE r.household.id = :householdId
|
||||
AND r.deletedAt IS NULL
|
||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
|
||||
AND (:effort IS NULL OR r.effort = :effort)
|
||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
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 (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
ORDER BY r.createdAt DESC
|
||||
""")
|
||||
List<RecipeSummaryResponse> findFiltered(
|
||||
List<Recipe> findFiltered(
|
||||
@Param("householdId") UUID householdId,
|
||||
@Param("search") String search,
|
||||
@Param("effort") String effort,
|
||||
@Param("isChildFriendly") Boolean isChildFriendly,
|
||||
@Param("cookTimeMaxMin") Integer cookTimeMaxMin,
|
||||
@Param("sort") String sort,
|
||||
@Param("limit") int limit,
|
||||
@@ -43,15 +39,13 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
||||
FROM Recipe r
|
||||
WHERE r.household.id = :householdId
|
||||
AND r.deletedAt IS NULL
|
||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
|
||||
AND (:effort IS NULL OR r.effort = :effort)
|
||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
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 (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
""")
|
||||
long countFiltered(
|
||||
@Param("householdId") UUID householdId,
|
||||
@Param("search") String search,
|
||||
@Param("effort") String effort,
|
||||
@Param("isChildFriendly") Boolean isChildFriendly,
|
||||
@Param("cookTimeMaxMin") Integer cookTimeMaxMin);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
@@ -22,31 +23,39 @@ public class RecipeService {
|
||||
private final TagRepository tagRepository;
|
||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final ImageCompressor imageCompressor;
|
||||
|
||||
public RecipeService(RecipeRepository recipeRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
TagRepository tagRepository,
|
||||
IngredientCategoryRepository ingredientCategoryRepository,
|
||||
HouseholdRepository householdRepository) {
|
||||
HouseholdRepository householdRepository,
|
||||
ImageCompressor imageCompressor) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.imageCompressor = imageCompressor;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin,
|
||||
String sort, int limit, int offset) {
|
||||
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
|
||||
cookTimeMaxMin, sort, limit, offset);
|
||||
Integer cookTimeMaxMin, String sort, int limit, int offset) {
|
||||
return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset)
|
||||
.stream()
|
||||
.map(r -> new RecipeSummaryResponse(
|
||||
r.getId(), r.getName(), r.getServes(), r.getCookTimeMin(), r.getEffort(),
|
||||
r.getHeroImageUrl(),
|
||||
r.getTags().stream()
|
||||
.map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType()))
|
||||
.toList()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long countRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin) {
|
||||
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
||||
public long countRecipes(UUID householdId, String search, String effort, Integer cookTimeMaxMin) {
|
||||
return recipeRepository.countFiltered(householdId, search, effort, cookTimeMaxMin);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -60,9 +69,14 @@ public class RecipeService {
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
|
||||
Recipe recipe = new Recipe(household, request.name(), request.serves(),
|
||||
request.cookTimeMin(), request.effort(), request.isChildFriendly());
|
||||
validateHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
Recipe recipe = new Recipe(household, request.name(),
|
||||
request.serves() != null ? request.serves().shortValue() : 0,
|
||||
request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0,
|
||||
request.effort());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
|
||||
|
||||
addIngredients(recipe, household, request.ingredients());
|
||||
addSteps(recipe, request.steps());
|
||||
@@ -77,12 +91,14 @@ public class RecipeService {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
Household household = recipe.getHousehold();
|
||||
|
||||
validateHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
recipe.setName(request.name());
|
||||
recipe.setServes(request.serves());
|
||||
recipe.setCookTimeMin(request.cookTimeMin());
|
||||
recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0);
|
||||
recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0);
|
||||
recipe.setEffort(request.effort());
|
||||
recipe.setChildFriendly(request.isChildFriendly());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
|
||||
|
||||
recipe.getIngredients().clear();
|
||||
recipe.getSteps().clear();
|
||||
@@ -180,6 +196,18 @@ public class RecipeService {
|
||||
return new IngredientCategoryResponse(category.getId(), category.getName());
|
||||
}
|
||||
|
||||
// ── Image validation ──
|
||||
|
||||
private static final java.util.regex.Pattern ALLOWED_IMAGE_PATTERN =
|
||||
java.util.regex.Pattern.compile("data:image/(jpeg|jpg|png|gif|webp);base64,.*");
|
||||
|
||||
private void validateHeroImageUrl(String heroImageUrl) {
|
||||
if (heroImageUrl == null || heroImageUrl.isBlank()) return;
|
||||
if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).matches()) {
|
||||
throw new ValidationException("Ungültiger Bildtyp. Erlaubt sind: JPEG, PNG, GIF, WebP.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
private Recipe findRecipe(UUID householdId, UUID recipeId) {
|
||||
@@ -238,7 +266,7 @@ public class RecipeService {
|
||||
|
||||
return new RecipeDetailResponse(
|
||||
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
|
||||
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(),
|
||||
recipe.getEffort(), recipe.getHeroImageUrl(),
|
||||
ingredients, steps, tags);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
public record RecipeCreateRequest(
|
||||
@NotBlank @Size(max = 200) String name,
|
||||
@Min(1) @Max(20) short serves,
|
||||
@Min(0) short cookTimeMin,
|
||||
Integer serves,
|
||||
Integer cookTimeMin,
|
||||
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
|
||||
boolean isChildFriendly,
|
||||
@Size(max = 500) String heroImageUrl,
|
||||
@Size(max = 7_000_000) String heroImageUrl,
|
||||
@NotEmpty @Valid List<IngredientEntry> ingredients,
|
||||
@Valid List<StepEntry> steps,
|
||||
@NotEmpty List<UUID> tagIds
|
||||
|
||||
@@ -10,7 +10,6 @@ public record RecipeDetailResponse(
|
||||
short serves,
|
||||
short cookTimeMin,
|
||||
String effort,
|
||||
boolean isChildFriendly,
|
||||
String heroImageUrl,
|
||||
List<IngredientItem> ingredients,
|
||||
List<StepItem> steps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RecipeSummaryResponse(
|
||||
@@ -8,6 +9,6 @@ public record RecipeSummaryResponse(
|
||||
short serves,
|
||||
short cookTimeMin,
|
||||
String effort,
|
||||
boolean isChildFriendly,
|
||||
String heroImageUrl
|
||||
String heroImageUrl,
|
||||
List<TagResponse> tags
|
||||
) {}
|
||||
|
||||
@@ -33,12 +33,12 @@ public class Recipe {
|
||||
@Column(nullable = false, length = 10)
|
||||
private String effort;
|
||||
|
||||
@Column(name = "is_child_friendly", nullable = false)
|
||||
private boolean isChildFriendly;
|
||||
|
||||
@Column(name = "hero_image_url", length = 500)
|
||||
@Column(name = "hero_image_url", columnDefinition = "text")
|
||||
private String heroImageUrl;
|
||||
|
||||
@Column(name = "hero_image_preview", columnDefinition = "text")
|
||||
private String heroImagePreview;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private Instant deletedAt;
|
||||
|
||||
@@ -64,14 +64,12 @@ public class Recipe {
|
||||
|
||||
protected Recipe() {}
|
||||
|
||||
public Recipe(Household household, String name, short serves, short cookTimeMin,
|
||||
String effort, boolean isChildFriendly) {
|
||||
public Recipe(Household household, String name, short serves, short cookTimeMin, String effort) {
|
||||
this.household = household;
|
||||
this.name = name;
|
||||
this.serves = serves;
|
||||
this.cookTimeMin = cookTimeMin;
|
||||
this.effort = effort;
|
||||
this.isChildFriendly = isChildFriendly;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
@@ -95,10 +93,10 @@ public class Recipe {
|
||||
public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; }
|
||||
public String getEffort() { return effort; }
|
||||
public void setEffort(String effort) { this.effort = effort; }
|
||||
public boolean isChildFriendly() { return isChildFriendly; }
|
||||
public void setChildFriendly(boolean childFriendly) { isChildFriendly = childFriendly; }
|
||||
public String getHeroImageUrl() { return heroImageUrl; }
|
||||
public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; }
|
||||
public String getHeroImagePreview() { return heroImagePreview; }
|
||||
public void setHeroImagePreview(String heroImagePreview) { this.heroImagePreview = heroImagePreview; }
|
||||
public Instant getDeletedAt() { return deletedAt; }
|
||||
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.recipeapp.shopping;
|
||||
|
||||
import com.recipeapp.common.RequiresHouseholdRole;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@@ -19,8 +23,21 @@ public class ShoppingListController {
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@GetMapping("/v1/shopping-list")
|
||||
public ShoppingListResponse getByWeekStart(
|
||||
@RequestParam(required = false) LocalDate weekStart,
|
||||
Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
ShoppingListResponse response = shoppingService.getByWeekStart(householdId, weekStart);
|
||||
if (response == null) {
|
||||
throw new ResourceNotFoundException("No shopping list for this week");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@PostMapping("/v1/week-plans/{id}/shopping-list")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequiresHouseholdRole("planner")
|
||||
public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.generateFromPlan(householdId, id);
|
||||
@@ -45,7 +62,7 @@ public class ShoppingListController {
|
||||
@PostMapping("/v1/shopping-lists/{id}/items")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ShoppingListItemResponse addItem(@PathVariable UUID id,
|
||||
@RequestBody AddItemRequest request,
|
||||
@Valid @RequestBody AddItemRequest request,
|
||||
Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.addItem(householdId, id, request);
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.recipeapp.shopping;
|
||||
import com.recipeapp.shopping.entity.ShoppingList;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ShoppingListRepository extends JpaRepository<ShoppingList, UUID> {
|
||||
Optional<ShoppingList> findByHouseholdIdAndWeekPlanWeekStart(UUID householdId, LocalDate weekStart);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.planning.WeekPlanRepository;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
import com.recipeapp.recipe.entity.RecipeIngredient;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import com.recipeapp.shopping.entity.ShoppingList;
|
||||
@@ -16,6 +18,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -29,19 +34,34 @@ public class ShoppingService {
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final RecipeRepository recipeRepository;
|
||||
|
||||
public ShoppingService(ShoppingListRepository shoppingListRepository,
|
||||
ShoppingListItemRepository shoppingListItemRepository,
|
||||
WeekPlanRepository weekPlanRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
UserAccountRepository userAccountRepository,
|
||||
RecipeRepository recipeRepository) {
|
||||
this.shoppingListRepository = shoppingListRepository;
|
||||
this.shoppingListItemRepository = shoppingListItemRepository;
|
||||
this.weekPlanRepository = weekPlanRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.recipeRepository = recipeRepository;
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ShoppingListResponse getByWeekStart(UUID householdId, LocalDate weekStart) {
|
||||
if (weekStart == null) {
|
||||
weekStart = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
|
||||
}
|
||||
|
||||
return shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekStart)
|
||||
.map(this::toResponse)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,45 +73,71 @@ public class ShoppingService {
|
||||
throw new ResourceNotFoundException("Week plan not found");
|
||||
}
|
||||
|
||||
var household = weekPlan.getHousehold();
|
||||
|
||||
ShoppingList shoppingList = new ShoppingList(household, weekPlan);
|
||||
shoppingList = shoppingListRepository.save(shoppingList);
|
||||
// Find or create the shopping list
|
||||
ShoppingList shoppingList = shoppingListRepository
|
||||
.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekPlan.getWeekStart())
|
||||
.orElseGet(() -> {
|
||||
var newList = new ShoppingList(weekPlan.getHousehold(), weekPlan);
|
||||
return shoppingListRepository.save(newList);
|
||||
});
|
||||
|
||||
// Aggregate ingredients across all slots/recipes
|
||||
// Key: ingredientId + unit -> merged data
|
||||
Map<String, MergedIngredient> merged = new LinkedHashMap<>();
|
||||
|
||||
for (var slot : weekPlan.getSlots()) {
|
||||
var recipe = slot.getRecipe();
|
||||
for (RecipeIngredient ri : recipe.getIngredients()) {
|
||||
Ingredient ingredient = ri.getIngredient();
|
||||
|
||||
// Filter out staples
|
||||
if (ingredient.isStaple()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = ingredient.getId().toString() + "|" + ri.getUnit();
|
||||
String key = mergeKey(ingredient.getId(), ri.getUnit());
|
||||
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
|
||||
.addQuantity(ri.getQuantity())
|
||||
.addRecipeId(recipe.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// Create shopping list items
|
||||
for (MergedIngredient mi : merged.values()) {
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
shoppingList,
|
||||
mi.ingredient,
|
||||
null,
|
||||
mi.totalQuantity,
|
||||
mi.unit,
|
||||
mi.recipeIds.stream().distinct().toArray(UUID[]::new)
|
||||
);
|
||||
shoppingList.getItems().add(item);
|
||||
// Build index of existing generated items by merge key
|
||||
Map<String, ShoppingListItem> existingByKey = new HashMap<>();
|
||||
List<ShoppingListItem> customItems = new ArrayList<>();
|
||||
for (ShoppingListItem item : shoppingList.getItems()) {
|
||||
if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
|
||||
// Generated item
|
||||
String key = mergeKey(item.getIngredient() != null ? item.getIngredient().getId() : null, item.getUnit());
|
||||
existingByKey.put(key, item);
|
||||
} else {
|
||||
customItems.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: update existing, add new, collect keys to keep
|
||||
Set<String> mergedKeys = new HashSet<>();
|
||||
for (MergedIngredient mi : merged.values()) {
|
||||
String key = mergeKey(mi.ingredient.getId(), mi.unit);
|
||||
mergedKeys.add(key);
|
||||
|
||||
ShoppingListItem existing = existingByKey.get(key);
|
||||
if (existing != null) {
|
||||
// Update quantity and sources, preserve check state
|
||||
existing.setQuantity(mi.totalQuantity);
|
||||
existing.setSourceRecipes(mi.recipeIds.stream().distinct().toArray(UUID[]::new));
|
||||
} else {
|
||||
// New item
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
shoppingList, mi.ingredient, null, mi.totalQuantity, mi.unit,
|
||||
mi.recipeIds.stream().distinct().toArray(UUID[]::new));
|
||||
shoppingList.getItems().add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove generated items no longer in the plan
|
||||
shoppingList.getItems().removeIf(item ->
|
||||
item.getSourceRecipes() != null && item.getSourceRecipes().length > 0
|
||||
&& !mergedKeys.contains(mergeKey(
|
||||
item.getIngredient() != null ? item.getIngredient().getId() : null,
|
||||
item.getUnit())));
|
||||
|
||||
shoppingList.setGeneratedAt(java.time.Instant.now());
|
||||
shoppingListRepository.save(shoppingList);
|
||||
|
||||
return toResponse(shoppingList);
|
||||
@@ -121,7 +167,7 @@ public class ShoppingService {
|
||||
}
|
||||
|
||||
shoppingListItemRepository.save(item);
|
||||
return toItemResponse(item);
|
||||
return toItemResponseWithNames(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +192,7 @@ public class ShoppingService {
|
||||
item = shoppingListItemRepository.save(item);
|
||||
list.getItems().add(item);
|
||||
|
||||
return toItemResponse(item);
|
||||
return toItemResponseWithNames(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -178,18 +224,53 @@ public class ShoppingService {
|
||||
}
|
||||
|
||||
private ShoppingListResponse toResponse(ShoppingList list) {
|
||||
// Batch-fetch recipe names for source references
|
||||
Set<UUID> allRecipeIds = list.getItems().stream()
|
||||
.filter(i -> i.getSourceRecipes() != null)
|
||||
.flatMap(i -> Arrays.stream(i.getSourceRecipes()))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<UUID, String> recipeNames = allRecipeIds.isEmpty()
|
||||
? Map.of()
|
||||
: recipeRepository.findAllById(allRecipeIds).stream()
|
||||
.collect(Collectors.toMap(Recipe::getId, Recipe::getName));
|
||||
|
||||
List<ShoppingListItemResponse> items = list.getItems().stream()
|
||||
.map(this::toItemResponse)
|
||||
.map(item -> toItemResponse(item, recipeNames))
|
||||
.toList();
|
||||
|
||||
// Count filtered staples from the week plan
|
||||
int filteredStaplesCount = countFilteredStaples(list.getWeekPlan());
|
||||
|
||||
return new ShoppingListResponse(
|
||||
list.getId(),
|
||||
list.getWeekPlan().getId(),
|
||||
list.getGeneratedAt(),
|
||||
filteredStaplesCount,
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
private ShoppingListItemResponse toItemResponse(ShoppingListItem item) {
|
||||
private int countFilteredStaples(WeekPlan weekPlan) {
|
||||
return (int) weekPlan.getSlots().stream()
|
||||
.flatMap(slot -> slot.getRecipe().getIngredients().stream())
|
||||
.map(RecipeIngredient::getIngredient)
|
||||
.filter(Ingredient::isStaple)
|
||||
.map(Ingredient::getId)
|
||||
.distinct()
|
||||
.count();
|
||||
}
|
||||
|
||||
private ShoppingListItemResponse toItemResponseWithNames(ShoppingListItem item) {
|
||||
Map<UUID, String> recipeNames = Map.of();
|
||||
if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
|
||||
recipeNames = recipeRepository.findAllById(Arrays.asList(item.getSourceRecipes())).stream()
|
||||
.collect(Collectors.toMap(Recipe::getId, Recipe::getName));
|
||||
}
|
||||
return toItemResponse(item, recipeNames);
|
||||
}
|
||||
|
||||
private ShoppingListItemResponse toItemResponse(ShoppingListItem item, Map<UUID, String> recipeNames) {
|
||||
String name;
|
||||
ShoppingListItemResponse.CategoryRef categoryRef = null;
|
||||
UUID ingredientId = null;
|
||||
@@ -207,6 +288,14 @@ public class ShoppingService {
|
||||
name = item.getCustomName();
|
||||
}
|
||||
|
||||
List<ShoppingListItemResponse.RecipeRef> sourceRefs = item.getSourceRecipes() != null
|
||||
? Arrays.stream(item.getSourceRecipes())
|
||||
.distinct()
|
||||
.filter(recipeNames::containsKey)
|
||||
.map(id -> new ShoppingListItemResponse.RecipeRef(id, recipeNames.get(id)))
|
||||
.toList()
|
||||
: List.of();
|
||||
|
||||
return new ShoppingListItemResponse(
|
||||
item.getId(),
|
||||
ingredientId,
|
||||
@@ -216,10 +305,14 @@ public class ShoppingService {
|
||||
item.getUnit(),
|
||||
item.isChecked(),
|
||||
item.getCheckedBy() != null ? item.getCheckedBy().getId() : null,
|
||||
item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of()
|
||||
sourceRefs
|
||||
);
|
||||
}
|
||||
|
||||
private static String mergeKey(UUID ingredientId, String unit) {
|
||||
return (ingredientId != null ? ingredientId.toString() : "") + "|" + unit;
|
||||
}
|
||||
|
||||
private static class MergedIngredient {
|
||||
final Ingredient ingredient;
|
||||
final String unit;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AddItemRequest(
|
||||
UUID ingredientId,
|
||||
String customName,
|
||||
BigDecimal quantity,
|
||||
@NotBlank @Size(max = 255) String customName,
|
||||
@Positive BigDecimal quantity,
|
||||
String unit
|
||||
) {}
|
||||
|
||||
@@ -13,7 +13,8 @@ public record ShoppingListItemResponse(
|
||||
String unit,
|
||||
boolean isChecked,
|
||||
UUID checkedBy,
|
||||
List<UUID> sourceRecipes
|
||||
List<RecipeRef> sourceRecipes
|
||||
) {
|
||||
public record CategoryRef(UUID id, String name) {}
|
||||
public record RecipeRef(UUID id, String name) {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ShoppingListResponse(
|
||||
UUID id,
|
||||
UUID weekPlanId,
|
||||
Instant generatedAt,
|
||||
int filteredStaplesCount,
|
||||
List<ShoppingListItemResponse> items
|
||||
) {}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.recipeapp.shopping.entity;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -23,6 +24,9 @@ public class ShoppingList {
|
||||
@JoinColumn(name = "week_plan_id", nullable = false)
|
||||
private WeekPlan weekPlan;
|
||||
|
||||
@Column(name = "generated_at", nullable = false)
|
||||
private Instant generatedAt = Instant.now();
|
||||
|
||||
@OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<ShoppingListItem> items = new ArrayList<>();
|
||||
|
||||
@@ -36,5 +40,7 @@ public class ShoppingList {
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public WeekPlan getWeekPlan() { return weekPlan; }
|
||||
public Instant getGeneratedAt() { return generatedAt; }
|
||||
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
|
||||
public List<ShoppingListItem> getItems() { return items; }
|
||||
}
|
||||
|
||||
3
backend/src/main/resources/application-dev.yml
Normal file
3
backend/src/main/resources/application-dev.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
spring:
|
||||
flyway:
|
||||
locations: classpath:db/migration,classpath:db/seed
|
||||
7
backend/src/main/resources/application-docker.yml
Normal file
7
backend/src/main/resources/application-docker.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
spring:
|
||||
flyway:
|
||||
locations: classpath:db/migration,classpath:db/seed
|
||||
out-of-order: true
|
||||
|
||||
app:
|
||||
base-url: ${APP_BASE_URL:http://localhost:5173}
|
||||
@@ -19,5 +19,17 @@ spring:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
# NOTE: these limits only apply to multipart/form-data uploads.
|
||||
# Images sent as base64 inside a JSON body (Content-Type: application/json)
|
||||
# are NOT constrained here — the @Size(max=7_000_000) annotation on
|
||||
# RecipeCreateRequest.heroImageUrl enforces the limit for that path.
|
||||
max-file-size: 5MB
|
||||
max-request-size: 6MB
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
app:
|
||||
base-url: http://localhost:5173
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE shopping_list
|
||||
ADD COLUMN IF NOT EXISTS generated_at timestamptz NOT NULL DEFAULT now();
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE recipe ALTER COLUMN hero_image_url TYPE text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE recipe ADD COLUMN hero_image_preview text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE recipe DROP COLUMN is_child_friendly;
|
||||
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE household_invite
|
||||
ADD COLUMN invalidated_at timestamptz;
|
||||
|
||||
-- Mark all but the most-recent invite per household as invalidated,
|
||||
-- so the unique partial index below can be created on dev databases
|
||||
-- that accumulated multiple pending invites before this migration was added.
|
||||
UPDATE household_invite
|
||||
SET invalidated_at = NOW()
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT ON (household_id) id
|
||||
FROM household_invite
|
||||
ORDER BY household_id, expires_at DESC
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_household_invite_active
|
||||
ON household_invite (household_id)
|
||||
WHERE invalidated_at IS NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE household_invite
|
||||
ADD COLUMN invited_by uuid REFERENCES user_account (id) ON DELETE SET NULL;
|
||||
182
backend/src/main/resources/db/seed/V100__dev_seed.sql
Normal file
182
backend/src/main/resources/db/seed/V100__dev_seed.sql
Normal file
@@ -0,0 +1,182 @@
|
||||
-- Dev seed: German household with Italian-leaning staples
|
||||
-- Fixed UUIDs so the migration is idempotent and references are stable.
|
||||
|
||||
-- ─── User & Household ────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO user_account (id, email, password_hash, display_name, created_at)
|
||||
VALUES (
|
||||
'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
'dev@mealprep.local',
|
||||
-- bcrypt of "dev" — never expose this outside local dev
|
||||
'$2a$10$IK233Yyc62EHt2hL5fw9F.0fBlEdoERr75LldZD35VFAAYfnkaOuK',
|
||||
'Dev User',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO household (id, name, created_by, created_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Musterhaushalt',
|
||||
'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO household_member (household_id, user_id, role, joined_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
'planner',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
-- ─── Ingredient Categories ───────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO ingredient_category (id, household_id, name, sort_order) VALUES
|
||||
('cc000001-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gemüse', 1),
|
||||
('cc000001-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Obst', 2),
|
||||
('cc000001-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Fleisch & Fisch', 3),
|
||||
('cc000001-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Milchprodukte & Eier', 4),
|
||||
('cc000001-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getreide & Nudeln', 5),
|
||||
('cc000001-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hülsenfrüchte', 6),
|
||||
('cc000001-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Konserven', 7),
|
||||
('cc000001-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürze & Kräuter', 8),
|
||||
('cc000001-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Öle & Essig', 9),
|
||||
('cc000001-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Saucen & Pasten', 10),
|
||||
('cc000001-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Nüsse & Samen', 11),
|
||||
('cc000001-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Backzutaten', 12),
|
||||
('cc000001-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tiefkühl', 13),
|
||||
('cc000001-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getränke', 14)
|
||||
ON CONFLICT (household_id, name) DO NOTHING;
|
||||
|
||||
-- ─── Staple Ingredients ──────────────────────────────────────────────────────
|
||||
-- is_staple = true means "always keep in stock"
|
||||
|
||||
-- Gemüse (frisch → kein Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zwiebeln', true, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Knoblauch', true, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Karotten', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Staudensellerie', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomaten', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Paprika', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zucchini', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Aubergine', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Spinat', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Brokkoli', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kartoffeln', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Süßkartoffeln', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Lauch', false, 'cc000001-0000-0000-0000-000000000001')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Obst (frisch → kein Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zitronen', false, 'cc000001-0000-0000-0000-000000000002'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Limetten', false, 'cc000001-0000-0000-0000-000000000002')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Fleisch & Fisch (frisches Fleisch → kein Staple; Konserven/Gepökeltes → Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Hähnchenbrust', false, 'cc000001-0000-0000-0000-000000000003'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Hackfleisch (gemischt)', false, 'cc000001-0000-0000-0000-000000000003'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Pancetta', true, 'cc000001-0000-0000-0000-000000000003'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Thunfisch (Dose)', true, 'cc000001-0000-0000-0000-000000000003')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Milchprodukte & Eier (frisch → kein Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Eier', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Butter', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Parmesan', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Mozzarella', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sahne', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Schmand', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Ricotta', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Milch', false, 'cc000001-0000-0000-0000-000000000004')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Getreide & Nudeln (Pasta → kein Staple; Trockenvorräte wie Reis/Mehl → Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Spaghetti', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Penne', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tagliatelle', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Lasagneplatten', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Risottoreis (Arborio)', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Basmati-Reis', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Weizenmehl (Type 405)', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Paniermehl', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Polenta', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Haferflocken', true, 'cc000001-0000-0000-0000-000000000005')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Hülsenfrüchte
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kichererbsen (Dose)', true, 'cc000001-0000-0000-0000-000000000006'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Linsen', true, 'cc000001-0000-0000-0000-000000000006'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Cannellini-Bohnen (Dose)', true, 'cc000001-0000-0000-0000-000000000006')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Konserven
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Gehackte Tomaten (Dose)', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'San-Marzano-Tomaten (Dose)', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomatenmark', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Gemüsebrühe', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Hühnerbrühe', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kapern', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Oliven (schwarz)', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sardellen (Dose)', true, 'cc000001-0000-0000-0000-000000000007')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Gewürze & Kräuter
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Salz', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Schwarzer Pfeffer', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Oregano (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Thymian (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rosmarin (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Lorbeerblätter', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Paprikapulver (edelsüß)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Chiliflocken', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Muskat (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zimt (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kümmel (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zucker', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Knoblauchpulver', true, 'cc000001-0000-0000-0000-000000000008')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Öle & Essig
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Olivenöl (extra vergine)', true, 'cc000001-0000-0000-0000-000000000009'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rapsöl', true, 'cc000001-0000-0000-0000-000000000009'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Balsamico-Essig', true, 'cc000001-0000-0000-0000-000000000009'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Weißweinessig', true, 'cc000001-0000-0000-0000-000000000009')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Saucen & Pasten
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum-Pesto', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomatenpassata', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sojasauce', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Worcestershiresauce', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Senf (mittelscharf)', true, 'cc000001-0000-0000-0000-000000000010')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Nüsse & Samen
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Pinienkerne', true, 'cc000001-0000-0000-0000-000000000011'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Walnüsse', true, 'cc000001-0000-0000-0000-000000000011'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sonnenblumenkerne', true, 'cc000001-0000-0000-0000-000000000011'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rosinen', true, 'cc000001-0000-0000-0000-000000000011')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Backzutaten
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Backpulver', true, 'cc000001-0000-0000-0000-000000000012'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Trockenhefe', true, 'cc000001-0000-0000-0000-000000000012'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Natron', true, 'cc000001-0000-0000-0000-000000000012'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Vanilleextrakt', true, 'cc000001-0000-0000-0000-000000000012')
|
||||
ON CONFLICT DO NOTHING;
|
||||
434
backend/src/main/resources/db/seed/V101__dev_seed_recipes.sql
Normal file
434
backend/src/main/resources/db/seed/V101__dev_seed_recipes.sql
Normal file
@@ -0,0 +1,434 @@
|
||||
-- Dev seed: 11 HelloFresh vegetarian recipes (4 persons)
|
||||
-- Fixed UUIDs so the migration is idempotent and references are stable.
|
||||
-- Ingredients use dd000002-prefix, tags ee000001-prefix, recipes ff000002-prefix.
|
||||
|
||||
-- ─── Tags ────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO tag (id, household_id, name, tag_type) VALUES
|
||||
('ee000001-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Vegetarisch', 'dietary'),
|
||||
('ee000001-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Glutenfrei', 'dietary'),
|
||||
('ee000001-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Deutsch', 'cuisine'),
|
||||
('ee000001-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mediterran', 'cuisine'),
|
||||
('ee000001-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Asiatisch', 'cuisine'),
|
||||
('ee000001-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mexikanisch', 'cuisine'),
|
||||
('ee000001-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Käse', 'protein'),
|
||||
('ee000001-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hülsenfrüchte', 'protein'),
|
||||
('ee000001-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Eier', 'protein'),
|
||||
('ee000001-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Auflauf', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Nudeln', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Reis', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schnell', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Ofengericht', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000015', 'bbbbbbbb-0000-0000-0000-000000000001', 'Flammkuchen', 'other')
|
||||
ON CONFLICT ON CONSTRAINT uq_tag_name DO NOTHING;
|
||||
|
||||
-- ─── Additional Ingredients ──────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
-- Gemüse
|
||||
('dd000002-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rucola', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Kirschtomaten', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Chilischote', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gurke', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Radieschen', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Zwiebeln', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Spitzpaprika', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gelbe Paprika', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Feldsalat', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
-- Obst
|
||||
('dd000002-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Avocado', false, 'cc000001-0000-0000-0000-000000000002'),
|
||||
('dd000002-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Äpfel', false, 'cc000001-0000-0000-0000-000000000002'),
|
||||
-- Milchprodukte & Eier
|
||||
('dd000002-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hartkäse ital. Art', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Cheddar (gerieben)', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Frischkäse', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000015', 'bbbbbbbb-0000-0000-0000-000000000001', 'Joghurt', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000016', 'bbbbbbbb-0000-0000-0000-000000000001', 'Halloumi', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000017', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tex-Mex-Käsemischung', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
-- Getreide & Nudeln
|
||||
('dd000002-0000-0000-0000-000000000018', 'bbbbbbbb-0000-0000-0000-000000000001', 'Orzonudeln', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
('dd000002-0000-0000-0000-000000000019', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tortellini', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
('dd000002-0000-0000-0000-000000000020', 'bbbbbbbb-0000-0000-0000-000000000001', 'Jasminreis', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
('dd000002-0000-0000-0000-000000000021', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gnocchi (frisch)', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
('dd000002-0000-0000-0000-000000000022', 'bbbbbbbb-0000-0000-0000-000000000001', 'Fladenbrot', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
-- Hülsenfrüchte
|
||||
('dd000002-0000-0000-0000-000000000023', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schwarze Bohnen (Dose)', true, 'cc000001-0000-0000-0000-000000000006'),
|
||||
-- Konserven
|
||||
('dd000002-0000-0000-0000-000000000024', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomaten-Polpa', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
('dd000002-0000-0000-0000-000000000025', 'bbbbbbbb-0000-0000-0000-000000000001', 'Chilipolpa', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
('dd000002-0000-0000-0000-000000000026', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getrocknete Tomaten', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
('dd000002-0000-0000-0000-000000000027', 'bbbbbbbb-0000-0000-0000-000000000001', 'Grüne Oliven', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
-- Gewürze & Kräuter
|
||||
('dd000002-0000-0000-0000-000000000028', 'bbbbbbbb-0000-0000-0000-000000000001', 'Petersilie (frisch)', false, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000029', 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum (frisch)', false, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000030', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schnittlauch', false, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000031', 'bbbbbbbb-0000-0000-0000-000000000001', 'Koriander (frisch)', false, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000032', 'bbbbbbbb-0000-0000-0000-000000000001', 'Scharfes Currypulver', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000033', 'bbbbbbbb-0000-0000-0000-000000000001', 'Kumin (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000034', 'bbbbbbbb-0000-0000-0000-000000000001', 'Koriander & Kumin', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000035', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung HelloMexico', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000036', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung Kartoffelknaller', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000037', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung HelloMediterraneo',true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
-- Tiefkühl
|
||||
('dd000002-0000-0000-0000-000000000038', 'bbbbbbbb-0000-0000-0000-000000000001', 'Flammkuchenteig', false, 'cc000001-0000-0000-0000-000000000013'),
|
||||
-- Saucen & Pasten
|
||||
('dd000002-0000-0000-0000-000000000039', 'bbbbbbbb-0000-0000-0000-000000000001', 'Balsamicocreme', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
('dd000002-0000-0000-0000-000000000040', 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikumpaste', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
('dd000002-0000-0000-0000-000000000041', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mayonnaise', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
-- Nüsse & Samen
|
||||
('dd000002-0000-0000-0000-000000000042', 'bbbbbbbb-0000-0000-0000-000000000001', 'Haselnusskerne', true, 'cc000001-0000-0000-0000-000000000011')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ─── Recipes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO recipe (id, household_id, name, serves, cook_time_min, effort, is_child_friendly) VALUES
|
||||
('ff000002-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Scharfer Auflauf mit Orzonudeln', 4, 30, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Tortellini mit Ricotta-Füllung', 4, 25, 'easy', true),
|
||||
('ff000002-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Knuspriger Flammkuchen mit Mozzarella', 4, 35, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Fruchtiges Tomatenrisotto mit Zitrone', 4, 30, 'medium', false),
|
||||
('ff000002-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Karotten-Hafer-Puffer', 4, 40, 'medium', false),
|
||||
('ff000002-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Überbackene Penne mit getrockneten Tomaten', 4, 50, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Chili sin Carne', 4, 40, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Gebratene Gnocchi mit Ofenzucchini', 4, 35, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Pasta nach Art Caponata', 4, 45, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Auflauf mit Halloumi und Aubergine', 4, 40, 'medium', false),
|
||||
('ff000002-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Buntes Ofengemüse mit Halloumi', 4, 30, 'easy', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ─── Recipe Ingredients ──────────────────────────────────────────────────────
|
||||
-- V100 ingredients referenced by name via subquery (gen_random_uuid IDs).
|
||||
-- New dd000002 ingredients referenced by fixed UUID.
|
||||
|
||||
-- 01 Scharfer Auflauf mit Orzonudeln
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 1, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000003', 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000024', 2, 'Dose', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Oliven (schwarz)'), 100, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000018', 300, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000012', 40, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000013', 100, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000001', 75, 'g', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 800, 'ml', 12);
|
||||
|
||||
-- 02 Tortellini mit Ricotta-Füllung
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000002', 400, 'g', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000029', 5, 'g', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000028', 3, 'g', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000030', 2, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000019', 800, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Sonnenblumenkerne'), 10, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 4, 'EL', 10);
|
||||
|
||||
-- 03 Knuspriger Flammkuchen mit Mozzarella
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000028', 5, 'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000030', 5, 'g', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000014', 200, 'g', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000038', 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Mozzarella'), 250, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000004', 2, 'Stück', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000005', 200, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000015', 200, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000001', 200, 'g', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 3, 'EL', 12),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Weißweinessig'), 2, 'EL', 13);
|
||||
|
||||
-- 04 Fruchtiges Tomatenrisotto mit Zitrone
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Karotten'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 2, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000012', 80, 'g', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 8, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Risottoreis (Arborio)'), 600, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Mozzarella'), 2, 'Stück', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000040', 24, 'ml', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000002', 400, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Weißweinessig'), 1, 'EL', 12);
|
||||
|
||||
-- 05 Karotten-Hafer-Puffer
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Kartoffeln'), 1200,'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000036', 4, 'g', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000004', 2, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000011', 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000031', 20, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000015', 150, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000041', 4, 'EL', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Karotten'), 2, 'Stück', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Haferflocken'), 50, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000017', 200, 'g', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000032', 2, 'g', 12),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000009', 150, 'g', 13),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Eier'), 2, 'Stück', 14),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Balsamico-Essig'), 2, 'EL', 15);
|
||||
|
||||
-- 06 Überbackene Penne mit getrockneten Tomaten
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Penne'), 500, 'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000030', 10, 'g', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000002', 300, 'g', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000013', 200, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Butter'), 5, 'g', 9);
|
||||
|
||||
-- 07 Chili sin Carne
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000020', 300, 'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 3, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000007', 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000008', 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000023', 2, 'Dose', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000035', 8, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 8, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000025', 2, 'Dose', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000039', 24, 'ml', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000031', 20, 'g', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000003', 2, 'Stück', 12),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Schmand'), 150, 'g', 13),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000028', 10, 'g', 14),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000010', 2, 'Stück', 15);
|
||||
|
||||
-- 08 Gebratene Gnocchi mit Ofenzucchini
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000037', 6, 'g', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000042', 40, 'g', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000012', 40, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000021', 800, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 9);
|
||||
|
||||
-- 09 Pasta nach Art Caponata
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000027', 120, 'g', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 1, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000025', 2, 'Dose', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Penne'), 500, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 1, 'Stück', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000012', 80, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000001', 75, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11);
|
||||
|
||||
-- 10 Auflauf mit Halloumi und Aubergine
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 4, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 4, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomaten'), 6, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000034', 4, 'g', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomatenmark'), 70, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000039', 24, 'ml', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000016', 400, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000029', 20, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000022', 1, 'Stück', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 5, 'EL', 11);
|
||||
|
||||
-- 11 Buntes Ofengemüse mit Halloumi
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Süßkartoffeln'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomaten'), 2, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000028', 20, 'g', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000010', 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 1, 'Stück', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000033', 2, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000003', 1, 'Stück', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000016', 500, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11);
|
||||
|
||||
-- ─── Recipe Steps ─────────────────────────────────────────────────────────────
|
||||
|
||||
-- 01 Scharfer Auflauf mit Orzonudeln
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 1, 'Backofen auf 200 °C (Grillfunktion) vorheizen. Zwiebeln und Knoblauch abziehen und fein hacken. Aubergine in ca. 2 cm große Würfel schneiden. Heiße Gemüsebrühe vorbereiten. Chilischote halbieren, Kerne entfernen und in feine Streifen schneiden (Achtung: scharf!).'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 2, 'Öl in einer großen Pfanne erhitzen. Zwiebeln und Knoblauch darin 2–3 Min. glasig andünsten. Orzonudeln und Aubergine zugeben und anbraten, bis das Öl vollständig aufgenommen ist. Brühe, Tomaten-Polpa und Chili zugeben, verrühren und ca. 10 Min. bei mittlerer Hitze köcheln lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 3, 'Zucchini längs halbieren und in 0,5 cm Scheiben schneiden. Oliven in Ringe schneiden. Zucchini und die Hälfte der Oliven zum Orzo geben und ca. 3 Min. mitkochen. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 4, 'Hartkäse fein reiben. Cheddar unter den Orzo heben und alles in eine Auflaufform füllen. Mit Hartkäse bestreuen und im Backofen 5–10 Min. gratinieren, bis der Käse goldbraun ist.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 5, 'Öl, Salz und Pfeffer in einer großen Schüssel vermengen. Rucola und restliche Oliven unterheben.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 6, 'Orzoauflauf auf Teller verteilen und mit dem Rucola-Oliven-Salat servieren.');
|
||||
|
||||
-- 02 Tortellini mit Ricotta-Füllung
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Knoblauch abziehen. Zucchini in 0,5 cm dünne Scheiben schneiden. Kirschtomaten halbieren. Gemüse in eine große Schüssel geben, Knoblauch hinzupressen, mit Olivenöl, Salz und Pfeffer vermengen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 2, 'Gemüse auf einem mit Backpapier belegten Blech verteilen und 18–20 Min. backen, bis die Zucchini leicht bräunt und die Tomaten fast geschmolzen sind. Währenddessen Kräuter abzupfen, Basilikum und Petersilie fein hacken, Schnittlauch in Röllchen schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 3, 'Großen Topf mit gesalzenem Wasser zum Kochen bringen. Frischkäse mit den gehackten Kräutern verrühren, mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 4, 'Sonnenblumenkerne in einer kleinen Pfanne ohne Fett bei mittlerer Hitze goldbraun rösten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 5, 'Tortellini in den letzten 3–4 Min. der Gemüse-Backzeit in das kochende Wasser geben und al dente garen. Abgießen, zurück in den Topf geben. Gebackenes Gemüse und 4 EL Kräuterfrischkäse unterheben.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 6, 'Tortellini auf Teller verteilen. Restlichen Kräuterfrischkäse als Kleckse darauf verteilen, mit Sonnenblumenkernen bestreuen und mit Basilikum dekorieren.');
|
||||
|
||||
-- 03 Knuspriger Flammkuchen mit Mozzarella
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Schnittlauch und Petersilie fein hacken und unter den Frischkäse heben. Flammkuchenteig auf einem mit Backpapier belegten Blech ausrollen und gleichmäßig mit dem Kräuterfrischkäse bestreichen (ca. 1 cm Rand frei lassen). Mit Salz und Pfeffer bestreuen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 2, 'Rote Zwiebeln abziehen, halbieren und in feine Streifen schneiden. Mozzarella in kleine Stücke zupfen. Flammkuchen mit Zwiebelstreifen belegen und Mozzarellastücke darauf verteilen. Auf der mittleren Schiene 13–15 Min. knusprig backen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 3, 'Gurke in lange, dünne Scheiben hobeln oder schneiden. Radieschen vierteln.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 4, 'Joghurt, Senf, Olivenöl, Weißweinessig, Salz und Pfeffer in einer großen Schüssel zu einem Dressing verrühren.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 5, 'Rucola, Gurkenstreifen und Radieschen in die Schüssel geben und unterheben. Bis zum Anrichten ziehen lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 6, 'Flammkuchen in Stücke schneiden und auf Teller verteilen. Mit dem Salat servieren.');
|
||||
|
||||
-- 04 Fruchtiges Tomatenrisotto mit Zitrone
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 1, '1200 ml Wasser erhitzen. Karotten schälen und grob reiben. Zwiebeln fein würfeln. Knoblauch in dünne Scheiben schneiden. Hartkäse fein reiben. Zitronenschale abreiben, Zitronen halbieren und entsaften. Gemüsebrühe im heißen Wasser auflösen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 2, 'Öl in einem großen Topf erhitzen. Zwiebeln und Knoblauch darin 2–3 Min. glasig andünsten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 3, 'Risottoreis zugeben und unter Rühren erhitzen, bis das Öl vollständig aufgenommen ist. Karotten und ein Drittel der Brühe zugeben und gut verrühren. Restliche Brühe nach und nach einrühren. Insgesamt ca. 20 Min. köcheln lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 4, 'Mozzarella in mundgerechte Stücke schneiden. Mit Basilikumpaste, Olivenöl, Weißweinessig, Salz, Pfeffer und 1 Prise Zucker marinieren und ziehen lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 5, 'Kirschtomaten und Hartkäse in den Risotto einrühren. Mit Zitronenabrieb und Zitronensaft abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 6, 'Risotto auf Teller verteilen und mit dem marinierten Basilikummozzarella toppen.');
|
||||
|
||||
-- 05 Karotten-Hafer-Puffer
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Kartoffeln ungeschält in Spalten (Wedges) schneiden. Auf einem mit Backpapier belegten Blech verteilen, mit Öl beträufeln, mit Gewürzmischung Kartoffelknaller, Salz und Pfeffer würzen. 20–25 Min. backen, bis die Wedges innen weich und außen knusprig sind.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 2, 'Gurke längs halbieren und in Halbmondscheiben schneiden. Äpfel entkernen und in dünne Halbmonde schneiden. Balsamicoessig, Öl und Senf zu einem Dressing verrühren, mit Salz und Pfeffer abschmecken. Gurke und Apfel unterheben und marinieren lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 3, 'Koriander fein hacken und mit Joghurt und Mayonnaise verrühren. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 4, 'Karotten schälen und grob raspeln. In einer großen Schüssel Karotten, Haferflocken, Eier, Tex-Mex-Käsemischung und Currypulver vermischen. Mit Salz und Pfeffer würzen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 5, 'Öl in einer großen Pfanne erhitzen. Karottenmischung mithilfe eines Esslöffels zu Puffern formen und leicht flach drücken. Von beiden Seiten je ca. 3 Min. goldbraun braten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 6, 'Feldsalat unter den Apfel-Gurken-Salat heben. Auf Tellern anrichten, Karottenpuffer und Kartoffelwedges dazu platzieren. Mit Korianderdip beträufeln und genießen.');
|
||||
|
||||
-- 06 Überbackene Penne mit getrockneten Tomaten
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Reichlich gesalzenes Wasser zum Kochen bringen. Penne 7–9 Min. bissfest garen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 2, 'Knoblauch abziehen und fein würfeln. Schnittlauch in Röllchen schneiden. Kirschtomaten halbieren. Getrocknete Tomaten grob zerkleinern.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 3, 'Frischkäse mit Knoblauch, Senf und dem Großteil des Schnittlauchs verrühren. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 4, 'Penne abgießen, dabei 100 ml Kochwasser auffangen. Penne zurück in den Topf geben. Frischkäsemischung und getrocknete Tomaten einrühren, bei Bedarf Kochwasser zugeben, bis eine cremige Konsistenz entsteht. Kirschtomaten unterheben.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 5, 'Penne-Mischung in eine mit Butter eingefettete Auflaufform füllen. Mit Cheddar bestreuen und im Backofen 6–7 Min. gratinieren.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 6, 'Auflauf auf Teller verteilen und mit restlichem Schnittlauch bestreuen.');
|
||||
|
||||
-- 07 Chili sin Carne
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 1, 'Jasminreis mit 600 ml heißem Wasser in einem kleinen Topf aufkochen. Bei niedriger Hitze ca. 10 Min. köcheln lassen, vom Herd nehmen und abgedeckt 10 Min. quellen lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 2, 'Knoblauch abziehen und in feine Streifen schneiden. Rote Zwiebeln halbieren und in Streifen schneiden. Paprika halbieren, entkernen und in Streifen schneiden. Schwarze Bohnen abgießen und kalt abspülen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 3, 'Öl in einem großen Topf erhitzen. Zwiebeln und Paprika 2–3 Min. anbraten. Knoblauch und Gewürzmischung HelloMexico zugeben und 1 Min. mitbraten. Mit Salz und Pfeffer würzen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 4, 'Schwarze Bohnen, Chilipolpa, Gemüsebrühe und Balsamicocreme zugeben. Chili 25–30 Min. bei niedriger Hitze köcheln lassen, bis die Paprika weich und das Chili cremig ist. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 5, 'Koriander und Petersilie fein hacken. Chilischote entkernen und in Streifen schneiden (Achtung: scharf!). Avocado halbieren, Stein entfernen und in Streifen schneiden. Schmand mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 6, 'Reis mit einer Gabel auflockern, Koriander unterheben und auf Teller verteilen. Chili daneben anrichten, mit Chili und Petersilie bestreuen. Mit Avocado und einem Klecks Schmand servieren.');
|
||||
|
||||
-- 08 Gebratene Gnocchi mit Ofenzucchini
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Zucchini in 0,5 cm dünne Scheiben schneiden. Auf einem mit Backpapier belegten Blech verteilen, mit Gewürzmischung HelloMediterraneo, Öl, Salz und Pfeffer würzen. Ca. 15 Min. backen, bis die Zucchini weich ist.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 2, 'Haselnusskerne in einer großen Pfanne ohne Fett bei mittlerer Hitze rösten, bis sie duften. Herausnehmen, abkühlen lassen und grob hacken. Pfanne beiseite stellen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 3, 'Knoblauch abziehen. Getrocknete Tomaten grob hacken und mit Frischkäse, Knoblauch und 200 ml Wasser in ein hohes Gefäß geben. Mit einem Pürierstab zu einer glatten Soße mixen. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 4, 'Öl in der Pfanne bei mittlerer Hitze erhitzen. Gnocchi darin 8–9 Min. anbraten, bis sie knusprig und leicht gebräunt sind.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 5, 'Soße zu den Gnocchi geben, alles vermengen und ca. 2 Min. einkochen lassen. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 6, 'Gnocchi auf Teller verteilen, mit gebackener Zucchini, geriebenem Hartkäse und Haselnusskernen toppen.');
|
||||
|
||||
-- 09 Pasta nach Art Caponata
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 1, 'Reichlich gesalzenes Wasser für die Pasta aufkochen. Getrocknete Tomaten und grüne Oliven grob hacken (Öl der Oliven auffangen). Knoblauch abziehen und fein hacken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 2, 'Aubergine in 1–2 cm Würfel schneiden. Rote Zwiebeln halbieren und in Streifen schneiden. Olivenöl in einer großen Pfanne stark erhitzen. Aubergine 3–4 Min. scharf anbraten. Zwiebeln, getrocknete Tomaten, Oliven und Knoblauch zugeben und 2 Min. mitbraten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 3, 'Hitze reduzieren, Chilipolpa zugeben und alles 10–12 Min. köcheln lassen, bis die Soße eingedickt und das Gemüse weich ist. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 4, 'Penne ca. 10 Min. bissfest garen und abgießen. Zitrone heiß abwaschen, Schale abreiben und in Spalten schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 5, 'Penne zur Soße in die Pfanne geben und gut vermengen. Mit Zitronenabrieb und Zitronensaft abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 6, 'Pasta in tiefen Tellern anrichten, mit geriebenem Hartkäse und Rucola bestreuen.');
|
||||
|
||||
-- 10 Auflauf mit Halloumi und Aubergine
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. 2 Knoblauchzehen mit dem Messerrücken andrücken und 15 Min. im Ofen rösten. Restlichen Knoblauch abziehen und fein hacken. Zwiebeln in Streifen schneiden. Tomaten und Auberginen in ca. 2 cm Würfel schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 2, 'Öl in einer großen Pfanne erhitzen. Zwiebeln und gehackten Knoblauch 3 Min. andünsten. Aubergine, Tomatenwürfel sowie Koriander & Kumin zugeben und kurz anbraten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 3, 'Gemüse mit 200 ml Wasser ablöschen. Tomatenmark und Balsamicocreme einrühren und ca. 10 Min. köcheln lassen. Mit Salz und Pfeffer abschmecken. Währenddessen Halloumi in 0,5 cm Scheiben schneiden und Basilikumblätter abzupfen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 4, 'Soße in eine große Auflaufform geben und Halloumischeiben darüber verteilen. Ca. 20 Min. im Ofen backen. Fladenbrot in 2 cm Scheiben schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 5, 'Geröstete Knoblauchzehen abziehen und fein hacken. Mit Olivenöl, Salz und Pfeffer verrühren. Knoblauchöl auf die Brotscheiben träufeln, auf ein Backblech legen und 5–10 Min. knusprig aufbacken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 6, 'Auflauf 2 Min. unter dem Grill bräunen, bis der Halloumi goldbraun ist. Mit Basilikumblättern bestreuen und mit dem Knoblauchbrot servieren.');
|
||||
|
||||
-- 11 Buntes Ofengemüse mit Halloumi
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Süßkartoffeln schälen und in 2 cm Würfel schneiden. Rote Zwiebeln halbieren und in ca. 1 cm Spalten schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 2, 'Süßkartoffelwürfel und Zwiebelspalten auf einem mit Backpapier belegten Blech verteilen, mit Salz und Pfeffer würzen. Ca. 25 Min. backen, bis die Süßkartoffeln weich sind.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 3, 'Tomaten halbieren und in Spalten schneiden. Petersilie fein hacken. Avocado würfeln. Die Hälfte der Petersilie und die Avocadowürfel zu den Tomaten geben und beiseitestellen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 4, 'Knoblauch und Chilischote fein hacken. Restliche Petersilie mit Kumin, Knoblauch, Chili (Achtung: scharf!), Zitronensaft und Olivenöl zu einem Chimichurri verrühren. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 5, 'Halloumi in ca. 3 cm Würfel schneiden. In einer Pfanne mit etwas Öl bei mittlerer Hitze rundherum 3–4 Min. goldbraun braten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 6, 'Geröstetes Gemüse und Zwiebeln in die Schüssel mit Tomaten und Avocado geben, vorsichtig vermengen. Auf Teller verteilen, mit Halloumiwürfeln toppen und Petersilien-Chimichurri darüberträufeln.');
|
||||
|
||||
-- ─── Recipe Tags ──────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO recipe_tag (recipe_id, tag_id) VALUES
|
||||
-- 01 Scharfer Auflauf
|
||||
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf
|
||||
-- 02 Tortellini
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000013'), -- Schnell
|
||||
-- 03 Flammkuchen
|
||||
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000015'), -- Flammkuchen
|
||||
-- 04 Tomatenrisotto
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000012'), -- Reis
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000013'), -- Schnell
|
||||
-- 05 Karotten-Hafer-Puffer
|
||||
('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000003'), -- Deutsch
|
||||
('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000009'), -- Eier
|
||||
-- 06 Überbackene Penne
|
||||
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf
|
||||
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln
|
||||
-- 07 Chili sin Carne
|
||||
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000006'), -- Mexikanisch
|
||||
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000008'), -- Hülsenfrüchte
|
||||
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000012'), -- Reis
|
||||
-- 08 Gnocchi
|
||||
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000013'), -- Schnell
|
||||
-- 09 Pasta Caponata
|
||||
('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln
|
||||
-- 10 Auflauf Halloumi
|
||||
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf
|
||||
-- 11 Buntes Ofengemüse
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000002'), -- Glutenfrei
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000014') -- Ofengericht
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -10,19 +10,17 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.notNullValue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -100,7 +98,7 @@ class AuthControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void signupShouldStoreSecurityContextInSession() throws Exception {
|
||||
void signupShouldDelegateSessionCreationToAuthService() throws Exception {
|
||||
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
|
||||
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
|
||||
|
||||
@@ -109,14 +107,13 @@ class AuthControllerTest {
|
||||
mockMvc.perform(post("/v1/auth/signup")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(request().sessionAttribute(
|
||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
||||
notNullValue()));
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginShouldStoreSecurityContextInSession() throws Exception {
|
||||
void loginShouldDelegateSessionCreationToAuthService() throws Exception {
|
||||
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
|
||||
var response = UserResponse.withHousehold(
|
||||
UUID.randomUUID(), "sarah@example.com", "Sarah",
|
||||
@@ -127,10 +124,9 @@ class AuthControllerTest {
|
||||
mockMvc.perform(post("/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(request().sessionAttribute(
|
||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
||||
notNullValue()));
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.AbstractIntegrationTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class SecurityConfigTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private WebApplicationContext context;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.webAppContextSetup(context)
|
||||
.apply(springSecurity())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inviteInfoEndpointIsAccessibleWithoutAuthentication() throws Exception {
|
||||
// 404 = unauthenticated request reached the service (ResourceNotFoundException), not 401
|
||||
mockMvc.perform(get("/v1/invites/ANYCODE"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void inviteAcceptEndpointIsAccessibleWithoutAuthentication() throws Exception {
|
||||
// 400 = validation error (empty body), but NOT 401 — proves the path is permitted
|
||||
mockMvc.perform(post("/v1/invites/ANYCODE/accept")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectedEndpointRequiresAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/v1/households/mine"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HouseholdRoleInterceptorTest {
|
||||
|
||||
@Mock private HouseholdResolver householdResolver;
|
||||
@Mock private HttpServletRequest request;
|
||||
@Mock private HttpServletResponse response;
|
||||
|
||||
@InjectMocks private HouseholdRoleInterceptor interceptor;
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(String email) {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(email, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowWhenUserHasRequiredRole() throws Exception {
|
||||
authenticateAs("planner@example.com");
|
||||
when(householdResolver.resolveRole("planner@example.com")).thenReturn("planner");
|
||||
|
||||
var handlerMethod = mock(HandlerMethod.class);
|
||||
var annotation = mock(RequiresHouseholdRole.class);
|
||||
when(annotation.value()).thenReturn("planner");
|
||||
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, handlerMethod);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowForbiddenWhenUserLacksRequiredRole() {
|
||||
authenticateAs("member@example.com");
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
var handlerMethod = mock(HandlerMethod.class);
|
||||
var annotation = mock(RequiresHouseholdRole.class);
|
||||
when(annotation.value()).thenReturn("planner");
|
||||
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
|
||||
|
||||
assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod))
|
||||
.isInstanceOf(ForbiddenException.class)
|
||||
.hasMessage("Insufficient permissions");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassThroughWhenNoAnnotation() throws Exception {
|
||||
var handlerMethod = mock(HandlerMethod.class);
|
||||
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(null);
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, handlerMethod);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassThroughWhenNotHandlerMethod() throws Exception {
|
||||
boolean result = interceptor.preHandle(request, response, new Object());
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.recipeapp.household;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.recipeapp.auth.AuthService;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.household.dto.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -15,10 +18,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
@@ -32,6 +37,9 @@ class HouseholdControllerTest {
|
||||
@Mock
|
||||
private HouseholdService householdService;
|
||||
|
||||
@Mock
|
||||
private AuthService authService;
|
||||
|
||||
@InjectMocks
|
||||
private HouseholdController householdController;
|
||||
|
||||
@@ -104,16 +112,119 @@ class HouseholdControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldReturn200() throws Exception {
|
||||
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
|
||||
void getActiveInviteShouldReturn200WithInvite() throws Exception {
|
||||
var response = new InviteResponse("ACTIVE12", "https://yourapp.com/join/ACTIVE12",
|
||||
Instant.now().plusSeconds(172800));
|
||||
|
||||
when(householdService.acceptInvite("tom@example.com", "ABC12XYZ")).thenReturn(response);
|
||||
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.of(response));
|
||||
|
||||
mockMvc.perform(get("/v1/households/mine/invites")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.data.inviteCode").value("ACTIVE12"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActiveInviteShouldReturn204WhenNoActiveInvite() throws Exception {
|
||||
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/v1/households/mine/invites")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteMemberShouldReturn204() throws Exception {
|
||||
var memberId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/v1/households/mine/members/" + memberId)
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(householdService).removeMember("sarah@example.com", memberId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchMemberRoleShouldReturn200() throws Exception {
|
||||
var memberId = UUID.randomUUID();
|
||||
var memberResponse = new MemberResponse(memberId, "Tom", "planner", Instant.now());
|
||||
var request = new ChangeRoleRequest("planner");
|
||||
|
||||
when(householdService.changeMemberRole("sarah@example.com", memberId, "planner"))
|
||||
.thenReturn(memberResponse);
|
||||
|
||||
mockMvc.perform(patch("/v1/households/mine/members/" + memberId)
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.data.role").value("planner"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getInviteInfoShouldReturn200WithHouseholdAndInviterName() throws Exception {
|
||||
var response = new InviteInfoResponse("Smith family", "Sarah");
|
||||
|
||||
when(householdService.getInviteInfo("ABC12XYZ")).thenReturn(response);
|
||||
|
||||
mockMvc.perform(get("/v1/invites/ABC12XYZ"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
|
||||
.andExpect(jsonPath("$.data.inviterName").value("Sarah"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getInviteInfoShouldReturn404WhenInvalid() throws Exception {
|
||||
when(householdService.getInviteInfo("BADTOKEN"))
|
||||
.thenThrow(new ResourceNotFoundException("Invite not found or invalid"));
|
||||
|
||||
mockMvc.perform(get("/v1/invites/BADTOKEN"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldReturn200AndCreateSession() throws Exception {
|
||||
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
|
||||
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
|
||||
|
||||
when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
|
||||
.thenReturn(response);
|
||||
|
||||
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
|
||||
.principal(() -> "tom@example.com"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
|
||||
.andExpect(jsonPath("$.data.role").value("member"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldReturn409WhenEmailAlreadyRegistered() throws Exception {
|
||||
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
|
||||
|
||||
when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
|
||||
.thenThrow(new ConflictException("Email already registered"));
|
||||
|
||||
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldReturn404WhenTokenInvalid() throws Exception {
|
||||
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
|
||||
|
||||
when(householdService.acceptInvite("BADTOKEN", "Tom", "tom@example.com", "secret123"))
|
||||
.thenThrow(new ResourceNotFoundException("Invite not found or invalid"));
|
||||
|
||||
mockMvc.perform(post("/v1/invites/BADTOKEN/accept")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,14 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -38,10 +41,16 @@ class HouseholdServiceTest {
|
||||
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
|
||||
@Mock private TagRepository tagRepository;
|
||||
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||
@Mock private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder;
|
||||
|
||||
@InjectMocks
|
||||
private HouseholdService householdService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(householdService, "baseUrl", "http://localhost:5173");
|
||||
}
|
||||
|
||||
private UserAccount testUser() {
|
||||
return new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||
}
|
||||
@@ -132,85 +141,164 @@ class HouseholdServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldAddUserAsMember() {
|
||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||
void createInviteShouldBuildShareUrlWithConfiguredBaseUrl() {
|
||||
var user = testUser();
|
||||
var household = new Household("Smith family", user);
|
||||
var member = new HouseholdMember(household, user, "planner");
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
InviteResponse result = householdService.createInvite("sarah@example.com");
|
||||
|
||||
assertThat(result.shareUrl()).startsWith("http://localhost:5173/join/");
|
||||
assertThat(result.shareUrl()).endsWith(result.inviteCode());
|
||||
}
|
||||
|
||||
// ── getInviteInfo ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getInviteInfoShouldReturnHouseholdNameAndInviterName() {
|
||||
var owner = testUser();
|
||||
var household = new Household("Smith family", owner);
|
||||
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
|
||||
invite.setInvitedBy(owner);
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
||||
when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
|
||||
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
AcceptInviteResponse result = householdService.acceptInvite("tom@example.com", "ABC12XYZ");
|
||||
InviteInfoResponse result = householdService.getInviteInfo("ABC12XYZ");
|
||||
|
||||
assertThat(result.householdName()).isEqualTo("Smith family");
|
||||
assertThat(result.role()).isEqualTo("member");
|
||||
assertThat(result.inviterName()).isEqualTo("Sarah");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldThrowWhenAlreadyInHousehold() {
|
||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||
var household = new Household("Other", user);
|
||||
var member = new HouseholdMember(household, user, "member");
|
||||
void getInviteInfoShouldThrow404WhenCodeNotFound() {
|
||||
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> householdService.getInviteInfo("INVALID"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getInviteInfoShouldThrow404WhenCodeExpired() {
|
||||
var owner = testUser();
|
||||
var household = new Household("Smith family", owner);
|
||||
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
|
||||
invite.setInvitedBy(owner);
|
||||
|
||||
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
|
||||
|
||||
assertThatThrownBy(() -> householdService.getInviteInfo("EXPIRED"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getInviteInfoShouldThrow404WhenCodeAlreadyUsed() {
|
||||
var owner = testUser();
|
||||
var household = new Household("Smith family", owner);
|
||||
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
|
||||
invite.setStatus("used");
|
||||
invite.setInvitedBy(owner);
|
||||
|
||||
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
|
||||
|
||||
assertThatThrownBy(() -> householdService.getInviteInfo("USED123"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getInviteInfoShouldThrow404WhenInviteIsInvalidated() {
|
||||
var owner = testUser();
|
||||
var household = new Household("Smith family", owner);
|
||||
var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400));
|
||||
invite.setInvitedBy(owner);
|
||||
invite.setInvalidatedAt(Instant.now()); // superseded by a new invite
|
||||
|
||||
when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite));
|
||||
|
||||
assertThatThrownBy(() -> householdService.getInviteInfo("SUPERSEDED"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── acceptInvite (new: creates account + joins) ───────────────────────────
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldCreateAccountAndAddAsMember() {
|
||||
var owner = testUser();
|
||||
var household = new Household("Smith family", owner);
|
||||
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
|
||||
invite.setInvitedBy(owner);
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(member));
|
||||
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||
when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
|
||||
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0));
|
||||
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
|
||||
when(passwordEncoder.encode("secret123")).thenReturn("hashed");
|
||||
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "ABC12XYZ"))
|
||||
AcceptInviteResponse result = householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123");
|
||||
|
||||
assertThat(result.householdName()).isEqualTo("Smith family");
|
||||
assertThat(result.role()).isEqualTo("member");
|
||||
verify(userAccountRepository).save(any(UserAccount.class));
|
||||
verify(householdMemberRepository).save(any(HouseholdMember.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldThrow409WhenEmailAlreadyRegistered() {
|
||||
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
|
||||
.isInstanceOf(ConflictException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldThrowWhenCodeExpired() {
|
||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||
void acceptInviteShouldThrow404WhenCodeExpired() {
|
||||
var owner = testUser();
|
||||
var household = new Household("Smith family", owner);
|
||||
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
||||
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
|
||||
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "EXPIRED"))
|
||||
.isInstanceOf(ValidationException.class);
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("EXPIRED", "Tom", "tom@example.com", "secret123"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldThrowWhenCodeAlreadyUsed() {
|
||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||
void acceptInviteShouldThrow404WhenCodeAlreadyUsed() {
|
||||
var owner = testUser();
|
||||
var household = new Household("Smith family", owner);
|
||||
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
|
||||
invite.setStatus("used");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
||||
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
|
||||
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123"))
|
||||
.isInstanceOf(ConflictException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldThrowWhenInviteNotFound() {
|
||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
||||
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "INVALID"))
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("USED123", "Tom", "tom@example.com", "secret123"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldThrowWhenUserNotFound() {
|
||||
when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty());
|
||||
void acceptInviteShouldThrow404WhenInviteNotFound() {
|
||||
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("unknown@example.com", "ABC12XYZ"))
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("INVALID", "Tom", "tom@example.com", "secret123"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteShouldThrow404WhenInviteIsInvalidated() {
|
||||
var owner = testUser();
|
||||
var household = new Household("Smith family", owner);
|
||||
var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400));
|
||||
invite.setInvalidatedAt(Instant.now()); // superseded by a new invite
|
||||
|
||||
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||
when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite));
|
||||
|
||||
assertThatThrownBy(() -> householdService.acceptInvite("SUPERSEDED", "Tom", "tom@example.com", "secret123"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@@ -223,6 +311,187 @@ class HouseholdServiceTest {
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── changeMemberRole ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void changeMemberRoleShouldUpdateRole() {
|
||||
var planner = testUser();
|
||||
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||
var household = new Household("Smith family", planner);
|
||||
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||
var targetMembership = new HouseholdMember(household, target, "member");
|
||||
var targetId = UUID.randomUUID();
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "planner");
|
||||
|
||||
assertThat(result.role()).isEqualTo("planner");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changeMemberRoleShouldBeIdempotentWhenRoleUnchanged() {
|
||||
var planner = testUser();
|
||||
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||
var household = new Household("Smith family", planner);
|
||||
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||
var targetMembership = new HouseholdMember(household, target, "member");
|
||||
var targetId = UUID.randomUUID();
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||
|
||||
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "member");
|
||||
|
||||
assertThat(result.role()).isEqualTo("member");
|
||||
verify(householdMemberRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changeMemberRoleShouldThrow409WhenDegradingLastPlanner() {
|
||||
var planner = testUser();
|
||||
var household = new Household("Smith family", planner);
|
||||
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||
var targetId = UUID.randomUUID();
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(plannerMembership));
|
||||
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
|
||||
|
||||
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", targetId, "member"))
|
||||
.isInstanceOf(ConflictException.class)
|
||||
.hasMessageContaining("last planner");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changeMemberRoleShouldThrow404WhenTargetNotInHousehold() {
|
||||
var planner = testUser();
|
||||
var household = new Household("Smith family", planner);
|
||||
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||
var unknownId = UUID.randomUUID();
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", unknownId, "planner"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── removeMember ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void removeMemberShouldDeleteMember() {
|
||||
var planner = testUser();
|
||||
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||
var household = new Household("Smith family", planner);
|
||||
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||
var targetMembership = new HouseholdMember(household, target, "member");
|
||||
var targetId = UUID.randomUUID();
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||
|
||||
householdService.removeMember("sarah@example.com", targetId);
|
||||
|
||||
verify(householdMemberRepository).delete(targetMembership);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeMemberShouldThrow409WhenPlannerTriesToRemoveSelf() {
|
||||
var planner = testUser();
|
||||
var household = new Household("Smith family", planner);
|
||||
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||
var plannerId = UUID.randomUUID();
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(plannerId))).thenReturn(Optional.of(plannerMembership));
|
||||
|
||||
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", plannerId))
|
||||
.isInstanceOf(ConflictException.class)
|
||||
.hasMessageContaining("cannot remove yourself");
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeMemberShouldThrow409WhenRemovingLastPlanner() {
|
||||
var planner = testUser();
|
||||
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||
var household = new Household("Smith family", planner);
|
||||
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||
var targetMembership = new HouseholdMember(household, target, "planner");
|
||||
var targetId = UUID.randomUUID();
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
|
||||
|
||||
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", targetId))
|
||||
.isInstanceOf(ConflictException.class)
|
||||
.hasMessageContaining("last planner");
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeMemberShouldThrow404WhenTargetNotInHousehold() {
|
||||
var planner = testUser();
|
||||
var household = new Household("Smith family", planner);
|
||||
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||
var unknownId = UUID.randomUUID();
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", unknownId))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── getActiveInvite ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getActiveInviteShouldReturnActiveInviteResponse() {
|
||||
var user = testUser();
|
||||
var household = new Household("Smith family", user);
|
||||
var member = new HouseholdMember(household, user, "planner");
|
||||
var invite = new HouseholdInvite(household, "ACTIVE123", Instant.now().plusSeconds(86400));
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
|
||||
|
||||
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().inviteCode()).isEqualTo("ACTIVE123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActiveInviteShouldReturnEmptyWhenNoActiveInvite() {
|
||||
var user = testUser();
|
||||
var household = new Household("Smith family", user);
|
||||
var member = new HouseholdMember(household, user, "planner");
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.empty());
|
||||
|
||||
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActiveInviteShouldReturnEmptyWhenExpired() {
|
||||
var user = testUser();
|
||||
var household = new Household("Smith family", user);
|
||||
var member = new HouseholdMember(household, user, "planner");
|
||||
var invite = new HouseholdInvite(household, "EXPIRED1", Instant.now().minusSeconds(3600));
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
|
||||
|
||||
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMembersShouldReturnAllMembers() {
|
||||
var user1 = testUser();
|
||||
@@ -256,4 +525,23 @@ class HouseholdServiceTest {
|
||||
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createInviteShouldInvalidatePreviousActiveInvite() {
|
||||
var user = testUser();
|
||||
var household = new Household("Smith family", user);
|
||||
var member = new HouseholdMember(household, user, "planner");
|
||||
var existingInvite = new HouseholdInvite(household, "OLD12345", Instant.now().plusSeconds(86400));
|
||||
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite));
|
||||
when(householdInviteRepository.saveAndFlush(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
|
||||
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
householdService.createInvite("sarah@example.com");
|
||||
|
||||
assertThat(existingInvite.getInvalidatedAt()).isNotNull();
|
||||
verify(householdInviteRepository).saveAndFlush(existingInvite);
|
||||
verify(householdInviteRepository).save(any(HouseholdInvite.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class PlanningServiceTest {
|
||||
}
|
||||
|
||||
private Recipe testRecipe(Household household, String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
@@ -443,4 +443,93 @@ class PlanningServiceTest {
|
||||
assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── Variety preview ──
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldReturnScoreDeltaForDifferentRecipe() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var planId = plan.getId();
|
||||
|
||||
// Plan already has one slot (Mon) with Spaghetti
|
||||
var existingRecipe = testRecipe(household, "Spaghetti");
|
||||
var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
// Candidate is Lachsfilet (different recipe, no shared tags/ingredients)
|
||||
var candidate = testRecipe(household, "Lachsfilet");
|
||||
var candidateId = candidate.getId();
|
||||
|
||||
when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan));
|
||||
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(candidateId, HOUSEHOLD_ID))
|
||||
.thenReturn(Optional.of(candidate));
|
||||
when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty());
|
||||
when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
var result = planningService.getVarietyPreview(HOUSEHOLD_ID, planId, candidateId, WEEK_START.plusDays(1));
|
||||
|
||||
// 1 existing slot with no conflicts → currentScore = 10.0
|
||||
// Adding a different recipe with no tags/ingredients → projectedScore = 10.0, delta = 0
|
||||
assertThat(result.currentScore()).isEqualTo(10.0);
|
||||
assertThat(result.projectedScore()).isEqualTo(10.0);
|
||||
assertThat(result.scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldReturnNegativeDeltaForDuplicateRecipe() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var planId = plan.getId();
|
||||
|
||||
// Plan already has Spaghetti on Mon
|
||||
var existingRecipe = testRecipe(household, "Spaghetti");
|
||||
var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
// Candidate is the same Spaghetti recipe → triggers duplicate penalty (wPlanDuplicate = 2.0)
|
||||
when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan));
|
||||
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(existingRecipe.getId(), HOUSEHOLD_ID))
|
||||
.thenReturn(Optional.of(existingRecipe));
|
||||
when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty());
|
||||
when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
var result = planningService.getVarietyPreview(
|
||||
HOUSEHOLD_ID, planId, existingRecipe.getId(), WEEK_START.plusDays(1));
|
||||
|
||||
// currentScore = 10.0 (1 slot, no conflicts)
|
||||
// projectedScore = 10.0 - 1 * 2.0 (duplicate penalty) = 8.0
|
||||
assertThat(result.currentScore()).isEqualTo(10.0);
|
||||
assertThat(result.projectedScore()).isEqualTo(8.0);
|
||||
assertThat(result.scoreDelta()).isEqualTo(-2.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldThrowWhenPlanNotFound() {
|
||||
var planId = UUID.randomUUID();
|
||||
when(weekPlanRepository.findById(planId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> planningService.getVarietyPreview(
|
||||
HOUSEHOLD_ID, planId, UUID.randomUUID(), WEEK_START))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldThrowWhenRecipeNotFound() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var recipeId = UUID.randomUUID();
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, HOUSEHOLD_ID))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> planningService.getVarietyPreview(
|
||||
HOUSEHOLD_ID, plan.getId(), recipeId, WEEK_START))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class SuggestionsTest {
|
||||
}
|
||||
|
||||
private Recipe createRecipe(String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true);
|
||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
@@ -165,7 +165,7 @@ class SuggestionsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyPlanWithRecipesShouldReturnAllWithPerfectScore() {
|
||||
void emptyPlanWithRecipesShouldReturnAllWithZeroDelta() {
|
||||
var plan = createPlan();
|
||||
var r1 = createRecipe("Pasta");
|
||||
var r2 = createRecipe("Salad");
|
||||
@@ -179,8 +179,12 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(3);
|
||||
assertThat(result.suggestions()).allSatisfy(s ->
|
||||
assertThat(s.simulatedScore()).isEqualTo(10.0));
|
||||
// Empty plan → currentScore = 10.0; no penalties → scoreDelta = 0.0 for all
|
||||
// hasConflict = (scoreDelta < 0) = false for neutral recipes
|
||||
assertThat(result.suggestions()).allSatisfy(s -> {
|
||||
assertThat(s.scoreDelta()).isEqualTo(0.0);
|
||||
assertThat(s.hasConflict()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -204,6 +208,28 @@ class SuggestionsTest {
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void topNZeroShouldReturnEmptyList() {
|
||||
var plan = createPlan();
|
||||
stubPlan(plan);
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 0);
|
||||
|
||||
assertThat(result.suggestions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void topNNegativeShouldReturnEmptyList() {
|
||||
var plan = createPlan();
|
||||
stubPlan(plan);
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), -1);
|
||||
|
||||
assertThat(result.suggestions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleCandidateShouldReturnOne() {
|
||||
var plan = createPlan();
|
||||
@@ -221,6 +247,148 @@ class SuggestionsTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Category 1b: scoreDelta and hasConflict
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@Nested
|
||||
class ScoreDeltaAndHasConflict {
|
||||
|
||||
@Test
|
||||
void recipeWithZeroDeltaOnEmptyPlanShouldNotHaveConflict() {
|
||||
// Empty plan → currentScore = 10.0. Clean recipe → simulatedScore = 10.0.
|
||||
// scoreDelta = 0.0. No worsening → hasConflict = false.
|
||||
var plan = createPlan();
|
||||
var recipe = createRecipe("Clean Recipe");
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(recipe);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
var item = result.suggestions().getFirst();
|
||||
assertThat(item.scoreDelta()).isEqualTo(0.0);
|
||||
assertThat(item.hasConflict()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipeWithTagConflictShouldHaveNegativeDeltaAndHasConflict() {
|
||||
// Existing slot Mon=Monday Pasta (cuisine tag). Adding Tue=More Pasta → tag repeat penalty (-1.5).
|
||||
// currentScore = 10.0 (1 slot, no consecutive). simulatedScore = 10.0 - 1.5 = 8.5.
|
||||
// scoreDelta = -1.5, hasConflict = true.
|
||||
var plan = createPlan();
|
||||
var pastaTag = createTag("Pasta", "cuisine");
|
||||
var existingRecipe = createRecipe("Monday Pasta");
|
||||
addTag(existingRecipe, pastaTag);
|
||||
addSlot(plan, existingRecipe, MONDAY);
|
||||
|
||||
var candidate = createRecipe("More Pasta");
|
||||
addTag(candidate, pastaTag);
|
||||
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(existingRecipe, candidate);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
var item = result.suggestions().getFirst();
|
||||
assertThat(item.scoreDelta()).isEqualTo(-1.5);
|
||||
assertThat(item.hasConflict()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipeWithIngredientConflictShouldHaveNegativeDeltaAndHasConflict() {
|
||||
// Existing slot Mon=Tomato Soup (tomato ingredient). Adding Tue=Tomato Pasta → overlap (-0.3).
|
||||
// currentScore = 10.0, simulatedScore = 9.7, scoreDelta = -0.3, hasConflict = true.
|
||||
var plan = createPlan();
|
||||
var tomato = createIngredient("Tomatoes", false);
|
||||
var existingRecipe = createRecipe("Tomato Soup");
|
||||
addIngredient(existingRecipe, tomato);
|
||||
addSlot(plan, existingRecipe, MONDAY);
|
||||
|
||||
var candidate = createRecipe("Tomato Pasta");
|
||||
addIngredient(candidate, tomato);
|
||||
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(existingRecipe, candidate);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
var item = result.suggestions().getFirst();
|
||||
assertThat(item.scoreDelta()).isCloseTo(-0.3, within(0.001));
|
||||
assertThat(item.hasConflict()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void swappingExistingSlotForCleanRecipeShouldHavePositiveDelta() {
|
||||
// Plan has Mon=ItalianA, Tue=ItalianB → consecutive cuisine tag repeat → currentScore = 8.5
|
||||
// Asking for suggestions for Mon (swap scenario).
|
||||
// CleanRecipe (no Italian tag) → correct simulation: [Mon:CleanRecipe, Tue:ItalianB] → no repeat → 10.0
|
||||
// scoreDelta = +1.5 → hasConflict = false
|
||||
var plan = createPlan();
|
||||
var italianTag = createTag("Italienisch", "cuisine");
|
||||
var italianA = createRecipe("Spaghetti Carbonara");
|
||||
addTag(italianA, italianTag);
|
||||
addSlot(plan, italianA, MONDAY);
|
||||
var italianB = createRecipe("Penne Arrabiata");
|
||||
addTag(italianB, italianTag);
|
||||
addSlot(plan, italianB, MONDAY.plusDays(1));
|
||||
var cleanRecipe = createRecipe("Grillhähnchen");
|
||||
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(italianA, italianB, cleanRecipe);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
var item = result.suggestions().getFirst();
|
||||
assertThat(item.recipe().name()).isEqualTo("Grillhähnchen");
|
||||
assertThat(item.scoreDelta()).isCloseTo(1.5, within(0.001));
|
||||
assertThat(item.hasConflict()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void scoreDeltaIsSortedDescendingCleanBeforeConflicting() {
|
||||
// Clean recipe (scoreDelta = 0.0) should rank above conflicting (scoreDelta < 0).
|
||||
var plan = createPlan();
|
||||
var pastaTag = createTag("Pasta", "cuisine");
|
||||
var existingRecipe = createRecipe("Monday Pasta");
|
||||
addTag(existingRecipe, pastaTag);
|
||||
addSlot(plan, existingRecipe, MONDAY);
|
||||
|
||||
var cleanRecipe = createRecipe("Plain Rice");
|
||||
var conflictingRecipe = createRecipe("More Pasta");
|
||||
addTag(conflictingRecipe, pastaTag);
|
||||
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(existingRecipe, cleanRecipe, conflictingRecipe);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice");
|
||||
assertThat(result.suggestions().get(0).scoreDelta()).isEqualTo(0.0);
|
||||
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("More Pasta");
|
||||
assertThat(result.suggestions().get(1).scoreDelta()).isEqualTo(-1.5);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Category 2: Exclusion of In-Plan Recipes
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -402,8 +570,8 @@ class SuggestionsTest {
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
// B should rank higher (no tag penalty)
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice");
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -428,8 +596,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isEqualTo(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isEqualTo(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -453,8 +621,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
// No penalty — dietary not tracked
|
||||
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0);
|
||||
// No penalty — dietary not tracked → scoreDelta = 0.0
|
||||
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,8 +660,8 @@ class SuggestionsTest {
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Mushroom Risotto");
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -519,7 +687,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0);
|
||||
// Staples ignored → scoreDelta = 0.0
|
||||
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,8 +716,8 @@ class SuggestionsTest {
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Stir Fry");
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -566,7 +735,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0);
|
||||
// No penalty → scoreDelta = 0.0
|
||||
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +801,7 @@ class SuggestionsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rankingOrderShouldBeBySimulatedScoreDescending() {
|
||||
void rankingOrderShouldBeByScoreDeltaDescending() {
|
||||
var plan = createPlan();
|
||||
var pastaTag = createTag("Pasta", "cuisine");
|
||||
var tomato = createIngredient("Tomatoes", false);
|
||||
@@ -666,11 +836,11 @@ class SuggestionsTest {
|
||||
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Dry Pasta");
|
||||
assertThat(result.suggestions().get(2).recipe().name()).isEqualTo("Tomato Pasta");
|
||||
|
||||
// Verify scores are strictly descending
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(1).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(2).simulatedScore());
|
||||
// Verify scoreDelta is strictly descending
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(1).scoreDelta());
|
||||
assertThat(result.suggestions().get(1).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(2).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -688,8 +858,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isEqualTo(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isEqualTo(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,7 +896,7 @@ class SuggestionsTest {
|
||||
addTag(c1, pastaTag);
|
||||
addIngredient(c1, tomato);
|
||||
|
||||
// Candidate 2: Chicken only → protein repeat with Mon
|
||||
// Candidate 2: Chicken only → protein repeat with Mon (Mon→Wed not consecutive)
|
||||
var c2 = createRecipe("Chicken Salad");
|
||||
addTag(c2, chickenTag);
|
||||
|
||||
@@ -745,7 +915,7 @@ class SuggestionsTest {
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(monRecipe, tueRecipe, c1, c2, c3, c4, c5);
|
||||
// c1 was cooked recently
|
||||
// c1 was cooked recently (within 14-day window)
|
||||
stubCookingLogs(createCookingLog(c1, MONDAY.minusDays(3)));
|
||||
|
||||
// Slot date = Wednesday (adjacent to Tuesday)
|
||||
@@ -754,19 +924,20 @@ class SuggestionsTest {
|
||||
|
||||
assertThat(result.suggestions()).hasSize(5);
|
||||
|
||||
// c2, c4, c5 all score 10.0 (no penalties — Chicken Mon→Wed not consecutive)
|
||||
// currentScore = 10.0 (Mon+Tue plan: no consecutive conflicts between just those 2 slots)
|
||||
// c2, c4, c5: no additional conflicts → scoreDelta = 0.0
|
||||
var topThree = result.suggestions().subList(0, 3);
|
||||
assertThat(topThree).extracting(s -> s.recipe().name())
|
||||
.containsExactlyInAnyOrder("Chicken Salad", "Mushroom Risotto", "Lentil Soup");
|
||||
assertThat(topThree).allSatisfy(s -> assertThat(s.simulatedScore()).isEqualTo(10.0));
|
||||
assertThat(topThree).allSatisfy(s -> assertThat(s.scoreDelta()).isEqualTo(0.0));
|
||||
|
||||
// c3 (Cheese Omelette) has ingredient overlap Tue→Wed: -0.3
|
||||
// c3 (Cheese Omelette) has ingredient overlap Tue→Wed: scoreDelta = -0.3
|
||||
assertThat(result.suggestions().get(3).recipe().name()).isEqualTo("Cheese Omelette");
|
||||
assertThat(result.suggestions().get(3).simulatedScore()).isCloseTo(9.7, within(0.001));
|
||||
assertThat(result.suggestions().get(3).scoreDelta()).isCloseTo(-0.3, within(0.001));
|
||||
|
||||
// c1 (Tomato Spaghetti) has recent repeat: -1.0
|
||||
// c1 (Tomato Spaghetti) has recent repeat: scoreDelta = -1.0
|
||||
assertThat(result.suggestions().get(4).recipe().name()).isEqualTo("Tomato Spaghetti");
|
||||
assertThat(result.suggestions().get(4).simulatedScore()).isEqualTo(9.0);
|
||||
assertThat(result.suggestions().get(4).scoreDelta()).isEqualTo(-1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -800,7 +971,7 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1),
|
||||
List.of("Quick meal"), 5);
|
||||
|
||||
// Only quick recipes, ranked by variety
|
||||
// Only quick recipes, ranked by scoreDelta desc
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Quick Salad");
|
||||
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Quick Pasta");
|
||||
@@ -815,7 +986,7 @@ class SuggestionsTest {
|
||||
class EdgeCases {
|
||||
|
||||
@Test
|
||||
void recipeWithNoTagsOrIngredientsShouldGetPerfectScore() {
|
||||
void recipeWithNoTagsOrIngredientsShouldGetZeroDelta() {
|
||||
var plan = createPlan();
|
||||
var existingRecipe = createRecipe("Existing");
|
||||
addSlot(plan, existingRecipe, MONDAY);
|
||||
@@ -832,7 +1003,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0);
|
||||
// No conflicts → scoreDelta = 0.0
|
||||
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -69,7 +69,7 @@ class VarietyScoreTest {
|
||||
}
|
||||
|
||||
private Recipe createRecipe(String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true);
|
||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.recipeapp.planning;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.common.HouseholdRoleInterceptor;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.planning.dto.*;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -13,6 +15,8 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
@@ -49,6 +53,11 @@ class WeekPlanControllerTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWeekPlanShouldReturn200() throws Exception {
|
||||
var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of());
|
||||
@@ -153,7 +162,7 @@ class WeekPlanControllerTest {
|
||||
@Test
|
||||
void getSuggestionsShouldReturn200() throws Exception {
|
||||
var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null);
|
||||
var item = new SuggestionResponse.SuggestionItem(recipe, 9.5);
|
||||
var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
|
||||
var response = new SuggestionResponse(List.of(item));
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
@@ -166,7 +175,8 @@ class WeekPlanControllerTest {
|
||||
.param("slotDate", "2026-04-08"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry"))
|
||||
.andExpect(jsonPath("$.suggestions[0].simulatedScore").value(9.5));
|
||||
.andExpect(jsonPath("$.suggestions[0].scoreDelta").value(1.5))
|
||||
.andExpect(jsonPath("$.suggestions[0].hasConflict").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -182,4 +192,79 @@ class WeekPlanControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.score").value(7.5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldReturn200() throws Exception {
|
||||
var recipeId = UUID.randomUUID();
|
||||
var response = new VarietyPreviewResponse(8.0, 9.0, 1.0);
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(planningService.getVarietyPreview(HOUSEHOLD_ID, PLAN_ID, recipeId, WEEK_START.plusDays(2)))
|
||||
.thenReturn(response);
|
||||
|
||||
mockMvc.perform(get("/v1/week-plans/{planId}/variety-preview", PLAN_ID)
|
||||
.principal(() -> "sarah@example.com")
|
||||
.param("recipeId", recipeId.toString())
|
||||
.param("date", "2026-04-08"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.currentScore").value(8.0))
|
||||
.andExpect(jsonPath("$.projectedScore").value(9.0))
|
||||
.andExpect(jsonPath("$.scoreDelta").value(1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addSlotShouldReturn403ForMemberRole() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("member@example.com", null));
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
|
||||
.build();
|
||||
|
||||
var recipeId = UUID.randomUUID();
|
||||
mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/slots", PLAN_ID)
|
||||
.principal(() -> "member@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new CreateSlotRequest(WEEK_START.plusDays(1), recipeId))))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSlotShouldReturn403ForMemberRole() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("member@example.com", null));
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
|
||||
.build();
|
||||
|
||||
var recipeId = UUID.randomUUID();
|
||||
mockMvcWithInterceptor.perform(patch("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
|
||||
.principal(() -> "member@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(new UpdateSlotRequest(recipeId))))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSlotShouldReturn403ForMemberRole() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("member@example.com", null));
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
|
||||
.build();
|
||||
|
||||
mockMvcWithInterceptor.perform(delete("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
|
||||
.principal(() -> "member@example.com"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressToPreview_acceptsJpegInput() throws Exception {
|
||||
String dataUri = makeJpegDataUri(800, 600);
|
||||
String result = compressor.compressToPreview(dataUri);
|
||||
assertThat(result).startsWith("data:image/jpeg;base64,");
|
||||
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(400);
|
||||
}
|
||||
|
||||
// ── 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());
|
||||
}
|
||||
|
||||
private String makeJpegDataUri(int width, int height) throws Exception {
|
||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
java.awt.Graphics2D g = img.createGraphics();
|
||||
g.setColor(java.awt.Color.ORANGE);
|
||||
g.fillRect(0, 0, width, height);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "jpeg", bos);
|
||||
return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
}
|
||||
}
|
||||
@@ -46,14 +46,15 @@ class RecipeControllerTest {
|
||||
|
||||
@Test
|
||||
void listRecipesShouldReturn200WithPagination() throws Exception {
|
||||
var tag = new TagResponse(UUID.randomUUID(), "Rind", "protein");
|
||||
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
|
||||
(short) 4, (short) 45, "medium", true, null);
|
||||
(short) 4, (short) 45, "medium", "https://example.com/img.jpg", List.of(tag));
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(),
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(),
|
||||
isNull(), eq(20), eq(0)))
|
||||
.thenReturn(List.of(summary));
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull()))
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull()))
|
||||
.thenReturn(1L);
|
||||
|
||||
mockMvc.perform(get("/v1/recipes")
|
||||
@@ -62,6 +63,9 @@ class RecipeControllerTest {
|
||||
.param("offset", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data[0].name").value("Spaghetti Bolognese"))
|
||||
.andExpect(jsonPath("$.data[0].heroImageUrl").value("https://example.com/img.jpg"))
|
||||
.andExpect(jsonPath("$.data[0].tags[0].name").value("Rind"))
|
||||
.andExpect(jsonPath("$.data[0].tags[0].tagType").value("protein"))
|
||||
.andExpect(jsonPath("$.meta.pagination.total").value(1))
|
||||
.andExpect(jsonPath("$.meta.pagination.hasMore").value(false));
|
||||
}
|
||||
@@ -69,17 +73,16 @@ class RecipeControllerTest {
|
||||
@Test
|
||||
void listRecipesWithFiltersShouldPassParams() throws Exception {
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true),
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"),
|
||||
eq(30), eq("-cookTimeMin"), eq(10), eq(5)))
|
||||
.thenReturn(List.of());
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), eq(30)))
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(30)))
|
||||
.thenReturn(0L);
|
||||
|
||||
mockMvc.perform(get("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.param("search", "pasta")
|
||||
.param("effort", "easy")
|
||||
.param("isChildFriendly", "true")
|
||||
.param("cookTimeMin.lte", "30")
|
||||
.param("sort", "-cookTimeMin")
|
||||
.param("limit", "10")
|
||||
@@ -162,10 +165,50 @@ class RecipeControllerTest {
|
||||
verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithOversizedHeroImageShouldReturn400() throws Exception {
|
||||
String heroImageUrl = "data:image/jpeg;base64," + "A".repeat(7_000_000);
|
||||
String body = "{\"name\":\"Test\",\"effort\":\"easy\",\"tagIds\":[\"" + UUID.randomUUID() + "\"]," +
|
||||
"\"ingredients\":[{\"quantity\":1,\"unit\":\"g\",\"newIngredientName\":\"x\",\"sortOrder\":0}]," +
|
||||
"\"heroImageUrl\":\"" + heroImageUrl + "\"}";
|
||||
|
||||
mockMvc.perform(post("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithEmptyIngredientsListShouldReturn400() throws Exception {
|
||||
var body = """
|
||||
{"name":"Test","effort":"easy","tagIds":["%s"],"ingredients":[]}
|
||||
""".formatted(UUID.randomUUID());
|
||||
|
||||
mockMvc.perform(post("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithCapitalisedEffortShouldReturn400() throws Exception {
|
||||
var body = """
|
||||
{"name":"Test","effort":"Easy","tagIds":["%s"],"ingredients":[{"quantity":1,"unit":"g","newIngredientName":"x","sortOrder":0}]}
|
||||
""".formatted(UUID.randomUUID());
|
||||
|
||||
mockMvc.perform(post("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
private RecipeCreateRequest sampleCreateRequest() {
|
||||
var ingredientId = UUID.randomUUID();
|
||||
return new RecipeCreateRequest(
|
||||
"Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
|
||||
"Spaghetti Bolognese", 4, 45, "medium", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
ingredientId, null, new BigDecimal("400"), "g", (short) 1)),
|
||||
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")),
|
||||
@@ -175,7 +218,7 @@ class RecipeControllerTest {
|
||||
private RecipeDetailResponse sampleDetail() {
|
||||
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta");
|
||||
return new RecipeDetailResponse(
|
||||
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
|
||||
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", null,
|
||||
List.of(new RecipeDetailResponse.IngredientItem(
|
||||
UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)),
|
||||
List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")),
|
||||
|
||||
@@ -27,6 +27,7 @@ class RecipeServiceTest {
|
||||
@Mock private TagRepository tagRepository;
|
||||
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
|
||||
@Mock private HouseholdRepository householdRepository;
|
||||
@Mock private ImageCompressor imageCompressor;
|
||||
|
||||
@InjectMocks private RecipeService recipeService;
|
||||
|
||||
@@ -43,7 +44,7 @@ class RecipeServiceTest {
|
||||
}
|
||||
|
||||
private Recipe testRecipe(Household household) {
|
||||
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true);
|
||||
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium");
|
||||
try {
|
||||
var field = Recipe.class.getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
@@ -126,7 +127,7 @@ class RecipeServiceTest {
|
||||
});
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
|
||||
"Spaghetti Bolognese", 4, 45, "medium", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
ingredient.getId(), null, new BigDecimal("400"), "g", (short) 1)),
|
||||
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")),
|
||||
@@ -166,7 +167,7 @@ class RecipeServiceTest {
|
||||
});
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Carbonara", (short) 2, (short) 30, "medium", false, null,
|
||||
"Carbonara", 2, 30, "medium", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
null, "pancetta", new BigDecimal("100"), "g", (short) 1)),
|
||||
List.of(),
|
||||
@@ -192,7 +193,7 @@ class RecipeServiceTest {
|
||||
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Chicken Rice", (short) 3, (short) 25, "easy", true, null,
|
||||
"Chicken Rice", 3, 25, "easy", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
ingredient.getId(), null, new BigDecimal("300"), "g", (short) 1)),
|
||||
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Cook rice.")),
|
||||
@@ -450,7 +451,7 @@ class RecipeServiceTest {
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty());
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Test", (short) 2, (short) 15, "easy", false, null,
|
||||
"Test", 2, 15, "easy", null,
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request))
|
||||
@@ -466,7 +467,7 @@ class RecipeServiceTest {
|
||||
when(ingredientRepository.findById(ingredientId)).thenReturn(Optional.empty());
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Test", (short) 2, (short) 15, "easy", false, null,
|
||||
"Test", 2, 15, "easy", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
ingredientId, null, new BigDecimal("100"), "g", (short) 1)),
|
||||
List.of(), List.of());
|
||||
@@ -491,7 +492,7 @@ class RecipeServiceTest {
|
||||
});
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Simple", (short) 1, (short) 5, "easy", false, null,
|
||||
"Simple", 1, 5, "easy", null,
|
||||
null, null, null);
|
||||
|
||||
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
|
||||
@@ -518,13 +519,36 @@ class RecipeServiceTest {
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Updated", (short) 2, (short) 20, "easy", false, null,
|
||||
"Updated", 2, 20, "easy", null,
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
assertThatThrownBy(() -> recipeService.updateRecipe(HOUSEHOLD_ID, id, request))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithNullServesAndCookTimeShouldStoreZero() {
|
||||
var household = testHousehold();
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> {
|
||||
Recipe r = i.getArgument(0);
|
||||
try {
|
||||
var field = Recipe.class.getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
field.set(r, UUID.randomUUID());
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
return r;
|
||||
});
|
||||
|
||||
var request = new RecipeCreateRequest("Soup", null, null, "easy", null,
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
|
||||
|
||||
assertThat(result.serves()).isEqualTo((short) 0);
|
||||
assertThat(result.cookTimeMin()).isEqualTo((short) 0);
|
||||
}
|
||||
|
||||
// ── Tag/Category edge cases ──
|
||||
|
||||
@Test
|
||||
@@ -547,6 +571,33 @@ class RecipeServiceTest {
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithDisallowedImageTypeShouldThrowValidationException() {
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(testHousehold()));
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Test", null, null, "easy", "data:application/pdf;base64,abc",
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request))
|
||||
.isInstanceOf(com.recipeapp.common.ValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithAllowedImageTypeShouldNotThrow() {
|
||||
var household = testHousehold();
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
// "abc" is not valid base64 for a real image; ImageCompressor will return null for the
|
||||
// preview, but validateHeroImageUrl() should pass for a well-formed data URI prefix.
|
||||
var request = new RecipeCreateRequest(
|
||||
"Test", null, null, "easy", "data:image/jpeg;base64,abc",
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
assertThatNoException().isThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTagsShouldReturnEmptyList() {
|
||||
when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of());
|
||||
@@ -555,4 +606,30 @@ class RecipeServiceTest {
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeShouldStoreNullPreviewWhenCompressorReturnsNull() {
|
||||
var household = testHousehold();
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||
when(imageCompressor.compressToPreview(any())).thenReturn(null);
|
||||
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", "data:image/jpeg;base64,abc",
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
|
||||
|
||||
assertThat(result.id()).isNotNull();
|
||||
// verify the recipe was saved without a preview (compressor returned null)
|
||||
verify(recipeRepository).save(argThat(r -> r.getHeroImagePreview() == null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.recipeapp.shopping;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.common.HouseholdRoleInterceptor;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -12,10 +14,13 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -48,13 +53,46 @@ class ShoppingListControllerTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturn200() throws Exception {
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 3, List.of());
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(response);
|
||||
|
||||
mockMvc.perform(get("/v1/shopping-list")
|
||||
.param("weekStart", "2026-04-06")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(LIST_ID.toString()))
|
||||
.andExpect(jsonPath("$.filteredStaplesCount").value(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturn404WhenNoListExists() throws Exception {
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(get("/v1/shopping-list")
|
||||
.param("weekStart", "2026-04-06")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldReturn201() throws Exception {
|
||||
var recipeId = UUID.randomUUID();
|
||||
var item = new ShoppingListItemResponse(
|
||||
ITEM_ID, UUID.randomUUID(), "Tomatoes",
|
||||
new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"),
|
||||
new BigDecimal("4.00"), "pcs", false, null, List.of(UUID.randomUUID()));
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of(item));
|
||||
new BigDecimal("4.00"), "pcs", false, null,
|
||||
List.of(new ShoppingListItemResponse.RecipeRef(recipeId, "Spaghetti")));
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 2, List.of(item));
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response);
|
||||
@@ -68,7 +106,7 @@ class ShoppingListControllerTest {
|
||||
|
||||
@Test
|
||||
void getShoppingListShouldReturn200() throws Exception {
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of());
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 0, List.of());
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response);
|
||||
@@ -84,7 +122,8 @@ class ShoppingListControllerTest {
|
||||
void checkItemShouldReturn200() throws Exception {
|
||||
var response = new ShoppingListItemResponse(
|
||||
ITEM_ID, UUID.randomUUID(), "Tomatoes", null,
|
||||
new BigDecimal("4.00"), "pcs", true, USER_ID, List.of());
|
||||
new BigDecimal("4.00"), "pcs", true, USER_ID,
|
||||
List.of());
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID);
|
||||
@@ -104,7 +143,8 @@ class ShoppingListControllerTest {
|
||||
void addItemShouldReturn201() throws Exception {
|
||||
var response = new ShoppingListItemResponse(
|
||||
ITEM_ID, null, "Paper towels", null,
|
||||
new BigDecimal("1"), "", false, null, List.of());
|
||||
new BigDecimal("1"), "", false, null,
|
||||
List.of());
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class)))
|
||||
@@ -128,4 +168,30 @@ class ShoppingListControllerTest {
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addItemShouldReturn400WhenCustomNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/v1/shopping-lists/{id}/items", LIST_ID)
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new AddItemRequest(null, " ", new BigDecimal("1"), ""))))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldReturn403ForNonPlanner() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("member@example.com", null));
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(shoppingListController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
|
||||
.build();
|
||||
|
||||
mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/shopping-list", PLAN_ID)
|
||||
.principal(() -> "member@example.com"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.recipeapp.planning.WeekPlanRepository;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import com.recipeapp.planning.entity.WeekPlanSlot;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.IngredientCategory;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
@@ -39,6 +40,7 @@ class ShoppingServiceTest {
|
||||
@Mock private HouseholdRepository householdRepository;
|
||||
@Mock private IngredientRepository ingredientRepository;
|
||||
@Mock private UserAccountRepository userAccountRepository;
|
||||
@Mock private RecipeRepository recipeRepository;
|
||||
|
||||
@InjectMocks private ShoppingService shoppingService;
|
||||
|
||||
@@ -58,7 +60,7 @@ class ShoppingServiceTest {
|
||||
}
|
||||
|
||||
private Recipe testRecipe(Household household, String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
@@ -90,6 +92,46 @@ class ShoppingServiceTest {
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
}
|
||||
|
||||
// ── Get by week start ──
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturnListForGivenWeek() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var list = testShoppingList(household, plan);
|
||||
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.of(list));
|
||||
|
||||
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
|
||||
|
||||
assertThat(result.id()).isEqualTo(list.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldDefaultToCurrentWeekWhenNull() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var list = testShoppingList(household, plan);
|
||||
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(eq(HOUSEHOLD_ID), any(LocalDate.class)))
|
||||
.thenReturn(Optional.of(list));
|
||||
|
||||
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, null);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturnNullWhenNoListExists() {
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
|
||||
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
// ── Generate ──
|
||||
|
||||
@Test
|
||||
@@ -119,26 +161,84 @@ class ShoppingServiceTest {
|
||||
plan.getSlots().add(slot2);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
|
||||
ShoppingList sl = i.getArgument(0);
|
||||
setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
return sl;
|
||||
});
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered)
|
||||
assertThat(result.filteredStaplesCount()).isEqualTo(1); // salt
|
||||
|
||||
var tomatoItem = result.items().stream()
|
||||
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3
|
||||
assertThat(tomatoItem.sourceRecipes()).hasSize(2);
|
||||
assertThat(tomatoItem.sourceRecipes().get(0).name()).isNotNull();
|
||||
|
||||
var cheeseItem = result.items().stream()
|
||||
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldMergeWhenListAlreadyExists() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var existingList = testShoppingList(household, plan);
|
||||
|
||||
// Existing generated item: 2 tomatoes
|
||||
var tomato = testIngredient(household, "Tomatoes", false);
|
||||
var existingItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs");
|
||||
existingItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
|
||||
existingList.getItems().add(existingItem);
|
||||
|
||||
// Existing custom item (should be preserved)
|
||||
var customItem = new ShoppingListItem(existingList, null, "Paper towels",
|
||||
new BigDecimal("1"), "", new UUID[0]);
|
||||
setId(customItem, ShoppingListItem.class, UUID.randomUUID());
|
||||
customItem.setChecked(true);
|
||||
existingList.getItems().add(customItem);
|
||||
|
||||
// New plan: 5 tomatoes + cheese (tomato quantity updated, cheese added)
|
||||
var recipe = testRecipe(household, "Pasta");
|
||||
var cheese = testIngredient(household, "Cheese", false);
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("5.00"), "pcs", (short) 1));
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, cheese, new BigDecimal("200.00"), "g", (short) 2));
|
||||
var slot = new WeekPlanSlot(plan, recipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, plan.getWeekStart()))
|
||||
.thenReturn(Optional.of(existingList));
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0));
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
// Should have 3 items: tomato (updated), cheese (new), paper towels (preserved custom)
|
||||
assertThat(result.items()).hasSize(3);
|
||||
|
||||
var tomatoResult = result.items().stream()
|
||||
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(tomatoResult.quantity()).isEqualByComparingTo(new BigDecimal("5.00"));
|
||||
|
||||
var cheeseResult = result.items().stream()
|
||||
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(cheeseResult.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
|
||||
|
||||
// Custom item preserved with check state
|
||||
var customResult = result.items().stream()
|
||||
.filter(i -> "Paper towels".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(customResult.isChecked()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldThrowWhenPlanNotFound() {
|
||||
var planId = UUID.randomUUID();
|
||||
@@ -164,6 +264,7 @@ class ShoppingServiceTest {
|
||||
ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId());
|
||||
|
||||
assertThat(result.id()).isEqualTo(list.getId());
|
||||
assertThat(result.generatedAt()).isNotNull();
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
|
||||
}
|
||||
@@ -367,6 +468,97 @@ class ShoppingServiceTest {
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── Generate removes stale items ──
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldRemoveStaleGeneratedItems() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var existingList = testShoppingList(household, plan);
|
||||
|
||||
var tomato = testIngredient(household, "Tomatoes", false);
|
||||
var onion = testIngredient(household, "Onions", false);
|
||||
|
||||
// Existing list has both tomatoes and onions (generated)
|
||||
var tomatoItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs");
|
||||
tomatoItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
|
||||
existingList.getItems().add(tomatoItem);
|
||||
|
||||
var onionItem = testItem(existingList, onion, new BigDecimal("1.00"), "pcs");
|
||||
onionItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
|
||||
existingList.getItems().add(onionItem);
|
||||
|
||||
// New plan only has tomatoes — onions removed from recipes
|
||||
var recipe = testRecipe(household, "Sauce");
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("3.00"), "pcs", (short) 1));
|
||||
var slot = new WeekPlanSlot(plan, recipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.of(existingList));
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0));
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
|
||||
}
|
||||
|
||||
// ── Source recipes deduplication ──
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldDeduplicateSourceRecipesWhenSameRecipeInTwoSlots() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var recipe = testRecipe(household, "Pasta");
|
||||
var tomato = testIngredient(household, "Tomatoes", false);
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("2.00"), "pcs", (short) 1));
|
||||
|
||||
// Same recipe in two slots
|
||||
var slot1 = new WeekPlanSlot(plan, recipe, WEEK_START);
|
||||
setId(slot1, WeekPlanSlot.class, UUID.randomUUID());
|
||||
var slot2 = new WeekPlanSlot(plan, recipe, WEEK_START.plusDays(2));
|
||||
setId(slot2, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot1);
|
||||
plan.getSlots().add(slot2);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
|
||||
ShoppingList sl = i.getArgument(0);
|
||||
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
return sl;
|
||||
});
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().getFirst().sourceRecipes()).hasSize(1); // deduplicated
|
||||
}
|
||||
|
||||
// ── checkItem household isolation ──
|
||||
|
||||
@Test
|
||||
void checkItemShouldThrowWhenHouseholdMismatch() {
|
||||
var otherHousehold = new Household("Other family", null);
|
||||
setId(otherHousehold, Household.class, UUID.randomUUID());
|
||||
var plan = new WeekPlan(otherHousehold, WEEK_START);
|
||||
setId(plan, WeekPlan.class, UUID.randomUUID());
|
||||
var list = new ShoppingList(otherHousehold, plan);
|
||||
setId(list, ShoppingList.class, UUID.randomUUID());
|
||||
|
||||
when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list));
|
||||
|
||||
assertThatThrownBy(() -> shoppingService.checkItem(
|
||||
HOUSEHOLD_ID, list.getId(), UUID.randomUUID(), new CheckItemRequest(true), UUID.randomUUID()))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── Generate from plan with empty slots ──
|
||||
|
||||
@Test
|
||||
@@ -376,9 +568,11 @@ class ShoppingServiceTest {
|
||||
// no slots added
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
|
||||
ShoppingList sl = i.getArgument(0);
|
||||
setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
return sl;
|
||||
});
|
||||
|
||||
|
||||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
6
frontend/e2e/startseite.test.ts
Normal file
6
frontend/e2e/startseite.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('Startseite lädt korrekt', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'Willkommen bei Mealprep' })).toBeVisible();
|
||||
});
|
||||
12
frontend/playwright.config.ts
Normal file
12
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173
|
||||
},
|
||||
testDir: 'e2e',
|
||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -11,6 +11,7 @@
|
||||
--color-surface: #f5f4ee;
|
||||
--color-subtle: #edecea;
|
||||
--color-border: #d8d7d0;
|
||||
--color-border-hover: #c0bfb8;
|
||||
--color-text: #1c1c18;
|
||||
--color-text-muted: #6b6a63;
|
||||
|
||||
@@ -86,4 +87,28 @@
|
||||
--btn-font-size: 13px;
|
||||
--btn-font-weight: 500;
|
||||
--btn-letter-spacing: 0.04em;
|
||||
|
||||
/* ── Planner flip-tile semantic tokens ──────────────────────────── */
|
||||
--color-ring-today: var(--yellow-text);
|
||||
--color-ring-selected: var(--green-dark);
|
||||
--opacity-dimmed: 0.38;
|
||||
|
||||
/* ── Protein gradient tokens ────────────────────────────────────── */
|
||||
--gradient-protein-haehnchen: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
--gradient-protein-rind: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
|
||||
--gradient-protein-fisch: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
--gradient-protein-tofu: linear-gradient(135deg, #22c55e 0%, #15803d 100%);
|
||||
--gradient-protein-vegetarisch: linear-gradient(135deg, #86efac 0%, #4ade80 100%);
|
||||
--gradient-protein-schwein: linear-gradient(135deg, #fca5a5 0%, #f87171 100%);
|
||||
--gradient-protein-lamm: linear-gradient(135deg, #92400e 0%, #78350f 100%);
|
||||
--gradient-protein-eier: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||
--gradient-protein-kaese: linear-gradient(135deg, #fcd34d 0%, #d97706 100%);
|
||||
--gradient-protein-huelsenfruechte: linear-gradient(135deg, #a16207 0%, #854d0e 100%);
|
||||
|
||||
/* ── Cuisine gradient tokens ────────────────────────────────────── */
|
||||
--gradient-cuisine-deutsch: linear-gradient(135deg, #78716c 0%, #44403c 100%);
|
||||
--gradient-cuisine-asiatisch: linear-gradient(135deg, #166534 0%, #14532d 100%);
|
||||
--gradient-cuisine-indisch: linear-gradient(135deg, #ca8a04 0%, #a16207 100%);
|
||||
--gradient-cuisine-mexikanisch: linear-gradient(135deg, #ea580c 0%, #c2410c 100%);
|
||||
--gradient-cuisine-mediterran: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
||||
expect(resolve).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])(
|
||||
it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123', '/join/ABC12XYZ'])(
|
||||
'allows public route %s without auth',
|
||||
async (path) => {
|
||||
const { event, resolve } = createEvent(path);
|
||||
@@ -51,6 +51,17 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('redirects authenticated user on /join/[token] to /', async () => {
|
||||
const { event, resolve } = createEvent('/join/ABC12XYZ', 'valid-session');
|
||||
try {
|
||||
await handle({ event, resolve });
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.status).toBe(302);
|
||||
expect(e.location).toBe('/');
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
|
||||
'allows static asset %s without auth',
|
||||
async (path) => {
|
||||
@@ -79,7 +90,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
||||
displayName: 'Max',
|
||||
householdId: 'h1',
|
||||
householdName: 'Familie Müller',
|
||||
householdRole: 'planer',
|
||||
householdRole: 'planner',
|
||||
email: 'max@example.com',
|
||||
systemRole: 'user'
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite'];
|
||||
const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite', '/join'];
|
||||
|
||||
const STATIC_PREFIXES = ['/_app/', '/favicon'];
|
||||
|
||||
@@ -20,6 +20,10 @@ function loginRedirect(pathname: string): never {
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (isPublicRoute(event.url.pathname)) {
|
||||
const isJoinRoute = event.url.pathname.startsWith('/join/');
|
||||
if (isJoinRoute && event.cookies.get('JSESSIONID')) {
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
@@ -39,7 +43,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.benutzer = {
|
||||
id: user.id!,
|
||||
name: user.displayName!,
|
||||
rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied'
|
||||
rolle: user.householdRole === 'planner' ? 'planer' : 'mitglied'
|
||||
};
|
||||
event.locals.haushalt = {
|
||||
id: user.householdId ?? undefined,
|
||||
|
||||
File diff suppressed because one or more lines are too long
249
frontend/src/lib/api/schema.d.ts
vendored
249
frontend/src/lib/api/schema.d.ts
vendored
@@ -148,6 +148,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/invites/{code}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getInviteInfo"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/invites/{code}/accept": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -203,7 +219,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
get: operations["getActiveInvite"];
|
||||
put?: never;
|
||||
post: operations["createInvite"];
|
||||
delete?: never;
|
||||
@@ -212,6 +228,24 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/households/mine/members/{userId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["removeMember"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["changeMemberRole"];
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/cooking-logs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -452,6 +486,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/shopping-list": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getByWeekStart"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/ingredients": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -536,7 +586,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
cookTimeMin?: number;
|
||||
effort: string;
|
||||
isChildFriendly?: boolean;
|
||||
heroImageUrl?: string;
|
||||
ingredients: components["schemas"]["IngredientEntry"][];
|
||||
steps?: components["schemas"]["StepEntry"][];
|
||||
@@ -571,7 +620,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
isChildFriendly?: boolean;
|
||||
heroImageUrl?: string;
|
||||
ingredients?: components["schemas"]["IngredientItem"][];
|
||||
steps?: components["schemas"]["StepItem"][];
|
||||
@@ -624,6 +672,11 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
recipeId: string;
|
||||
};
|
||||
RecipeRef: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
ShoppingListItemResponse: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
@@ -636,13 +689,17 @@ export interface components {
|
||||
isChecked?: boolean;
|
||||
/** Format: uuid */
|
||||
checkedBy?: string;
|
||||
sourceRecipes?: string[];
|
||||
sourceRecipes?: components["schemas"]["RecipeRef"][];
|
||||
};
|
||||
ShoppingListResponse: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
/** Format: uuid */
|
||||
weekPlanId?: string;
|
||||
/** Format: date-time */
|
||||
generatedAt?: string;
|
||||
/** Format: int32 */
|
||||
filteredStaplesCount?: number;
|
||||
items?: components["schemas"]["ShoppingListItemResponse"][];
|
||||
};
|
||||
TagCreateRequest: {
|
||||
@@ -698,6 +755,20 @@ export interface components {
|
||||
data?: components["schemas"]["AcceptInviteResponse"];
|
||||
meta?: components["schemas"]["Meta"];
|
||||
};
|
||||
InviteInfoResponse: {
|
||||
householdName?: string;
|
||||
inviterName?: string;
|
||||
};
|
||||
ApiResponseInviteInfoResponse: {
|
||||
status?: string;
|
||||
data?: components["schemas"]["InviteInfoResponse"];
|
||||
meta?: components["schemas"]["Meta"];
|
||||
};
|
||||
AcceptInviteRequest: {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
Meta: {
|
||||
pagination?: components["schemas"]["Pagination"];
|
||||
};
|
||||
@@ -740,6 +811,14 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
joinedAt?: string;
|
||||
};
|
||||
ChangeRoleRequest: {
|
||||
role: string;
|
||||
};
|
||||
ApiResponseMemberResponse: {
|
||||
status?: string;
|
||||
data?: components["schemas"]["MemberResponse"];
|
||||
meta?: components["schemas"]["Meta"];
|
||||
};
|
||||
ApiResponseInviteResponse: {
|
||||
status?: string;
|
||||
data?: components["schemas"]["InviteResponse"];
|
||||
@@ -889,7 +968,8 @@ export interface components {
|
||||
SuggestionItem: {
|
||||
recipe?: components["schemas"]["SlotRecipe"];
|
||||
/** Format: double */
|
||||
simulatedScore?: number;
|
||||
scoreDelta?: number;
|
||||
hasConflict?: boolean;
|
||||
};
|
||||
SuggestionResponse: {
|
||||
suggestions?: components["schemas"]["SuggestionItem"][];
|
||||
@@ -908,8 +988,7 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
isChildFriendly?: boolean;
|
||||
heroImageUrl?: string;
|
||||
heroImagePreview?: string;
|
||||
};
|
||||
ApiResponseListAdminUserResponse: {
|
||||
status?: string;
|
||||
@@ -1296,7 +1375,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
acceptInvite: {
|
||||
getInviteInfo: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -1306,6 +1385,37 @@ export interface operations {
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseInviteInfoResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Not found */
|
||||
404: {
|
||||
headers: { [name: string]: unknown };
|
||||
content: { "*/*": components["schemas"]["ApiError"] };
|
||||
};
|
||||
};
|
||||
};
|
||||
acceptInvite: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
code: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AcceptInviteRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
@@ -1316,6 +1426,16 @@ export interface operations {
|
||||
"*/*": components["schemas"]["ApiResponseAcceptInviteResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Email already registered */
|
||||
409: {
|
||||
headers: { [name: string]: unknown };
|
||||
content: { "*/*": components["schemas"]["ApiError"] };
|
||||
};
|
||||
/** @description Invite not found or invalid */
|
||||
404: {
|
||||
headers: { [name: string]: unknown };
|
||||
content: { "*/*": components["schemas"]["ApiError"] };
|
||||
};
|
||||
};
|
||||
};
|
||||
listCategories: {
|
||||
@@ -1902,6 +2022,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getByWeekStart: {
|
||||
parameters: {
|
||||
query?: {
|
||||
weekStart?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ShoppingListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
searchIngredients: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -1965,6 +2107,97 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getActiveInvite: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseInviteResponse"];
|
||||
};
|
||||
};
|
||||
/** @description No active invite */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
removeMember: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
changeMemberRole: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChangeRoleRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseMemberResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listAuditLog: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
95
frontend/src/lib/components/BottomSheet.svelte
Normal file
95
frontend/src/lib/components/BottomSheet.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
open = false,
|
||||
onclose,
|
||||
height = '75vh',
|
||||
children
|
||||
}: {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
height?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
data-testid="bottom-sheet"
|
||||
aria-hidden={open ? 'false' : 'true'}
|
||||
class="fixed inset-0 z-50 flex items-end"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
data-testid="sheet-backdrop"
|
||||
class="absolute inset-0"
|
||||
style="background: rgba(28,28,24,0.4);"
|
||||
onclick={onclose}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<!-- Sheet panel -->
|
||||
<div
|
||||
class="relative z-10 w-full flex flex-col overflow-hidden"
|
||||
style="
|
||||
background: var(--color-page);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
box-shadow: var(--shadow-overlay);
|
||||
max-height: {height};
|
||||
"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header row: drag handle + close button -->
|
||||
<div class="relative flex items-center justify-center pt-3 pb-2 px-4">
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
data-testid="drag-handle"
|
||||
aria-hidden="true"
|
||||
class="absolute"
|
||||
style="
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
"
|
||||
></div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Schließen"
|
||||
class="ml-auto text-xl leading-none"
|
||||
onclick={onclose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body content -->
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
52
frontend/src/lib/components/BottomSheet.test.ts
Normal file
52
frontend/src/lib/components/BottomSheet.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import BottomSheet from './BottomSheet.svelte';
|
||||
|
||||
describe('BottomSheet', () => {
|
||||
it('is not mounted in DOM when open is false', () => {
|
||||
render(BottomSheet, { props: { open: false, onclose: vi.fn() } });
|
||||
expect(screen.queryByTestId('bottom-sheet')).toBeNull();
|
||||
});
|
||||
|
||||
it('is mounted in DOM when open is true', () => {
|
||||
render(BottomSheet, { props: { open: true, onclose: vi.fn() } });
|
||||
expect(screen.getByTestId('bottom-sheet')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onclose when close button is clicked', async () => {
|
||||
const onclose = vi.fn();
|
||||
render(BottomSheet, { props: { open: true, onclose } });
|
||||
const closeBtn = screen.getByRole('button', { name: /schließen/i });
|
||||
await userEvent.click(closeBtn);
|
||||
expect(onclose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onclose when backdrop is clicked', async () => {
|
||||
const onclose = vi.fn();
|
||||
render(BottomSheet, { props: { open: true, onclose } });
|
||||
const backdrop = screen.getByTestId('sheet-backdrop');
|
||||
await userEvent.click(backdrop);
|
||||
expect(onclose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onclose when Escape is pressed', async () => {
|
||||
const onclose = vi.fn();
|
||||
render(BottomSheet, { props: { open: true, onclose } });
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(onclose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('drag handle has aria-hidden', () => {
|
||||
render(BottomSheet, { props: { open: true, onclose: vi.fn() } });
|
||||
const handle = screen.getByTestId('drag-handle');
|
||||
expect(handle.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('does not call onclose when Escape is pressed while closed', async () => {
|
||||
const onclose = vi.fn();
|
||||
render(BottomSheet, { props: { open: false, onclose } });
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(onclose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
50
frontend/src/lib/components/SegmentedControl.svelte
Normal file
50
frontend/src/lib/components/SegmentedControl.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
options,
|
||||
value,
|
||||
onchange
|
||||
}: {
|
||||
options: { value: string; label: string }[];
|
||||
value: string;
|
||||
onchange: (value: string) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div role="group" class="segmented-control">
|
||||
{#each options as option (option.value)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={option.value === value ? 'true' : 'false'}
|
||||
class="segment"
|
||||
class:active={option.value === value}
|
||||
onclick={() => onchange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.segmented-control {
|
||||
display: flex;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.segment.active {
|
||||
background: var(--color-page);
|
||||
}
|
||||
</style>
|
||||
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal file
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import SegmentedControl from './SegmentedControl.svelte';
|
||||
|
||||
const options = [
|
||||
{ value: 'planner', label: 'Planer' },
|
||||
{ value: 'member', label: 'Mitglied' }
|
||||
];
|
||||
|
||||
describe('SegmentedControl', () => {
|
||||
it('renders all option labels', () => {
|
||||
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Planer' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Mitglied' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the active option with aria-pressed', () => {
|
||||
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Planer' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Mitglied' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('calls onchange with the new value when an option is clicked', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(SegmentedControl, { props: { options, value: 'planner', onchange } });
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Mitglied' }));
|
||||
expect(onchange).toHaveBeenCalledWith('member');
|
||||
});
|
||||
});
|
||||
29
frontend/src/lib/components/SettingsCard.svelte
Normal file
29
frontend/src/lib/components/SettingsCard.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
let { title, href, cta, meta }: Props = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
|
||||
>
|
||||
<span class="font-[var(--font-sans)] text-[16px] font-medium text-[var(--color-text)]">
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{#if meta}
|
||||
<p data-testid="card-meta" class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
|
||||
{cta}
|
||||
</span>
|
||||
</a>
|
||||
32
frontend/src/lib/components/SettingsCard.test.ts
Normal file
32
frontend/src/lib/components/SettingsCard.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import SettingsCard from './SettingsCard.svelte';
|
||||
|
||||
describe('SettingsCard', () => {
|
||||
it('renders the title', () => {
|
||||
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
|
||||
expect(screen.getByText('Vorräte')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as an anchor tag with the given href', () => {
|
||||
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Bearbeiten →' } });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/household/staples');
|
||||
});
|
||||
|
||||
it('renders the cta text', () => {
|
||||
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
|
||||
expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders meta text when provided', () => {
|
||||
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →', meta: '3 Mitglieder' } });
|
||||
expect(screen.getByText('3 Mitglieder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render meta element when meta is not provided', () => {
|
||||
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →' } });
|
||||
expect(screen.queryByTestId('card-meta')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
31
frontend/src/lib/components/Toast.svelte
Normal file
31
frontend/src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
const { message, visible, ondismiss }: {
|
||||
message: string;
|
||||
visible: boolean;
|
||||
ondismiss?: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
role="status"
|
||||
style="
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 200;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
"
|
||||
>
|
||||
<span>{message}</span>
|
||||
<button aria-label="Schließen" onclick={ondismiss}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
23
frontend/src/lib/components/Toast.test.ts
Normal file
23
frontend/src/lib/components/Toast.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Toast from './Toast.svelte';
|
||||
|
||||
describe('Toast', () => {
|
||||
it('is not mounted when visible is false', () => {
|
||||
render(Toast, { props: { message: 'Hallo', visible: false } });
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the message when visible is true', () => {
|
||||
render(Toast, { props: { message: 'Gespeichert', visible: true } });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Gespeichert');
|
||||
});
|
||||
|
||||
it('calls ondismiss when close button is clicked', async () => {
|
||||
const ondismiss = vi.fn();
|
||||
render(Toast, { props: { message: 'Fehler', visible: true, ondismiss } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /schließen/i }));
|
||||
expect(ondismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,28 @@ const requiredTokens = [
|
||||
// Shadows
|
||||
'--shadow-card',
|
||||
'--shadow-raised',
|
||||
'--shadow-overlay'
|
||||
'--shadow-overlay',
|
||||
// Planner flip-tile semantic tokens
|
||||
'--color-ring-today',
|
||||
'--color-ring-selected',
|
||||
'--opacity-dimmed',
|
||||
// Protein gradient tokens
|
||||
'--gradient-protein-haehnchen',
|
||||
'--gradient-protein-rind',
|
||||
'--gradient-protein-fisch',
|
||||
'--gradient-protein-tofu',
|
||||
'--gradient-protein-vegetarisch',
|
||||
'--gradient-protein-schwein',
|
||||
'--gradient-protein-lamm',
|
||||
'--gradient-protein-eier',
|
||||
'--gradient-protein-kaese',
|
||||
'--gradient-protein-huelsenfruechte',
|
||||
// Cuisine gradient tokens
|
||||
'--gradient-cuisine-deutsch',
|
||||
'--gradient-cuisine-asiatisch',
|
||||
'--gradient-cuisine-indisch',
|
||||
'--gradient-cuisine-mexikanisch',
|
||||
'--gradient-cuisine-mediterran'
|
||||
];
|
||||
|
||||
describe('design token completeness', () => {
|
||||
|
||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -31,7 +31,7 @@ describe('AppShell', () => {
|
||||
it('renders all navigation links from all nav variants', () => {
|
||||
render(AppShell, { props: defaultProps });
|
||||
const links = screen.getAllByRole('link');
|
||||
// Mobile: 4, Tablet: 4, Desktop: 5 = 13 total
|
||||
expect(links).toHaveLength(13);
|
||||
// Mobile: 4, Tablet: 4, Desktop: 4 = 12 total
|
||||
expect(links).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
{section.title}
|
||||
</p>
|
||||
{#each section.items as item (item.href)}
|
||||
{@const active = isActiveRoute(item.href, $page.url.pathname)}
|
||||
{@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
|
||||
@@ -28,17 +28,17 @@ describe('DesktopSidebar', () => {
|
||||
expect(screen.getByText('Einkauf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Household section with 2 items', () => {
|
||||
it('renders Household section with Einstellungen', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
expect(screen.getByText('Haushalt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mitglieder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Einstellungen')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mitglieder')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has 5 navigation links total', () => {
|
||||
it('has 4 navigation links total', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(5);
|
||||
expect(links).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('marks active item with aria-current="page"', () => {
|
||||
@@ -59,3 +59,18 @@ describe('DesktopSidebar', () => {
|
||||
expect(widget).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DesktopSidebar — extraPaths active state', () => {
|
||||
it('marks Einstellungen active when on /household/staples', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
vi.doMock('$app/stores', () => ({
|
||||
page: readable({ url: new URL('http://localhost/household/staples') })
|
||||
}));
|
||||
vi.resetModules();
|
||||
const { render: r, screen: s } = await import('@testing-library/svelte');
|
||||
const { default: Sidebar } = await import('./DesktopSidebar.svelte');
|
||||
r(Sidebar, { props: { appName: 'Test', householdName: 'Test' } });
|
||||
const link = s.getByRole('link', { name: /einstellungen/i });
|
||||
expect(link).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
|
||||
>
|
||||
{#each mobileNavItems as item (item.href)}
|
||||
{@const active = isActiveRoute(item.href, $page.url.pathname)}
|
||||
{@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
|
||||
@@ -53,3 +53,18 @@ describe('MobileTabBar', () => {
|
||||
expect(recipesLink).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileTabBar — extraPaths active state', () => {
|
||||
it('marks Einstellungen active when on /household/staples', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
vi.doMock('$app/stores', () => ({
|
||||
page: readable({ url: new URL('http://localhost/household/staples') })
|
||||
}));
|
||||
vi.resetModules();
|
||||
const { render: r, screen: s } = await import('@testing-library/svelte');
|
||||
const { default: TabBar } = await import('./MobileTabBar.svelte');
|
||||
r(TabBar);
|
||||
const link = s.getByRole('link', { name: /einstellungen/i });
|
||||
expect(link).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,9 @@ describe('nav config', () => {
|
||||
expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']);
|
||||
});
|
||||
|
||||
it('Household section has Members, Settings', () => {
|
||||
it('Household section has Settings', () => {
|
||||
const labels = desktopNavSections[1].items.map((item) => item.label);
|
||||
expect(labels).toEqual(['Mitglieder', 'Einstellungen']);
|
||||
expect(labels).toEqual(['Einstellungen']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,5 +56,35 @@ describe('nav config', () => {
|
||||
it('does not match unrelated route', () => {
|
||||
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches when pathname is in extraPaths', () => {
|
||||
expect(isActiveRoute('/settings', '/household/staples', ['/household/staples'])).toBe(true);
|
||||
});
|
||||
|
||||
it('matches sub-route of extraPath', () => {
|
||||
expect(isActiveRoute('/settings', '/household/staples/edit', ['/household/staples'])).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match extraPath with similar prefix', () => {
|
||||
expect(isActiveRoute('/settings', '/household/staples-old', ['/household/staples'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when extraPaths provided but no match', () => {
|
||||
expect(isActiveRoute('/settings', '/members', ['/household/staples'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NavItem extraPaths', () => {
|
||||
it('Einstellungen desktop nav item includes /household/staples in extraPaths', () => {
|
||||
const einstellungen = desktopNavSections
|
||||
.flatMap((s) => s.items)
|
||||
.find((i) => i.href === '/settings');
|
||||
expect(einstellungen?.extraPaths).toContain('/household/staples');
|
||||
});
|
||||
|
||||
it('Einstellungen mobile nav item includes /household/staples in extraPaths', () => {
|
||||
const einstellungen = mobileNavItems.find((i) => i.href === '/settings');
|
||||
expect(einstellungen?.extraPaths).toContain('/household/staples');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
extraPaths?: string[];
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
@@ -13,11 +14,15 @@ export const mobileNavItems: NavItem[] = [
|
||||
{ href: '/planner', label: 'Planer', icon: '📅' },
|
||||
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
|
||||
{ href: '/shopping', label: 'Einkauf', icon: '🛒' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
|
||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
|
||||
];
|
||||
|
||||
export function isActiveRoute(href: string, pathname: string): boolean {
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
export function isActiveRoute(href: string, pathname: string, extraPaths?: string[]): boolean {
|
||||
if (pathname === href || pathname.startsWith(href + '/')) return true;
|
||||
if (extraPaths) {
|
||||
return extraPaths.some((p) => pathname === p || pathname.startsWith(p + '/'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const desktopNavSections: NavSection[] = [
|
||||
@@ -32,8 +37,7 @@ export const desktopNavSections: NavSection[] = [
|
||||
{
|
||||
title: 'Haushalt',
|
||||
items: [
|
||||
{ href: '/members', label: 'Mitglieder', icon: '👥' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
|
||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
113
frontend/src/lib/planner/DayMealCard.svelte
Normal file
113
frontend/src/lib/planner/DayMealCard.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
interface SlotRecipe {
|
||||
id?: string;
|
||||
name?: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe?: SlotRecipe | null;
|
||||
}
|
||||
|
||||
let {
|
||||
slot,
|
||||
isToday = false,
|
||||
isSelected = false,
|
||||
readonly = false,
|
||||
onaddrecipe,
|
||||
onactionsheet
|
||||
}: {
|
||||
slot: Slot;
|
||||
isToday?: boolean;
|
||||
isSelected?: boolean;
|
||||
readonly?: boolean;
|
||||
onaddrecipe?: () => void;
|
||||
onactionsheet?: () => void;
|
||||
} = $props();
|
||||
|
||||
let actionSheetMode = $derived(!!onactionsheet && !!slot.recipe);
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
slot.recipe?.cookTimeMin != null ? `${slot.recipe.cookTimeMin} Min` : null,
|
||||
slot.recipe?.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
|
||||
let borderClass = $derived(
|
||||
isToday
|
||||
? 'border-[var(--yellow)] bg-[var(--yellow-tint)]'
|
||||
: isSelected
|
||||
? 'border-[var(--green)] bg-[var(--green-tint)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet recipeInfo()}
|
||||
<h3 class="font-[var(--font-display)] text-[20px] font-[300] leading-tight text-[var(--color-text)]">
|
||||
{slot.recipe?.name ?? ''}
|
||||
</h3>
|
||||
{#if metadata}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if actionSheetMode}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="day-meal-card"
|
||||
data-today={isToday}
|
||||
data-selected={isSelected}
|
||||
onclick={onactionsheet}
|
||||
class="w-full text-left rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
|
||||
>
|
||||
{@render recipeInfo()}
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
data-testid="day-meal-card"
|
||||
data-today={isToday}
|
||||
data-selected={isSelected}
|
||||
class="rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
|
||||
>
|
||||
{#if slot.recipe}
|
||||
{@render recipeInfo()}
|
||||
|
||||
{#if !readonly}
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a
|
||||
href="/recipes/{slot.recipe.id}/cook"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Jetzt kochen
|
||||
</a>
|
||||
{#if onaddrecipe}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onaddrecipe}
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
||||
>
|
||||
Tauschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if !readonly && onaddrecipe}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onaddrecipe}
|
||||
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
118
frontend/src/lib/planner/DayMealCard.test.ts
Normal file
118
frontend/src/lib/planner/DayMealCard.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import DayMealCard from './DayMealCard.svelte';
|
||||
|
||||
const slot = {
|
||||
id: 's1',
|
||||
slotDate: '2026-03-30',
|
||||
recipe: { id: 'r1', name: 'Pasta Bolognese', effort: 'Easy', cookTimeMin: 30 }
|
||||
};
|
||||
|
||||
describe('DayMealCard', () => {
|
||||
it('renders recipe name', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Jetzt kochen link and Tauschen button when not readonly and onaddrecipe provided', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe: vi.fn() } });
|
||||
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Tauschen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Tauschen button calls onaddrecipe when clicked', async () => {
|
||||
const onaddrecipe = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe } });
|
||||
await user.click(screen.getByRole('button', { name: /Tauschen/i }));
|
||||
expect(onaddrecipe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('hides Tauschen button when onaddrecipe not provided', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('hides action links when readonly', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: true, onaddrecipe: vi.fn() } });
|
||||
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('applies today styling when isToday is true', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: true, readonly: false } });
|
||||
const card = screen.getByTestId('day-meal-card');
|
||||
expect(card.getAttribute('data-today')).toBe('true');
|
||||
});
|
||||
|
||||
it('applies selected styling when isSelected is true and not today', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, isSelected: true, readonly: false } });
|
||||
const card = screen.getByTestId('day-meal-card');
|
||||
expect(card.getAttribute('data-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders empty state when slot has no recipe', () => {
|
||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
||||
expect(screen.getByText(/Kein Gericht/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows cook time and effort metadata', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
expect(screen.getByText(/30 Min/)).toBeTruthy();
|
||||
expect(screen.getByText(/Easy/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('empty state shows add button when onaddrecipe provided', () => {
|
||||
const onaddrecipe = vi.fn();
|
||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } });
|
||||
expect(screen.getByRole('button', { name: /Gericht hinzufügen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('add button calls onaddrecipe when clicked', async () => {
|
||||
const onaddrecipe = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } });
|
||||
await user.click(screen.getByRole('button', { name: /Gericht hinzufügen/i }));
|
||||
expect(onaddrecipe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('empty state hides add button when onaddrecipe not provided', () => {
|
||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
||||
expect(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull();
|
||||
});
|
||||
|
||||
describe('onactionsheet prop (mobile full-card tap target)', () => {
|
||||
it('card renders as a button when onactionsheet provided and recipe exists', () => {
|
||||
render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
|
||||
const card = screen.getByRole('button', { name: /Pasta Bolognese/i });
|
||||
expect(card).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicking card calls onactionsheet', async () => {
|
||||
const onactionsheet = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DayMealCard, { props: { slot, onactionsheet } });
|
||||
await user.click(screen.getByRole('button', { name: /Pasta Bolognese/i }));
|
||||
expect(onactionsheet).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('inline Jetzt kochen and Tauschen buttons are hidden when onactionsheet provided', () => {
|
||||
render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
|
||||
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to normal rendering when onactionsheet not provided', () => {
|
||||
render(DayMealCard, { props: { slot, readonly: false, onaddrecipe: vi.fn() } });
|
||||
expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
|
||||
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('empty slot does not render card as button even when onactionsheet provided', () => {
|
||||
const emptySlot = { id: 's2', slotDate: '2026-03-31', recipe: null };
|
||||
render(DayMealCard, { props: { slot: emptySlot, onactionsheet: vi.fn(), onaddrecipe: vi.fn() } });
|
||||
expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { weekDays, prevWeek, nextWeek, formatDayAbbr, formatWeekRange } from './week';
|
||||
|
||||
interface Slot {
|
||||
id: string;
|
||||
slotDate: string;
|
||||
recipe: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
let {
|
||||
recipeName,
|
||||
recipeId,
|
||||
planId,
|
||||
weekStart,
|
||||
today,
|
||||
slots = [],
|
||||
onconfirm,
|
||||
onweekchange
|
||||
}: {
|
||||
recipeName: string;
|
||||
recipeId: string;
|
||||
planId: string;
|
||||
weekStart: string;
|
||||
today: string;
|
||||
slots: Slot[];
|
||||
onconfirm: (result: { date: string; slotId: string | null }) => void;
|
||||
onweekchange: (newWeekStart: string) => void;
|
||||
} = $props();
|
||||
|
||||
let selectedDate = $state<string | null>(null);
|
||||
|
||||
const slotMap = $derived(
|
||||
new Map(slots.map((s) => [s.slotDate, s]))
|
||||
);
|
||||
|
||||
const days = $derived(weekDays(weekStart));
|
||||
|
||||
function chipState(date: string): string {
|
||||
const isSelected = selectedDate === date;
|
||||
const slot = slotMap.get(date);
|
||||
const hasFilled = slot?.recipe != null;
|
||||
|
||||
if (isSelected) {
|
||||
return hasFilled ? 'sel-filled' : 'sel-empty';
|
||||
}
|
||||
if (date === today) return 'today';
|
||||
return hasFilled ? 'filled' : 'empty';
|
||||
}
|
||||
|
||||
const selectedSlot = $derived(selectedDate ? slotMap.get(selectedDate) : undefined);
|
||||
const existingRecipeName = $derived(selectedSlot?.recipe?.name ?? null);
|
||||
const existingSlotId = $derived(selectedSlot?.id ?? null);
|
||||
|
||||
function chipStyle(state: string): string {
|
||||
switch (state) {
|
||||
case 'empty':
|
||||
return 'border-style: dashed; border-color: var(--green-light); background: var(--green-tint);';
|
||||
case 'filled':
|
||||
return 'border-color: var(--color-border); background: var(--color-surface);';
|
||||
case 'today':
|
||||
return 'border-color: var(--yellow); background: var(--yellow-tint);';
|
||||
case 'sel-empty':
|
||||
return 'border: 2px solid var(--green-dark); background: var(--green-tint);';
|
||||
case 'sel-filled':
|
||||
return 'border: 2px solid var(--orange-dark); background: var(--orange-tint);';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleChipClick(date: string) {
|
||||
selectedDate = date;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedDate) return;
|
||||
onconfirm({ date: selectedDate, slotId: existingSlotId });
|
||||
}
|
||||
|
||||
function dayNumber(date: string): string {
|
||||
return date.slice(-2).replace(/^0/, '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||
<!-- Header -->
|
||||
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
Tag wählen
|
||||
</p>
|
||||
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
|
||||
{recipeName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Week navigation -->
|
||||
<div
|
||||
style="display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--color-border);"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Vorherige Woche"
|
||||
onclick={() => onweekchange(prevWeek(weekStart))}
|
||||
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span style="font-size: 12px; font-weight: 500; color: var(--color-text);">
|
||||
{formatWeekRange(weekStart)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Nächste Woche"
|
||||
onclick={() => onweekchange(nextWeek(weekStart))}
|
||||
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Day chips -->
|
||||
<div
|
||||
style="display: flex; gap: 6px; padding: 10px 12px; overflow-x: auto;"
|
||||
>
|
||||
{#each days as date (date)}
|
||||
{@const state = chipState(date)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chip-{date}"
|
||||
data-state={state}
|
||||
onclick={() => handleChipClick(date)}
|
||||
style="flex: 1; min-width: 36px; padding: 6px 4px; border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; text-align: center; font-family: var(--font-sans); {chipStyle(state)}"
|
||||
>
|
||||
<span style="display: block; font-size: 9px; font-weight: 500; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em;">
|
||||
{formatDayAbbr(date, 'narrow')}
|
||||
</span>
|
||||
<span style="display: block; font-size: 13px; font-weight: 600; color: var(--color-text); margin-top: 2px;">
|
||||
{dayNumber(date)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Replace warning -->
|
||||
{#if selectedDate && existingRecipeName}
|
||||
<div
|
||||
data-testid="replace-warning"
|
||||
style="margin: 0 12px 10px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--orange-tint); border: 1px solid var(--orange-dark); font-size: 11px; color: var(--color-text);"
|
||||
>
|
||||
Ersetzt <strong>{existingRecipeName}</strong> an diesem Tag.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirm button -->
|
||||
<div style="padding: 0 12px 12px;">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-btn"
|
||||
disabled={!selectedDate}
|
||||
onclick={handleConfirm}
|
||||
style="width: 100%; padding: 9px 12px; font-family: var(--font-sans); font-size: 13px; font-weight: 600; border-radius: var(--radius-md); border: none; cursor: {selectedDate ? 'pointer' : 'not-allowed'}; background: {selectedDate ? 'var(--green)' : 'var(--color-border)'}; color: {selectedDate ? '#fff' : 'var(--color-text-muted)'};"
|
||||
>
|
||||
Einplanen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
134
frontend/src/lib/planner/DayPicker.test.ts
Normal file
134
frontend/src/lib/planner/DayPicker.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import DayPicker from './DayPicker.svelte';
|
||||
|
||||
const weekStart = '2026-03-30'; // Monday
|
||||
const today = '2026-04-01'; // Wednesday
|
||||
|
||||
// Mo: filled, Di: filled (today), Mi: filled, Do: empty, Fr: filled, Sa: empty, So: filled
|
||||
const slots = [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } },
|
||||
{ id: 's2', slotDate: '2026-04-01', recipe: { id: 'r2', name: 'Curry', effort: 'easy' } },
|
||||
{ id: 's3', slotDate: '2026-04-02', recipe: { id: 'r3', name: 'Risotto', effort: 'medium' } },
|
||||
{ id: 's5', slotDate: '2026-04-04', recipe: { id: 'r5', name: 'Suppe', effort: 'easy' } },
|
||||
{ id: 's7', slotDate: '2026-04-06', recipe: { id: 'r7', name: 'Stir Fry', effort: 'easy' } }
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
recipeName: 'Mushroom Risotto',
|
||||
recipeId: 'recipe-42',
|
||||
planId: 'plan-1',
|
||||
weekStart,
|
||||
today,
|
||||
slots,
|
||||
onconfirm: vi.fn(),
|
||||
onweekchange: vi.fn()
|
||||
};
|
||||
|
||||
describe('DayPicker', () => {
|
||||
it('shows recipe name in header', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
expect(screen.getByText('Mushroom Risotto')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows 7 day chips', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const chips = screen.getAllByTestId(/^chip-/);
|
||||
expect(chips).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('marks empty slot chips with data-state="empty"', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
// Do (2026-04-03) and Sa (2026-04-05) are empty
|
||||
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||
expect(doChip.getAttribute('data-state')).toBe('empty');
|
||||
});
|
||||
|
||||
it('marks filled slot chips with data-state="filled"', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||
expect(moChip.getAttribute('data-state')).toBe('filled');
|
||||
});
|
||||
|
||||
it('marks today chip with data-state="today"', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const todayChip = screen.getByTestId('chip-2026-04-01');
|
||||
expect(todayChip.getAttribute('data-state')).toBe('today');
|
||||
});
|
||||
|
||||
it('selecting an empty chip changes its state to sel-empty', async () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||
await userEvent.click(doChip);
|
||||
expect(doChip.getAttribute('data-state')).toBe('sel-empty');
|
||||
});
|
||||
|
||||
it('selecting a filled chip changes its state to sel-filled', async () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||
await userEvent.click(moChip);
|
||||
expect(moChip.getAttribute('data-state')).toBe('sel-filled');
|
||||
});
|
||||
|
||||
it('shows replace warning when filled chip is selected', async () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||
await userEvent.click(moChip);
|
||||
expect(screen.getByTestId('replace-warning')).toBeTruthy();
|
||||
expect(screen.getByText(/Pasta/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show replace warning when empty chip is selected', async () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||
await userEvent.click(doChip);
|
||||
expect(screen.queryByTestId('replace-warning')).toBeNull();
|
||||
});
|
||||
|
||||
it('confirm button is disabled when no chip is selected', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const btn = screen.getByTestId('confirm-btn');
|
||||
expect(btn.hasAttribute('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onconfirm with date and null slotId when empty chip confirmed', async () => {
|
||||
const onconfirm = vi.fn();
|
||||
render(DayPicker, { props: { ...baseProps, onconfirm } });
|
||||
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||
await userEvent.click(doChip);
|
||||
const btn = screen.getByTestId('confirm-btn');
|
||||
await userEvent.click(btn);
|
||||
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-04-03', slotId: null });
|
||||
});
|
||||
|
||||
it('calls onconfirm with date and slotId when filled chip confirmed', async () => {
|
||||
const onconfirm = vi.fn();
|
||||
render(DayPicker, { props: { ...baseProps, onconfirm } });
|
||||
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||
await userEvent.click(moChip);
|
||||
const btn = screen.getByTestId('confirm-btn');
|
||||
await userEvent.click(btn);
|
||||
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-03-30', slotId: 's1' });
|
||||
});
|
||||
|
||||
it('shows prev/next week navigation buttons', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
expect(screen.getByRole('button', { name: /Vorherige Woche/ })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Nächste Woche/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onweekchange with prev week when prev button clicked', async () => {
|
||||
const onweekchange = vi.fn();
|
||||
render(DayPicker, { props: { ...baseProps, onweekchange } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /Vorherige Woche/ }));
|
||||
expect(onweekchange).toHaveBeenCalledWith('2026-03-23');
|
||||
});
|
||||
|
||||
it('calls onweekchange with next week when next button clicked', async () => {
|
||||
const onweekchange = vi.fn();
|
||||
render(DayPicker, { props: { ...baseProps, onweekchange } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /Nächste Woche/ }));
|
||||
expect(onweekchange).toHaveBeenCalledWith('2026-04-06');
|
||||
});
|
||||
});
|
||||
412
frontend/src/lib/planner/DesktopDayTile.svelte
Normal file
412
frontend/src/lib/planner/DesktopDayTile.svelte
Normal file
@@ -0,0 +1,412 @@
|
||||
<script lang="ts">
|
||||
import EmptyDayTile from './EmptyDayTile.svelte';
|
||||
import { formatDayAbbr } from '$lib/planner/week';
|
||||
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
|
||||
import { sanitizeForCssUrl } from '$lib/planner/DesktopDayTile.utils';
|
||||
|
||||
let {
|
||||
slot,
|
||||
isToday,
|
||||
activeSlotId,
|
||||
isPlanner,
|
||||
slotMap,
|
||||
suggestions,
|
||||
topSuggestion,
|
||||
onflip,
|
||||
onclose,
|
||||
onswap,
|
||||
onremove,
|
||||
onaddrecipe
|
||||
}: {
|
||||
slot: Slot;
|
||||
isToday: boolean;
|
||||
activeSlotId: string | null;
|
||||
isPlanner: boolean;
|
||||
slotMap: Record<string, any>;
|
||||
suggestions: Suggestion[];
|
||||
topSuggestion?: Suggestion;
|
||||
onflip?: (slotId: string) => void;
|
||||
onclose?: () => void;
|
||||
onswap?: () => void;
|
||||
onremove?: () => void;
|
||||
onaddrecipe?: () => void;
|
||||
} = $props();
|
||||
|
||||
const slotId = $derived(slot.id ?? '');
|
||||
const isFlipped = $derived(activeSlotId === slot.id && !!slot.recipe);
|
||||
const isDimmed = $derived(activeSlotId !== null && activeSlotId !== slot.id && !!slot.recipe);
|
||||
|
||||
const dayAbbr = $derived(slot.slotDate ? formatDayAbbr(slot.slotDate, 'short') : '');
|
||||
const dateNum = $derived(slot.slotDate ? slot.slotDate.slice(-2).replace(/^0/, '') : '');
|
||||
|
||||
const visibleTags = $derived(
|
||||
(slot.recipe?.tags ?? []).filter((t) => t.tagType === 'protein' || t.tagType === 'cuisine')
|
||||
);
|
||||
|
||||
const metaLine = $derived((() => {
|
||||
const parts: string[] = [];
|
||||
if (slot.recipe?.cookTimeMin) parts.push(`${slot.recipe.cookTimeMin} Min`);
|
||||
if (slot.recipe?.effort) parts.push(slot.recipe.effort);
|
||||
return parts.join(' · ');
|
||||
})());
|
||||
|
||||
function handleFlip() {
|
||||
onflip?.(slotId);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onflip?.(slotId);
|
||||
}
|
||||
}
|
||||
|
||||
const umlautMap: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
|
||||
function toCssKey(name: string): string {
|
||||
return name.toLowerCase().replace(/[äöüß]/g, (c) => umlautMap[c] ?? c);
|
||||
}
|
||||
|
||||
const gradientBackground = $derived((() => {
|
||||
if (!slot.recipe) return 'var(--color-surface)';
|
||||
if (slot.recipe.heroImageUrl) return `url("${sanitizeForCssUrl(slot.recipe.heroImageUrl)}")`;
|
||||
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
|
||||
if (proteinTag?.name) {
|
||||
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;
|
||||
}
|
||||
const cuisineTag = slot.recipe.tags?.find((t) => t.tagType === 'cuisine');
|
||||
if (cuisineTag?.name) {
|
||||
return `var(--gradient-cuisine-${toCssKey(cuisineTag.name)})`;
|
||||
}
|
||||
return 'var(--color-surface)';
|
||||
})());
|
||||
</script>
|
||||
|
||||
{#if slot.recipe}
|
||||
<div
|
||||
data-testid="day-meal-card-{slot.slotDate ?? ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={slot.recipe?.name ?? 'Gericht'}
|
||||
aria-expanded={isFlipped}
|
||||
data-today={isToday}
|
||||
data-flipped={isFlipped}
|
||||
data-dimmed={isDimmed}
|
||||
class="scene"
|
||||
class:scene-today={isToday && !isFlipped}
|
||||
class:scene-selected={isFlipped}
|
||||
class:scene-dimmed={isDimmed}
|
||||
onclick={handleFlip}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<!-- FRONT -->
|
||||
<div class="card-front" class:flipped={isFlipped}>
|
||||
<div
|
||||
class="card-front-inner"
|
||||
style="background: {gradientBackground}; background-size: cover; background-position: center;"
|
||||
>
|
||||
<!-- Full-tile dual gradient overlay -->
|
||||
<div class="tile-overlay"></div>
|
||||
|
||||
<!-- Day header -->
|
||||
<div class="tile-head">
|
||||
<span class="tile-day-abbr">{dayAbbr}</span>
|
||||
<span class="tile-day-num" class:dn-today={isToday} class:dn-selected={isFlipped}>
|
||||
{dateNum}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recipe info at bottom -->
|
||||
<div class="tile-info">
|
||||
<p class="tile-name">{slot.recipe.name}</p>
|
||||
{#if metaLine}
|
||||
<p class="tile-meta">{metaLine}</p>
|
||||
{/if}
|
||||
{#if visibleTags.length > 0}
|
||||
<div class="tile-tags">
|
||||
{#each visibleTags as tag (tag.id)}
|
||||
<span class="tile-tag" class:tag-today={isToday} class:tag-selected={isFlipped}>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div> <!-- /.card-front-inner -->
|
||||
</div>
|
||||
|
||||
<!-- BACK -->
|
||||
<div class="card-back" class:flipped={isFlipped} aria-hidden={!isFlipped}>
|
||||
<div class="card-back-inner">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Schließen"
|
||||
class="btn-close"
|
||||
onclick={(e) => { e.stopPropagation(); onclose?.(); }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<p class="back-name">{slot.recipe.name}</p>
|
||||
{#if metaLine}
|
||||
<p class="back-meta">{metaLine}</p>
|
||||
{/if}
|
||||
|
||||
<div class="back-actions">
|
||||
<a class="btn-action btn-primary" href="/recipes/{slot.recipe.id}/cook" onclick={(e) => e.stopPropagation()}>Koch-Modus</a>
|
||||
<a class="btn-action" href="/recipes/{slot.recipe.id}" onclick={(e) => e.stopPropagation()}>Rezept ansehen</a>
|
||||
|
||||
{#if isPlanner}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
onclick={(e) => { e.stopPropagation(); onswap?.(); }}
|
||||
>
|
||||
Gericht tauschen
|
||||
</button>
|
||||
|
||||
{#if slot.id}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-danger"
|
||||
onclick={(e) => { e.stopPropagation(); onremove?.(); }}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div> <!-- /.card-back-inner -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyDayTile
|
||||
slotDate={slot.slotDate ?? ''}
|
||||
slotId={slot.id ?? ''}
|
||||
{isPlanner}
|
||||
{slotMap}
|
||||
{topSuggestion}
|
||||
{onaddrecipe}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Scene (outermost positioned element) ── */
|
||||
.scene {
|
||||
perspective: 900px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
|
||||
transition: box-shadow .15s, opacity .15s;
|
||||
}
|
||||
.scene:hover {
|
||||
box-shadow: 0 6px 18px rgba(28,28,24,.14), 0 2px 6px rgba(28,28,24,.08);
|
||||
}
|
||||
.scene-today {
|
||||
box-shadow: 0 0 0 2px var(--color-ring-today), 0 1px 3px rgba(28,28,24,.06);
|
||||
}
|
||||
.scene-today:hover {
|
||||
box-shadow: 0 0 0 2px var(--color-ring-today), 0 6px 18px rgba(28,28,24,.14);
|
||||
}
|
||||
.scene-selected {
|
||||
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
|
||||
}
|
||||
/* Keep ring visible on hover — :hover alone has higher specificity than .scene-selected */
|
||||
.scene-selected:hover {
|
||||
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
|
||||
}
|
||||
.scene-dimmed {
|
||||
opacity: var(--opacity-dimmed);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Card flip — independent face transforms, no preserve-3d ──
|
||||
preserve-3d + box-shadow/transition on parent causes Chrome to
|
||||
fail backface-visibility:hidden. Rotating each face independently
|
||||
avoids the 3D context entirely. */
|
||||
.card-front,
|
||||
.card-back {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
.card-front { transform: rotateY(0deg); }
|
||||
.card-front.flipped { transform: rotateY(-180deg); }
|
||||
.card-back { transform: rotateY(180deg); pointer-events: none; }
|
||||
.card-back.flipped { transform: rotateY(0deg); pointer-events: auto; }
|
||||
.card-front.flipped { pointer-events: none; }
|
||||
.card-front-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Front face ── */
|
||||
.tile-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,.32) 0%,
|
||||
rgba(0,0,0,0) 30%,
|
||||
rgba(0,0,0,0) 45%,
|
||||
rgba(0,0,0,.55) 100%
|
||||
);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.tile-head {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 8px;
|
||||
z-index: 2;
|
||||
}
|
||||
.tile-day-abbr {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: rgba(255,255,255,.85);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tile-day-num {
|
||||
width: 20px; height: 20px;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,.9);
|
||||
background: rgba(255,255,255,.22);
|
||||
}
|
||||
.dn-today {
|
||||
background: var(--color-ring-today) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.dn-selected {
|
||||
background: var(--green-dark) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.tile-info {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
padding: 8px 9px 9px;
|
||||
z-index: 2;
|
||||
}
|
||||
.tile-name {
|
||||
font-size: 19px;
|
||||
font-weight: 300;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,.4);
|
||||
}
|
||||
.tile-meta {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,.75);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.tile-tags {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.tile-tag {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 5px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255,255,255,.2);
|
||||
color: rgba(255,255,255,.92);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.tag-today { background: rgba(242,193,46,.35); }
|
||||
.tag-selected { background: rgba(46,110,57,.45); }
|
||||
|
||||
/* ── Back face ── */
|
||||
.card-back-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow-y: auto;
|
||||
background: var(--color-page);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px 6px;
|
||||
align-self: flex-end;
|
||||
margin: -4px -4px 2px 0;
|
||||
}
|
||||
.btn-close:hover { color: var(--color-text); }
|
||||
.back-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
margin: 0 0 2px;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.back-meta {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.back-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.btn-action {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .04em;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.btn-action:hover { background: var(--color-surface); }
|
||||
.btn-primary {
|
||||
background: var(--green-dark);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
.btn-primary:hover { background: var(--green-dark); filter: brightness(1.1); }
|
||||
.btn-danger {
|
||||
color: #dc4c3e;
|
||||
border-color: #dc4c3e;
|
||||
background: transparent;
|
||||
}
|
||||
.btn-danger:hover { background: rgba(220,76,62,.08); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card-front, .card-back { transition: none; }
|
||||
.scene { transition: none; }
|
||||
}
|
||||
</style>
|
||||
205
frontend/src/lib/planner/DesktopDayTile.test.ts
Normal file
205
frontend/src/lib/planner/DesktopDayTile.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import DesktopDayTile from './DesktopDayTile.svelte';
|
||||
import { sanitizeForCssUrl } from './DesktopDayTile.utils';
|
||||
|
||||
const filledSlot = {
|
||||
id: 's1',
|
||||
slotDate: '2026-04-14',
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
name: 'Pasta Bolognese',
|
||||
cookTimeMin: 45,
|
||||
effort: 'mittel',
|
||||
heroImageUrl: null,
|
||||
tags: [{ id: 't1', name: 'Rind', tagType: 'protein' }]
|
||||
}
|
||||
};
|
||||
|
||||
const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null };
|
||||
|
||||
describe('sanitizeForCssUrl', () => {
|
||||
it('strips parentheses that could break out of url() context', () => {
|
||||
expect(sanitizeForCssUrl('x);}body{display:none}/*')).not.toContain(')');
|
||||
});
|
||||
|
||||
it('strips single quotes', () => {
|
||||
expect(sanitizeForCssUrl("data:image/png;base64,abc'def")).not.toContain("'");
|
||||
});
|
||||
|
||||
it('strips double quotes', () => {
|
||||
expect(sanitizeForCssUrl('data:image/png;base64,abc"def')).not.toContain('"');
|
||||
});
|
||||
|
||||
it('strips backslashes', () => {
|
||||
expect(sanitizeForCssUrl('data:image/png;base64,abc\\def')).not.toContain('\\');
|
||||
});
|
||||
|
||||
it('preserves a safe data URI unchanged', () => {
|
||||
const safe = 'data:image/png;base64,abc123+/==';
|
||||
expect(sanitizeForCssUrl(safe)).toBe(safe);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DesktopDayTile — filled slot', () => {
|
||||
describe('front face', () => {
|
||||
it('renders recipe name on front face', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.getAllByText('Pasta Bolognese').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('has data-testid="day-meal-card" on the scene element', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.getByTestId("day-meal-card-2026-04-14")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies today ring when isToday', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: true, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('data-today')).toBe('true');
|
||||
});
|
||||
|
||||
it('applies selected ring when activeSlotId matches slot id', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('data-flipped')).toBe('true');
|
||||
});
|
||||
|
||||
it('dims tile when another slot is active', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 'other-slot', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('data-dimmed')).toBe('true');
|
||||
});
|
||||
|
||||
it('is not dimmed when no slot is active', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('data-dimmed')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flip interaction', () => {
|
||||
it('calls onflip with slot id when scene is clicked', async () => {
|
||||
const onflip = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
||||
await user.click(screen.getByTestId("day-meal-card-2026-04-14"));
|
||||
expect(onflip).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
|
||||
it('calls onflip when Enter key is pressed on scene', async () => {
|
||||
const onflip = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
||||
screen.getByTestId("day-meal-card-2026-04-14").focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onflip).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
|
||||
it('calls onflip when Space key is pressed on scene', async () => {
|
||||
const onflip = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
||||
screen.getByTestId("day-meal-card-2026-04-14").focus();
|
||||
await user.keyboard(' ');
|
||||
expect(onflip).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('back face (flipped state)', () => {
|
||||
it('shows recipe name on back face when flipped', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
// Back face should also show recipe name
|
||||
const names = screen.getAllByText('Pasta Bolognese');
|
||||
expect(names.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('shows Koch-Modus link on back face when flipped', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.getByRole('link', { name: /Koch-Modus/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Rezept ansehen link on back face when flipped', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows close button on back face', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.getByRole('button', { name: /Schließen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onclose when close button clicked', async () => {
|
||||
const onclose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onclose } });
|
||||
await user.click(screen.getByRole('button', { name: /Schließen/i }));
|
||||
expect(onclose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows Gericht tauschen button for planner on back face', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides Gericht tauschen for non-planner', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: false, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.queryByRole('button', { name: /Gericht tauschen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onswap when Gericht tauschen clicked', async () => {
|
||||
const onswap = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onswap } });
|
||||
await user.click(screen.getByRole('button', { name: /Gericht tauschen/i }));
|
||||
expect(onswap).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows Entfernen button for planner when slot has id', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides Entfernen button when slot.id is null (optimistic slot)', () => {
|
||||
const slotWithoutId = { ...filledSlot, id: null };
|
||||
render(DesktopDayTile, { props: { slot: slotWithoutId, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
// flip the tile first so back face is visible
|
||||
// activeSlotId must match slot.id to flip — but slot.id is null, so isFlipped = false
|
||||
// The back face is still rendered in the DOM; check button is absent
|
||||
expect(screen.queryByRole('button', { name: /Entfernen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onremove when Entfernen clicked', async () => {
|
||||
const onremove = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onremove } });
|
||||
await user.click(screen.getByRole('button', { name: /Entfernen/i }));
|
||||
expect(onremove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('aria-expanded is true when flipped', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('aria-expanded is false when not flipped', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DesktopDayTile — empty slot', () => {
|
||||
it('renders EmptyDayTile (shows Gericht wählen) for empty slot', () => {
|
||||
render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render Koch-Modus for empty slot', () => {
|
||||
render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.queryByRole('link', { name: /Koch-Modus/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
8
frontend/src/lib/planner/DesktopDayTile.utils.ts
Normal file
8
frontend/src/lib/planner/DesktopDayTile.utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Strips characters that could break out of a CSS url() context or inject
|
||||
* CSS into an inline style attribute. Safe data URIs (base64) are unaffected
|
||||
* as they contain only A-Z, a-z, 0-9, +, /, = and the data: prefix.
|
||||
*/
|
||||
export function sanitizeForCssUrl(url: string): string {
|
||||
return url.replace(/['"()\\]/g, '');
|
||||
}
|
||||
66
frontend/src/lib/planner/EffortBar.svelte
Normal file
66
frontend/src/lib/planner/EffortBar.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
easy,
|
||||
medium,
|
||||
hard
|
||||
}: {
|
||||
easy: number;
|
||||
medium: number;
|
||||
hard: number;
|
||||
} = $props();
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Labels below the bar -->
|
||||
<div class="space-y-2">
|
||||
<!-- Bar segments -->
|
||||
<div class="flex h-[10px] overflow-hidden rounded-full">
|
||||
{#if easy > 0}
|
||||
<div
|
||||
class="h-full bg-[var(--green)]"
|
||||
style="flex: {easy}"
|
||||
></div>
|
||||
{/if}
|
||||
{#if medium > 0}
|
||||
<div
|
||||
class="h-full bg-[var(--yellow)]"
|
||||
style="flex: {medium}"
|
||||
></div>
|
||||
{/if}
|
||||
{#if hard > 0}
|
||||
<div
|
||||
class="h-full bg-[var(--color-error)]"
|
||||
style="flex: {hard}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
<div class="flex gap-4">
|
||||
{#if easy > 0}
|
||||
<span
|
||||
data-testid="effort-easy"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--green-dark)]"
|
||||
>
|
||||
Einfach ×{easy}
|
||||
</span>
|
||||
{/if}
|
||||
{#if medium > 0}
|
||||
<span
|
||||
data-testid="effort-medium"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--yellow-text)]"
|
||||
>
|
||||
Mittel ×{medium}
|
||||
</span>
|
||||
{/if}
|
||||
{#if hard > 0}
|
||||
<span
|
||||
data-testid="effort-hard"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-error)]"
|
||||
>
|
||||
Aufwändig ×{hard}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
38
frontend/src/lib/planner/EffortBar.test.ts
Normal file
38
frontend/src/lib/planner/EffortBar.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import EffortBar from './EffortBar.svelte';
|
||||
|
||||
describe('EffortBar', () => {
|
||||
it('renders segment for easy effort', () => {
|
||||
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||
expect(screen.getByTestId('effort-easy').textContent).toContain('3');
|
||||
});
|
||||
|
||||
it('renders segment for medium effort', () => {
|
||||
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||
expect(screen.getByTestId('effort-medium').textContent).toContain('3');
|
||||
});
|
||||
|
||||
it('renders segment for hard effort', () => {
|
||||
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||
expect(screen.getByTestId('effort-hard').textContent).toContain('1');
|
||||
});
|
||||
|
||||
it('hides zero-count segments', () => {
|
||||
render(EffortBar, { props: { easy: 7, medium: 0, hard: 0 } });
|
||||
expect(screen.queryByTestId('effort-medium')).toBeNull();
|
||||
expect(screen.queryByTestId('effort-hard')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders label with ×N count', () => {
|
||||
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||
expect(screen.getByTestId('effort-easy').textContent).toContain('×3');
|
||||
});
|
||||
|
||||
it('renders no segments when all counts are zero', () => {
|
||||
render(EffortBar, { props: { easy: 0, medium: 0, hard: 0 } });
|
||||
expect(screen.queryByTestId('effort-easy')).toBeNull();
|
||||
expect(screen.queryByTestId('effort-medium')).toBeNull();
|
||||
expect(screen.queryByTestId('effort-hard')).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user