Compare commits
146 Commits
f33302e012
...
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 |
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();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -216,10 +216,6 @@ public class PlanningService {
|
||||
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<>();
|
||||
@@ -259,11 +255,16 @@ public class PlanningService {
|
||||
.mapToLong(c -> c - 1)
|
||||
.sum();
|
||||
|
||||
return applyPenalties(tagRepeatCount, ingredientOverlapCount, recentRepeatCount, duplicatePenaltyCount, config);
|
||||
}
|
||||
|
||||
private double applyPenalties(long tagRepeats, long ingredientOverlaps, long recentRepeats,
|
||||
long duplicates, VarietyScoreConfig config) {
|
||||
double score = MAX_VARIETY_SCORE;
|
||||
score -= tagRepeatCount * wTagRepeat;
|
||||
score -= ingredientOverlapCount * wIngredientOverlap;
|
||||
score -= recentRepeatCount * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -281,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
|
||||
@@ -352,13 +349,7 @@ public class PlanningService {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
double score = MAX_VARIETY_SCORE;
|
||||
score -= tagRepeats.size() * wTagRepeat;
|
||||
score -= overlaps.size() * wIngredientOverlap;
|
||||
score -= recentRepeats.size() * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
score = Math.max(0, Math.min(MAX_VARIETY_SCORE, score));
|
||||
double score = applyPenalties(tagRepeats.size(), overlaps.size(), recentRepeats.size(), duplicatePenaltyCount, config);
|
||||
|
||||
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
|
||||
}
|
||||
|
||||
@@ -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('%', CAST(:search AS string), '%')))
|
||||
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
|
||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
ORDER BY r.createdAt DESC
|
||||
""")
|
||||
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,
|
||||
@@ -45,13 +41,11 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
||||
AND r.deletedAt IS NULL
|
||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
|
||||
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
|
||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
""")
|
||||
long countFiltered(
|
||||
@Param("householdId") UUID householdId,
|
||||
@Param("search") String search,
|
||||
@Param("effort") String effort,
|
||||
@Param("isChildFriendly") Boolean isChildFriendly,
|
||||
@Param("cookTimeMaxMin") Integer cookTimeMaxMin);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
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
|
||||
@@ -2,3 +2,6 @@ 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 @@
|
||||
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;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ class ShoppingServiceTest {
|
||||
}
|
||||
|
||||
private Recipe testRecipe(Household household, String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
197
frontend/src/lib/api/schema.d.ts
vendored
197
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;
|
||||
@@ -552,7 +586,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
cookTimeMin?: number;
|
||||
effort: string;
|
||||
isChildFriendly?: boolean;
|
||||
heroImageUrl?: string;
|
||||
ingredients: components["schemas"]["IngredientEntry"][];
|
||||
steps?: components["schemas"]["StepEntry"][];
|
||||
@@ -587,7 +620,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
isChildFriendly?: boolean;
|
||||
heroImageUrl?: string;
|
||||
ingredients?: components["schemas"]["IngredientItem"][];
|
||||
steps?: components["schemas"]["StepItem"][];
|
||||
@@ -723,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"];
|
||||
};
|
||||
@@ -765,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"];
|
||||
@@ -934,8 +988,7 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
isChildFriendly?: boolean;
|
||||
heroImageUrl?: string;
|
||||
heroImagePreview?: string;
|
||||
};
|
||||
ApiResponseListAdminUserResponse: {
|
||||
status?: string;
|
||||
@@ -1322,7 +1375,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
acceptInvite: {
|
||||
getInviteInfo: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -1332,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: {
|
||||
@@ -1342,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: {
|
||||
@@ -2013,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 |
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'] }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
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, '');
|
||||
}
|
||||
73
frontend/src/lib/planner/EmptyDayTile.svelte
Normal file
73
frontend/src/lib/planner/EmptyDayTile.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { computeReasoningTags } from './reasoningTags';
|
||||
import { formatDayAbbr } from '$lib/planner/week';
|
||||
import type { Suggestion, SlotMap } from '$lib/planner/types';
|
||||
|
||||
let {
|
||||
slotDate,
|
||||
slotId,
|
||||
isPlanner,
|
||||
slotMap,
|
||||
topSuggestion,
|
||||
onaddrecipe
|
||||
}: {
|
||||
slotDate: string;
|
||||
slotId: string;
|
||||
isPlanner: boolean;
|
||||
slotMap: SlotMap;
|
||||
topSuggestion?: Suggestion;
|
||||
onaddrecipe?: () => void;
|
||||
} = $props();
|
||||
|
||||
let reasoningTags = $derived(
|
||||
topSuggestion ? computeReasoningTags(slotMap, topSuggestion.recipe) : []
|
||||
);
|
||||
|
||||
const dayAbbr = $derived(slotDate ? formatDayAbbr(slotDate, 'short') : '');
|
||||
const dateNum = $derived(slotDate ? slotDate.slice(-2).replace(/^0/, '') : '');
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="empty-day-tile"
|
||||
role="group"
|
||||
class="h-full flex flex-col overflow-hidden"
|
||||
style="border: 1.5px dashed var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: 0 1px 3px rgba(28,28,24,.06);"
|
||||
>
|
||||
<!-- Day header -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 7px 8px 0; flex-shrink: 0;">
|
||||
<span style="font-size: 9px; text-transform: uppercase; letter-spacing: .06em; color: var(--color-text-muted); font-weight: 500;">{dayAbbr}</span>
|
||||
<span style="font-size: 10px; font-weight: 500; color: var(--color-text-muted);">{dateNum}</span>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
{#if isPlanner}
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 8px 6px 6px; gap: 2px; flex-shrink: 0; border-bottom: 1px solid var(--color-border);">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Gericht wählen"
|
||||
onclick={() => onaddrecipe?.()}
|
||||
style="background: none; border: none; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px;"
|
||||
>
|
||||
<span style="font-size: 18px; color: var(--color-border); line-height: 1;">+</span>
|
||||
<span style="font-size: 9px; color: var(--color-text-muted); font-family: var(--font-sans);">Gericht wählen</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topSuggestion}
|
||||
<div style="display: flex; flex-direction: column; padding: 5px 7px 6px; flex: 1; overflow: hidden;">
|
||||
<div style="font-size: 8px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; color: var(--color-text-muted); padding: 3px 0 5px; border-bottom: 1px solid var(--color-subtle, var(--color-border)); margin-bottom: 2px;">Vorschlag</div>
|
||||
<div style="display: flex; align-items: center; gap: 4px; padding: 5px 0;">
|
||||
<span style="font-family: var(--font-display); font-size: 11px; font-weight: 300; color: var(--color-text); flex: 1; line-height: 1.2;">{topSuggestion.recipe.name}</span>
|
||||
{#each reasoningTags as tag (tag.id)}
|
||||
<span
|
||||
data-testid="reasoning-tag"
|
||||
style="font-size: 8px; font-weight: 500; padding: 1px 4px; border-radius: 2px; white-space: nowrap; flex-shrink: 0; {tag.color === 'green' ? 'background: var(--green-tint); color: var(--green-dark);' : 'background: var(--yellow-tint); color: var(--yellow-text);'}"
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
88
frontend/src/lib/planner/EmptyDayTile.test.ts
Normal file
88
frontend/src/lib/planner/EmptyDayTile.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import EmptyDayTile from './EmptyDayTile.svelte';
|
||||
|
||||
const slotDate = '2026-04-14';
|
||||
const slotId = 'slot-1';
|
||||
|
||||
const topSuggestionNewProtein = {
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
name: 'Lachs mit Gemüse',
|
||||
cookTimeMin: 20,
|
||||
effort: 'einfach',
|
||||
tags: [{ id: 't1', name: 'Fisch', tagType: 'protein' }]
|
||||
},
|
||||
scoreDelta: 3.2,
|
||||
hasConflict: false
|
||||
};
|
||||
|
||||
const slotMapEmpty = {};
|
||||
|
||||
describe('EmptyDayTile', () => {
|
||||
describe('base render', () => {
|
||||
it('shows + CTA for planner', () => {
|
||||
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } });
|
||||
expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides + CTA for non-planner', () => {
|
||||
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: false, slotMap: slotMapEmpty } });
|
||||
expect(screen.queryByRole('button', { name: /Gericht wählen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onaddrecipe when + CTA clicked', async () => {
|
||||
const onaddrecipe = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, onaddrecipe } });
|
||||
await user.click(screen.getByRole('button', { name: /Gericht wählen/i }));
|
||||
expect(onaddrecipe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('has data-testid="empty-day-tile"', () => {
|
||||
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } });
|
||||
expect(screen.getByTestId('empty-day-tile')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reasoning tags', () => {
|
||||
it('shows no tags when no topSuggestion', () => {
|
||||
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } });
|
||||
expect(screen.queryByTestId('reasoning-tag')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Neues Protein tag when topSuggestion has new protein', () => {
|
||||
render(EmptyDayTile, {
|
||||
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein }
|
||||
});
|
||||
expect(screen.getByText('Neues Protein')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Aufwand tag for easy suggestion', () => {
|
||||
render(EmptyDayTile, {
|
||||
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein }
|
||||
});
|
||||
expect(screen.getByText('Aufwand: leicht')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows suggestion recipe name when topSuggestion provided', () => {
|
||||
render(EmptyDayTile, {
|
||||
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein }
|
||||
});
|
||||
expect(screen.getByText('Lachs mit Gemüse')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show tags when suggestion has no matching conditions', () => {
|
||||
const heavySuggestion = {
|
||||
recipe: { id: 'r2', name: 'Roulade', cookTimeMin: 120, effort: 'aufwändig', tags: [] },
|
||||
scoreDelta: 1.0,
|
||||
hasConflict: false
|
||||
};
|
||||
render(EmptyDayTile, {
|
||||
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: heavySuggestion }
|
||||
});
|
||||
expect(screen.queryByTestId('reasoning-tag')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@
|
||||
suggestions = [],
|
||||
allRecipes = [],
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
excludeRecipeId,
|
||||
replacingRecipe,
|
||||
onpick
|
||||
}: {
|
||||
planId: string;
|
||||
@@ -16,15 +19,32 @@
|
||||
suggestions: Suggestion[];
|
||||
allRecipes: Recipe[];
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
excludeRecipeId?: string;
|
||||
replacingRecipe?: { name: string; meta?: string };
|
||||
onpick: (recipeId: string, recipeName: string) => void;
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
let topRecommendations = $derived(
|
||||
suggestions
|
||||
.filter((s) => s.scoreDelta > 0 && s.recipe.id !== excludeRecipeId)
|
||||
.slice(0, 5)
|
||||
);
|
||||
|
||||
let scoreMap = $derived(
|
||||
new Map(suggestions.map((s) => [s.recipe.id, s]))
|
||||
);
|
||||
|
||||
let baseRecipes = $derived(
|
||||
excludeRecipeId ? allRecipes.filter((r) => r.id !== excludeRecipeId) : allRecipes
|
||||
);
|
||||
|
||||
let filteredRecipes = $derived(
|
||||
searchQuery.trim() === ''
|
||||
? allRecipes
|
||||
: allRecipes.filter((r) =>
|
||||
? baseRecipes
|
||||
: baseRecipes.filter((r) =>
|
||||
r.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
@@ -39,8 +59,37 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet scoreBadge(recipeId: string, delta: number, hasConflict: boolean)}
|
||||
{#if delta > 0}
|
||||
<span
|
||||
data-testid="badge-{recipeId}"
|
||||
data-type="good"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
|
||||
>
|
||||
↑ +{delta.toFixed(1)} Punkte
|
||||
</span>
|
||||
{:else if hasConflict}
|
||||
<span
|
||||
data-testid="badge-{recipeId}"
|
||||
data-type="bad"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--red-tint, #fdecea); color: var(--color-error, #d9534f);"
|
||||
>
|
||||
↓ {delta.toFixed(1)} Punkte
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
data-testid="badge-{recipeId}"
|
||||
data-type="neutral"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--yellow-tint); color: var(--yellow-text);"
|
||||
>
|
||||
Kein Einfluss
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||
<!-- Header -->
|
||||
<!-- Header (hidden in swap context — the panel/sheet title already provides context) -->
|
||||
{#if !replacingRecipe}
|
||||
<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;">
|
||||
Rezept wählen
|
||||
@@ -49,6 +98,23 @@
|
||||
{dateLabel}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Wird ersetzt banner (swap context) -->
|
||||
{#if replacingRecipe}
|
||||
<div style="background: var(--orange-tint); border-bottom: 1px solid #FBCDA4; padding: 8px 12px;">
|
||||
<p style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--orange-dark); margin: 0 0 2px 0; font-family: var(--font-sans);">
|
||||
Wird ersetzt
|
||||
</p>
|
||||
<span
|
||||
data-testid="replacing-name"
|
||||
title={replacingRecipe.name}
|
||||
style="display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: 13px; text-decoration: line-through; opacity: 0.6; color: var(--color-text);"
|
||||
>
|
||||
{replacingRecipe.name}{#if replacingRecipe.meta} · {replacingRecipe.meta}{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search -->
|
||||
<div style="padding: 8px 12px; border-bottom: 1px solid var(--color-border);">
|
||||
@@ -81,14 +147,15 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if suggestions.length > 0}
|
||||
{:else if topRecommendations.length > 0}
|
||||
<div data-testid="empfohlen-section">
|
||||
<div
|
||||
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
Empfohlen · Beste Abwechslung
|
||||
</div>
|
||||
|
||||
{#each suggestions as suggestion (suggestion.recipe.id)}
|
||||
{#each topRecommendations as suggestion (suggestion.recipe.id)}
|
||||
{@const meta = recipeMetadata(suggestion.recipe)}
|
||||
<div
|
||||
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||
@@ -104,37 +171,24 @@
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
{#if (suggestion.scoreDelta ?? 0) > 0}
|
||||
<span
|
||||
data-testid="badge-{suggestion.recipe.id}"
|
||||
data-type="good"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
|
||||
>
|
||||
↑ +{(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte
|
||||
</span>
|
||||
{:else if suggestion.hasConflict}
|
||||
<span
|
||||
data-testid="badge-{suggestion.recipe.id}"
|
||||
data-type="bad"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--red-tint, #fdecea); color: var(--color-error, #d9534f);"
|
||||
>
|
||||
↓ {(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte
|
||||
</span>
|
||||
{/if}
|
||||
{@render scoreBadge(suggestion.recipe.id, suggestion.scoreDelta ?? 0, suggestion.hasConflict)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Wählen"
|
||||
onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)}
|
||||
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: pointer;"
|
||||
disabled={isDisabled}
|
||||
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: {isDisabled ? 'default' : 'pointer'}; opacity: {isDisabled ? '0.4' : '1'};"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Alle Rezepte section -->
|
||||
<div data-testid="alle-rezepte-section">
|
||||
<div
|
||||
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
@@ -148,6 +202,7 @@
|
||||
{:else}
|
||||
{#each filteredRecipes as recipe (recipe.id)}
|
||||
{@const meta = recipeMetadata(recipe)}
|
||||
{@const score = scoreMap.get(recipe.id)}
|
||||
<div
|
||||
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||
>
|
||||
@@ -162,18 +217,23 @@
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
{#if score}
|
||||
{@render scoreBadge(recipe.id, score.scoreDelta ?? 0, score.hasConflict)}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Wählen"
|
||||
onclick={() => onpick(recipe.id, recipe.name)}
|
||||
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: pointer;"
|
||||
disabled={isDisabled}
|
||||
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: {isDisabled ? 'default' : 'pointer'}; opacity: {isDisabled ? '0.4' : '1'};"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { render, screen, within } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import RecipePicker from './RecipePicker.svelte';
|
||||
|
||||
@@ -34,10 +34,12 @@ describe('RecipePicker', () => {
|
||||
expect(screen.getByText(/Empfohlen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows all suggestion recipe names', () => {
|
||||
it('shows only positive-delta suggestions in Empfohlen', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
// s1 (scoreDelta=1.5) appears in Empfohlen
|
||||
expect(screen.getByText('Lachsfilet')).toBeTruthy();
|
||||
expect(screen.getByText('Hähnchen-Curry')).toBeTruthy();
|
||||
// s2 (scoreDelta=-1.5) is excluded from Empfohlen; not in allRecipes either → absent
|
||||
expect(screen.queryByText('Hähnchen-Curry')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows green badge when hasConflict is false', () => {
|
||||
@@ -47,10 +49,15 @@ describe('RecipePicker', () => {
|
||||
expect(badge.getAttribute('data-type')).toBe('good');
|
||||
});
|
||||
|
||||
it('shows red delta badge when hasConflict is true', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
// Hähnchen-Curry: hasConflict = true, scoreDelta = -1.5 → red badge with delta
|
||||
const badge = screen.getByTestId('badge-s2');
|
||||
it('shows red delta badge in Alle Rezepte when hasConflict is true', () => {
|
||||
// r2 is in allRecipes; scoring it negative via suggestions → red badge in Alle Rezepte
|
||||
const withR2Scored = [
|
||||
...suggestions,
|
||||
{ recipe: { id: 'r2', name: 'Spaghetti Carbonara', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: -1.5, hasConflict: true }
|
||||
];
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: withR2Scored } });
|
||||
const alleRezepte = screen.getByTestId('alle-rezepte-section');
|
||||
const badge = within(alleRezepte).getByTestId('badge-r2');
|
||||
expect(badge.getAttribute('data-type')).toBe('bad');
|
||||
expect(badge.textContent).toContain('-1.5');
|
||||
});
|
||||
@@ -87,8 +94,8 @@ describe('RecipePicker', () => {
|
||||
const onpick = vi.fn();
|
||||
render(RecipePicker, { props: { ...baseProps, onpick } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
// First 2 are suggestions, rest are allRecipes
|
||||
await userEvent.click(buttons[2]);
|
||||
// First 1 is the positive-delta suggestion (s1), rest are allRecipes
|
||||
await userEvent.click(buttons[1]);
|
||||
expect(onpick).toHaveBeenCalledWith('r1', 'Beef Bourguignon');
|
||||
});
|
||||
|
||||
@@ -99,12 +106,61 @@ describe('RecipePicker', () => {
|
||||
expect(screen.getByText(/Keine Treffer/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows no badge when scoreDelta is zero (neutral, no improvement)', () => {
|
||||
it('shows yellow neutral badge in Alle Rezepte when scoreDelta is zero', () => {
|
||||
// r1 is in allRecipes; scoring it neutral via suggestions → yellow badge in Alle Rezepte
|
||||
const neutralSuggestions = [
|
||||
{ recipe: { id: 'sn', name: 'Neutrales Rezept', effort: 'easy', cookTimeMin: 20 }, scoreDelta: 0.0, hasConflict: false }
|
||||
{ recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard' as const, cookTimeMin: 150 }, scoreDelta: 0.0, hasConflict: false }
|
||||
];
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: neutralSuggestions } });
|
||||
expect(screen.queryByTestId('badge-sn')).toBeNull();
|
||||
const alleRezepte = screen.getByTestId('alle-rezepte-section');
|
||||
const badge = within(alleRezepte).getByTestId('badge-r1');
|
||||
expect(badge.getAttribute('data-type')).toBe('neutral');
|
||||
expect(badge.textContent).toContain('Kein Einfluss');
|
||||
});
|
||||
|
||||
it('Empfohlen shows only positive-delta suggestions, capped at 5', () => {
|
||||
const sixImproving = Array.from({ length: 6 }, (_, i) => ({
|
||||
recipe: { id: `imp${i}`, name: `Improving ${i}`, effort: 'easy' as const, cookTimeMin: 20 },
|
||||
scoreDelta: 1.0,
|
||||
hasConflict: false
|
||||
}));
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: sixImproving } });
|
||||
const empfohlen = screen.getByTestId('empfohlen-section');
|
||||
const buttons = empfohlen.querySelectorAll('button');
|
||||
expect(buttons).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('Empfohlen excludes neutral and negative suggestions', () => {
|
||||
const mixed = [
|
||||
{ recipe: { id: 'pos', name: 'Positiv', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: 1.0, hasConflict: false },
|
||||
{ recipe: { id: 'neu', name: 'Neutral', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: 0.0, hasConflict: false },
|
||||
{ recipe: { id: 'neg', name: 'Negativ', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: -1.0, hasConflict: true }
|
||||
];
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: mixed } });
|
||||
const empfohlen = screen.getByTestId('empfohlen-section');
|
||||
expect(empfohlen.textContent).toContain('Positiv');
|
||||
expect(empfohlen.textContent).not.toContain('Neutral');
|
||||
expect(empfohlen.textContent).not.toContain('Negativ');
|
||||
});
|
||||
|
||||
it('shows score badge inside Alle Rezepte for a recipe that has a matching suggestion', () => {
|
||||
// r1 is in allRecipes; scoreDelta=-0.3 → not in Empfohlen (needs >0), but scoreMap provides badge
|
||||
const withR1Scored = [
|
||||
...suggestions,
|
||||
{ recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard' as const, cookTimeMin: 150 }, scoreDelta: -0.3, hasConflict: true }
|
||||
];
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: withR1Scored } });
|
||||
const alleRezepte = screen.getByTestId('alle-rezepte-section');
|
||||
const badge = within(alleRezepte).getByTestId('badge-r1');
|
||||
expect(badge.getAttribute('data-type')).toBe('bad');
|
||||
});
|
||||
|
||||
it('shows no badge in Alle Rezepte for recipes with no suggestion score', () => {
|
||||
// r2 and r3 have no suggestion entry
|
||||
render(RecipePicker, { props: baseProps });
|
||||
const alleRezepte = screen.getByTestId('alle-rezepte-section');
|
||||
expect(within(alleRezepte).queryByTestId('badge-r2')).toBeNull();
|
||||
expect(within(alleRezepte).queryByTestId('badge-r3')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows loading skeleton instead of Empfohlen section when isLoading is true', () => {
|
||||
@@ -118,4 +174,50 @@ describe('RecipePicker', () => {
|
||||
expect(screen.queryByTestId('suggestions-loading')).toBeNull();
|
||||
expect(screen.getByText(/Empfohlen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Wird ersetzt banner when replacingRecipe is provided', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, replacingRecipe: { name: 'Pasta', meta: '20 Min · easy' } } });
|
||||
expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
|
||||
expect(screen.getByTestId('replacing-name').textContent).toContain('Pasta');
|
||||
});
|
||||
|
||||
it('hides Wird ersetzt banner when replacingRecipe is not provided', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
expect(screen.queryByText(/Wird ersetzt/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('hides Rezept wählen header when replacingRecipe is set', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, replacingRecipe: { name: 'Pasta' } } });
|
||||
expect(screen.queryByText(/Rezept wählen/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Rezept wählen header when replacingRecipe is not set', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
expect(screen.getByText(/Rezept wählen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('excludes recipe from Alle Rezepte when excludeRecipeId is set', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 'r2' } });
|
||||
expect(screen.queryByText('Spaghetti Carbonara')).toBeNull();
|
||||
expect(screen.getByText('Beef Bourguignon')).toBeTruthy();
|
||||
expect(screen.getByText('Tomatensuppe')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('excludes recipe from Empfohlen when excludeRecipeId matches a positive-delta suggestion', () => {
|
||||
// s1 (Lachsfilet, scoreDelta=1.5) would normally appear in Empfohlen
|
||||
render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 's1' } });
|
||||
expect(screen.queryByText('Lachsfilet')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables Wählen buttons when isDisabled is true', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, isDisabled: true } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true));
|
||||
});
|
||||
|
||||
it('enables Wählen buttons when isDisabled is false', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, isDisabled: false } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false));
|
||||
});
|
||||
});
|
||||
|
||||
79
frontend/src/lib/planner/RecipePickerDrawer.svelte
Normal file
79
frontend/src/lib/planner/RecipePickerDrawer.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { Recipe, Suggestion } from '$lib/planner/types';
|
||||
import RecipePicker from './RecipePicker.svelte';
|
||||
|
||||
let {
|
||||
open,
|
||||
slotDate,
|
||||
planId,
|
||||
suggestions,
|
||||
allRecipes,
|
||||
isLoading,
|
||||
onpick,
|
||||
onclose,
|
||||
excludeRecipeId,
|
||||
replacingRecipe
|
||||
}: {
|
||||
open: boolean;
|
||||
slotDate: string;
|
||||
planId: string;
|
||||
suggestions: Suggestion[];
|
||||
allRecipes: Recipe[];
|
||||
isLoading: boolean;
|
||||
onpick: (recipeId: string, recipeName: string) => void;
|
||||
onclose: () => void;
|
||||
excludeRecipeId?: string;
|
||||
replacingRecipe?: { name: string; meta?: string };
|
||||
} = $props();
|
||||
|
||||
let drawerTransform = $derived(open ? 'translateX(0)' : 'translateX(100%)');
|
||||
let backdropVisibility = $derived(open ? 'visible' : 'hidden');
|
||||
let backdropOpacity = $derived(open ? '1' : '0');
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
data-testid="drawer-backdrop"
|
||||
aria-hidden="true"
|
||||
onclick={onclose}
|
||||
style="position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 40; visibility: {backdropVisibility}; opacity: {backdropOpacity}; transition: opacity 0.2s, visibility 0.2s;"
|
||||
></div>
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<div
|
||||
data-testid="recipe-picker-drawer"
|
||||
aria-hidden={!open}
|
||||
style="position: fixed; right: 0; top: 0; height: 100%; width: min(480px, 90vw); background: var(--color-page); border-left: 1px solid var(--color-border); z-index: 50; transform: {drawerTransform}; transition: transform 0.25s ease; display: flex; flex-direction: column;"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--color-border); flex-shrink: 0;">
|
||||
<p style="margin: 0; font-family: var(--font-display); font-size: 15px; font-weight: 500; color: var(--color-text);">
|
||||
Rezept wählen
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Schließen"
|
||||
onclick={onclose}
|
||||
style="background: none; border: none; cursor: pointer; font-size: 20px; line-height: 1; color: var(--color-text-muted); padding: 4px 8px;"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RecipePicker content — only mount when open to avoid duplicate text in DOM -->
|
||||
<div style="overflow-y: auto; flex: 1;">
|
||||
{#if open}
|
||||
<RecipePicker
|
||||
{planId}
|
||||
date={slotDate}
|
||||
dateLabel={slotDate}
|
||||
{suggestions}
|
||||
{allRecipes}
|
||||
{isLoading}
|
||||
{onpick}
|
||||
{excludeRecipeId}
|
||||
{replacingRecipe}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
81
frontend/src/lib/planner/RecipePickerDrawer.test.ts
Normal file
81
frontend/src/lib/planner/RecipePickerDrawer.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import RecipePickerDrawer from './RecipePickerDrawer.svelte';
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
slotDate: '2026-04-14',
|
||||
planId: 'plan-1',
|
||||
suggestions: [],
|
||||
allRecipes: [
|
||||
{ id: 'r1', name: 'Pasta Bolognese', cookTimeMin: 45, effort: 'mittel' },
|
||||
{ id: 'r2', name: 'Lachs', cookTimeMin: 20, effort: 'einfach' }
|
||||
],
|
||||
isLoading: false,
|
||||
onpick: vi.fn(),
|
||||
onclose: vi.fn()
|
||||
};
|
||||
|
||||
describe('RecipePickerDrawer', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
describe('visibility', () => {
|
||||
it('renders drawer content when open=true', () => {
|
||||
render(RecipePickerDrawer, { props: baseProps });
|
||||
expect(screen.getByTestId('recipe-picker-drawer')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drawer is not visible when open=false', () => {
|
||||
render(RecipePickerDrawer, { props: { ...baseProps, open: false } });
|
||||
const drawer = screen.getByTestId('recipe-picker-drawer');
|
||||
// Drawer exists in DOM but should be off-screen / aria-hidden
|
||||
expect(drawer.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders recipe list inside drawer', () => {
|
||||
render(RecipePickerDrawer, { props: baseProps });
|
||||
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backdrop', () => {
|
||||
it('renders backdrop when open', () => {
|
||||
render(RecipePickerDrawer, { props: baseProps });
|
||||
expect(screen.getByTestId('drawer-backdrop')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onclose when backdrop is clicked', async () => {
|
||||
const onclose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(RecipePickerDrawer, { props: { ...baseProps, onclose } });
|
||||
await user.click(screen.getByTestId('drawer-backdrop'));
|
||||
expect(onclose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('close button', () => {
|
||||
it('renders a close button inside the drawer', () => {
|
||||
render(RecipePickerDrawer, { props: baseProps });
|
||||
expect(screen.getByRole('button', { name: /schließen|close/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onclose when close button clicked', async () => {
|
||||
const onclose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(RecipePickerDrawer, { props: { ...baseProps, onclose } });
|
||||
await user.click(screen.getByRole('button', { name: /schließen|close/i }));
|
||||
expect(onclose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recipe picking', () => {
|
||||
it('calls onpick when a recipe is selected', async () => {
|
||||
const onpick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(RecipePickerDrawer, { props: { ...baseProps, onpick } });
|
||||
const pickButtons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
await user.click(pickButtons[0]);
|
||||
expect(onpick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
effort?: string | null;
|
||||
cookTimeMin?: number | null;
|
||||
}
|
||||
|
||||
let {
|
||||
replacingName,
|
||||
replacingMeta,
|
||||
recipes,
|
||||
currentWeekRecipeIds,
|
||||
excludeRecipeId,
|
||||
isLoading = false,
|
||||
onpick,
|
||||
oncancel
|
||||
}: {
|
||||
replacingName: string;
|
||||
replacingMeta?: string;
|
||||
recipes: Recipe[];
|
||||
currentWeekRecipeIds: Set<string>;
|
||||
excludeRecipeId?: string;
|
||||
isLoading?: boolean;
|
||||
onpick: (recipeId: string, recipeName: string) => void;
|
||||
oncancel?: () => void;
|
||||
} = $props();
|
||||
|
||||
let visibleRecipes = $derived(
|
||||
excludeRecipeId ? recipes.filter((r) => r.id !== excludeRecipeId) : recipes
|
||||
);
|
||||
|
||||
function recipeMeta(recipe: Recipe): string {
|
||||
return [
|
||||
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} min` : null,
|
||||
recipe.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Replacing banner -->
|
||||
<div
|
||||
style="background: var(--orange-tint); border: 1px solid #FBCDA4; border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 14px;"
|
||||
>
|
||||
<p
|
||||
style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--orange-dark); margin: 0 0 4px 0; font-family: var(--font-sans);"
|
||||
>
|
||||
Wird ersetzt
|
||||
</p>
|
||||
<span
|
||||
data-testid="replacing-name"
|
||||
title={replacingName}
|
||||
style="display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: 14px; text-decoration: line-through; opacity: 0.6; color: var(--color-text);"
|
||||
>
|
||||
{replacingName}{#if replacingMeta} · {replacingMeta}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Eyebrow label -->
|
||||
<p
|
||||
style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); margin: 0 0 6px 0; font-family: var(--font-sans);"
|
||||
>
|
||||
Ersetzen durch (einfachste zuerst)
|
||||
</p>
|
||||
|
||||
<!-- Recipe list -->
|
||||
{#if visibleRecipes.length === 0}
|
||||
<p
|
||||
data-testid="swap-empty-state"
|
||||
style="text-align: center; color: var(--color-text-muted); font-family: var(--font-sans); margin: 0;"
|
||||
>
|
||||
Keine Rezepte verfügbar.
|
||||
</p>
|
||||
{:else}
|
||||
{#each visibleRecipes as recipe (recipe.id)}
|
||||
{@const meta = recipeMeta(recipe)}
|
||||
{@const alreadyPlanned = currentWeekRecipeIds.has(recipe.id)}
|
||||
<div
|
||||
style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 6px; display: flex; align-items: center; gap: 8px;"
|
||||
>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 13px; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
{recipe.name}
|
||||
</p>
|
||||
{#if meta}
|
||||
<p
|
||||
style="font-size: 9px; color: var(--color-text-muted); font-family: var(--font-sans); margin: 1px 0 0;"
|
||||
>
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
{#if alreadyPlanned}
|
||||
<p
|
||||
data-testid="already-planned-{recipe.id}"
|
||||
style="font-size: 9px; color: var(--yellow-text); font-family: var(--font-sans); margin: 1px 0 0;"
|
||||
>
|
||||
⚠ Bereits diese Woche
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onpick(recipe.id, recipe.name)}
|
||||
disabled={isLoading}
|
||||
style="background: none; border: none; cursor: {isLoading ? 'default' : 'pointer'}; font-size: 11px; font-weight: 500; color: var(--green); font-family: var(--font-sans); flex-shrink: 0; opacity: {isLoading ? '0.4' : '1'};"
|
||||
>
|
||||
Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Cancel button (optional) -->
|
||||
{#if oncancel}
|
||||
<button
|
||||
type="button"
|
||||
onclick={oncancel}
|
||||
style="width: 100%; background: none; border: none; cursor: pointer; color: var(--color-text-muted); font-size: 13px; text-align: center; padding: 8px 0; font-family: var(--font-sans);"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import SwapSuggestionList from './SwapSuggestionList.svelte';
|
||||
|
||||
const recipes = [
|
||||
{ id: 'r1', name: 'Quick carbonara', effort: 'easy', cookTimeMin: 20 },
|
||||
{ id: 'r2', name: 'Chicken stir-fry', effort: 'easy', cookTimeMin: 25 },
|
||||
{ id: 'r3', name: 'Mushroom risotto', effort: 'medium', cookTimeMin: 50 }
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
replacingName: 'Tomato pasta',
|
||||
replacingMeta: '45 min · Easy',
|
||||
recipes,
|
||||
currentWeekRecipeIds: new Set<string>(),
|
||||
onpick: vi.fn()
|
||||
};
|
||||
|
||||
describe('SwapSuggestionList', () => {
|
||||
it('renders the Replacing banner', () => {
|
||||
render(SwapSuggestionList, { props: baseProps });
|
||||
expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders old meal name with strikethrough', () => {
|
||||
render(SwapSuggestionList, { props: baseProps });
|
||||
const struck = screen.getByTestId('replacing-name');
|
||||
expect(struck.textContent).toContain('Tomato pasta');
|
||||
expect(getComputedStyle(struck).textDecoration || struck.style.textDecoration).toContain('line-through');
|
||||
});
|
||||
|
||||
it('replacing-name span has title attribute for full name', () => {
|
||||
render(SwapSuggestionList, { props: baseProps });
|
||||
const struck = screen.getByTestId('replacing-name');
|
||||
expect(struck.getAttribute('title')).toBe('Tomato pasta');
|
||||
});
|
||||
|
||||
it('renders the easiest-first eyebrow label', () => {
|
||||
render(SwapSuggestionList, { props: baseProps });
|
||||
expect(screen.getByText(/einfachste zuerst/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders all recipe names', () => {
|
||||
render(SwapSuggestionList, { props: baseProps });
|
||||
expect(screen.getByText('Quick carbonara')).toBeTruthy();
|
||||
expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
|
||||
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicking Wählen calls onpick with recipeId and name', async () => {
|
||||
const onpick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(SwapSuggestionList, { props: { ...baseProps, onpick } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
await user.click(buttons[0]);
|
||||
expect(onpick).toHaveBeenCalledWith('r1', 'Quick carbonara');
|
||||
});
|
||||
|
||||
it('shows already-planned warning for recipes in currentWeekRecipeIds', () => {
|
||||
render(SwapSuggestionList, {
|
||||
props: { ...baseProps, currentWeekRecipeIds: new Set(['r2']) }
|
||||
});
|
||||
expect(screen.getByTestId('already-planned-r2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show already-planned warning for recipes not in currentWeekRecipeIds', () => {
|
||||
render(SwapSuggestionList, { props: baseProps });
|
||||
expect(screen.queryByTestId('already-planned-r1')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows empty state when no recipes', () => {
|
||||
render(SwapSuggestionList, { props: { ...baseProps, recipes: [] } });
|
||||
expect(screen.getByTestId('swap-empty-state')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('excludes the recipe being replaced when excludeRecipeId is provided', () => {
|
||||
render(SwapSuggestionList, { props: { ...baseProps, excludeRecipeId: 'r2' } });
|
||||
expect(screen.queryByText('Chicken stir-fry')).toBeNull();
|
||||
expect(screen.getByText('Quick carbonara')).toBeTruthy();
|
||||
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows all recipes when excludeRecipeId is not provided', () => {
|
||||
render(SwapSuggestionList, { props: baseProps });
|
||||
expect(screen.getByText('Quick carbonara')).toBeTruthy();
|
||||
expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
|
||||
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('disables all Wählen buttons when isLoading is true', () => {
|
||||
render(SwapSuggestionList, { props: { ...baseProps, isLoading: true } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true));
|
||||
});
|
||||
|
||||
it('Wählen buttons are enabled when isLoading is false', () => {
|
||||
render(SwapSuggestionList, { props: { ...baseProps, isLoading: false } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false));
|
||||
});
|
||||
|
||||
it('renders optional Abbrechen button when oncancel provided', () => {
|
||||
render(SwapSuggestionList, { props: { ...baseProps, oncancel: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render Abbrechen button when oncancel not provided', () => {
|
||||
render(SwapSuggestionList, { props: baseProps });
|
||||
expect(screen.queryByRole('button', { name: /Abbrechen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('clicking Abbrechen calls oncancel', async () => {
|
||||
const oncancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(SwapSuggestionList, { props: { ...baseProps, oncancel } });
|
||||
await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
|
||||
expect(oncancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,51 @@
|
||||
<script lang="ts">
|
||||
interface Warning {
|
||||
title: string;
|
||||
explanation: string;
|
||||
interface WarningItem {
|
||||
dayShort: string;
|
||||
recipeName: string;
|
||||
slotId: number;
|
||||
}
|
||||
|
||||
let { warnings }: { warnings: Warning[] } = $props();
|
||||
interface ActionWarning {
|
||||
title: string;
|
||||
items: WarningItem[];
|
||||
}
|
||||
|
||||
let { warnings, weekStart }: { warnings: ActionWarning[]; weekStart: string } = $props();
|
||||
</script>
|
||||
|
||||
{#each warnings as warning}
|
||||
{#each warnings as warning (warning.title)}
|
||||
<div
|
||||
data-testid="warning-card"
|
||||
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] px-4 py-3"
|
||||
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] overflow-hidden"
|
||||
>
|
||||
<!-- Header row -->
|
||||
<div class="px-4 py-2.5 border-b border-[var(--yellow-light)]">
|
||||
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
|
||||
{warning.title}
|
||||
</p>
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{warning.explanation}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Item rows -->
|
||||
{#each warning.items as item (item.slotId)}
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-[var(--yellow-light)] last:border-b-0">
|
||||
<!-- Left: day label + recipe name -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-[var(--font-sans)] text-[11px] font-medium text-[var(--yellow-text)] w-6 flex-shrink-0">
|
||||
{item.dayShort}
|
||||
</span>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
|
||||
{item.recipeName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Right: swap link -->
|
||||
<a
|
||||
href="/planner?week={weekStart}&swap={item.slotId}"
|
||||
class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--yellow-text)] flex-shrink-0 hover:underline"
|
||||
>
|
||||
Tauschen →
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -3,30 +3,44 @@ import { render, screen } from '@testing-library/svelte';
|
||||
import VarietyWarningCards from './VarietyWarningCards.svelte';
|
||||
|
||||
const warnings = [
|
||||
{ title: 'Chicken zweimal diese Woche', explanation: 'Mo, Mi — erwäge einen Tausch.' },
|
||||
{ title: 'Tomaten in 3 Gerichten', explanation: 'Mo, Di, Mi — sorge für Abwechslung.' }
|
||||
{
|
||||
title: 'Chicken zweimal diese Woche',
|
||||
items: [
|
||||
{ dayShort: 'Mo', recipeName: 'Chicken Tikka', slotId: 1 },
|
||||
{ dayShort: 'Mi', recipeName: 'Chicken Curry', slotId: 3 }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Tomaten in 3 Gerichten',
|
||||
items: [
|
||||
{ dayShort: 'Mo', recipeName: 'Pasta Pomodoro', slotId: 1 },
|
||||
{ dayShort: 'Di', recipeName: 'Tomatensuppe', slotId: 2 },
|
||||
{ dayShort: 'Mi', recipeName: 'Pizza Margherita', slotId: 3 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
describe('VarietyWarningCards', () => {
|
||||
it('renders one card per warning', () => {
|
||||
render(VarietyWarningCards, { props: { warnings } });
|
||||
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
|
||||
const cards = screen.getAllByTestId('warning-card');
|
||||
expect(cards.length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders warning titles', () => {
|
||||
render(VarietyWarningCards, { props: { warnings } });
|
||||
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
|
||||
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
|
||||
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders warning explanations', () => {
|
||||
render(VarietyWarningCards, { props: { warnings } });
|
||||
expect(screen.getByText(/erwäge einen Tausch/)).toBeTruthy();
|
||||
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
|
||||
expect(screen.getByText('Chicken Tikka')).toBeTruthy();
|
||||
expect(screen.getByText('Chicken Curry')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nothing when warnings is empty', () => {
|
||||
render(VarietyWarningCards, { props: { warnings: [] } });
|
||||
render(VarietyWarningCards, { props: { warnings: [], weekStart: '2026-04-07' } });
|
||||
expect(screen.queryAllByTestId('warning-card').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
106
frontend/src/lib/planner/reasoningTags.test.ts
Normal file
106
frontend/src/lib/planner/reasoningTags.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeReasoningTags, type ReasoningTag } from './reasoningTags';
|
||||
|
||||
// SlotMap fixture helpers
|
||||
const emptySlotMap = {};
|
||||
|
||||
const slotMapWithChicken = {
|
||||
'2026-04-07': { id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Chicken curry', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] } },
|
||||
};
|
||||
|
||||
const slotMapWithBeefAndChicken = {
|
||||
'2026-04-07': { id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Steak', tags: [{ id: 't2', name: 'Rind', tagType: 'protein' }] } },
|
||||
'2026-04-08': { id: 's2', slotDate: '2026-04-08', recipe: { id: 'r2', name: 'Chicken', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] } },
|
||||
};
|
||||
|
||||
const fishRecipe = { id: 'r3', name: 'Lachs', cookTimeMin: 20, effort: 'einfach', tags: [{ id: 't3', name: 'Fisch', tagType: 'protein' }] };
|
||||
const chickenRecipe = { id: 'r1', name: 'Chicken curry', cookTimeMin: 45, effort: 'mittel', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] };
|
||||
const easyRecipe = { id: 'r4', name: 'Salat', cookTimeMin: 15, effort: 'einfach', tags: [] };
|
||||
const heavyRecipe = { id: 'r5', name: 'Roulade', cookTimeMin: 90, effort: 'aufwändig', tags: [] };
|
||||
|
||||
describe('computeReasoningTags', () => {
|
||||
describe('Neues Protein tag', () => {
|
||||
it('returns Neues Protein tag when recipe protein is not in week', () => {
|
||||
const tags = computeReasoningTags(slotMapWithChicken, fishRecipe);
|
||||
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||
expect(tagTypes).toContain('neues-protein');
|
||||
});
|
||||
|
||||
it('does not return Neues Protein when recipe protein is already in week', () => {
|
||||
const tags = computeReasoningTags(slotMapWithChicken, chickenRecipe);
|
||||
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||
expect(tagTypes).not.toContain('neues-protein');
|
||||
});
|
||||
|
||||
it('returns Neues Protein when recipe has protein tag and slotMap is empty', () => {
|
||||
const tags = computeReasoningTags(emptySlotMap, fishRecipe);
|
||||
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||
expect(tagTypes).toContain('neues-protein');
|
||||
});
|
||||
|
||||
it('does not return Neues Protein when recipe has no protein tag', () => {
|
||||
const tags = computeReasoningTags(emptySlotMap, easyRecipe);
|
||||
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||
expect(tagTypes).not.toContain('neues-protein');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aufwand: leicht tag', () => {
|
||||
it('returns Aufwand tag when cookTimeMin is less than 30', () => {
|
||||
const tags = computeReasoningTags(emptySlotMap, easyRecipe);
|
||||
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||
expect(tagTypes).toContain('aufwand-leicht');
|
||||
});
|
||||
|
||||
it('returns Aufwand tag when effort is einfach regardless of cookTime', () => {
|
||||
const recipe = { ...fishRecipe, cookTimeMin: 45 };
|
||||
const tags = computeReasoningTags(emptySlotMap, recipe);
|
||||
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||
expect(tagTypes).toContain('aufwand-leicht');
|
||||
});
|
||||
|
||||
it('does not return Aufwand tag for heavy recipe', () => {
|
||||
const tags = computeReasoningTags(emptySlotMap, heavyRecipe);
|
||||
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||
expect(tagTypes).not.toContain('aufwand-leicht');
|
||||
});
|
||||
|
||||
it('returns Aufwand tag exactly at cookTimeMin 29', () => {
|
||||
const recipe = { ...heavyRecipe, cookTimeMin: 29, effort: undefined };
|
||||
const tags = computeReasoningTags(emptySlotMap, recipe);
|
||||
expect(tags.map((t: ReasoningTag) => t.id)).toContain('aufwand-leicht');
|
||||
});
|
||||
|
||||
it('does not return Aufwand tag at cookTimeMin 30 with non-easy effort', () => {
|
||||
const recipe = { ...heavyRecipe, cookTimeMin: 30, effort: 'mittel' };
|
||||
const tags = computeReasoningTags(emptySlotMap, recipe);
|
||||
expect(tags.map((t: ReasoningTag) => t.id)).not.toContain('aufwand-leicht');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReasoningTag shape', () => {
|
||||
it('each tag has id, label, and color', () => {
|
||||
const tags = computeReasoningTags(emptySlotMap, fishRecipe);
|
||||
for (const tag of tags) {
|
||||
expect(tag).toHaveProperty('id');
|
||||
expect(tag).toHaveProperty('label');
|
||||
expect(tag).toHaveProperty('color');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple tags', () => {
|
||||
it('returns multiple tags when multiple conditions are true', () => {
|
||||
const recipe = { id: 'r6', name: 'Easy fish', cookTimeMin: 20, effort: 'einfach', tags: [{ id: 't3', name: 'Fisch', tagType: 'protein' }] };
|
||||
const tags = computeReasoningTags(slotMapWithBeefAndChicken, recipe);
|
||||
const tagIds = tags.map((t: ReasoningTag) => t.id);
|
||||
expect(tagIds).toContain('neues-protein');
|
||||
expect(tagIds).toContain('aufwand-leicht');
|
||||
});
|
||||
|
||||
it('returns empty array when no conditions are true', () => {
|
||||
const tags = computeReasoningTags(slotMapWithChicken, { ...chickenRecipe, cookTimeMin: 60, effort: 'aufwändig' });
|
||||
expect(tags).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/planner/reasoningTags.ts
Normal file
38
frontend/src/lib/planner/reasoningTags.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Recipe, Slot, SlotMap } from '$lib/planner/types';
|
||||
|
||||
export interface ReasoningTag {
|
||||
id: 'neues-protein' | 'aufwand-leicht';
|
||||
label: string;
|
||||
color: 'green' | 'yellow';
|
||||
}
|
||||
|
||||
export function computeReasoningTags(slotMap: SlotMap, recipe: Recipe): ReasoningTag[] {
|
||||
const tags: ReasoningTag[] = [];
|
||||
|
||||
// Neues Protein: recipe has a protein tag not already present in any filled slot
|
||||
const recipeProtein = recipe.tags?.find((t) => t.tagType === 'protein')?.name;
|
||||
if (recipeProtein) {
|
||||
const weekProteins = new Set<string>();
|
||||
for (const slot of Object.values(slotMap)) {
|
||||
if (slot.recipe) {
|
||||
for (const tag of slot.recipe.tags ?? []) {
|
||||
if (tag.tagType === 'protein' && tag.name) {
|
||||
weekProteins.add(tag.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!weekProteins.has(recipeProtein)) {
|
||||
tags.push({ id: 'neues-protein', label: 'Neues Protein', color: 'green' });
|
||||
}
|
||||
}
|
||||
|
||||
// Aufwand: leicht — cookTimeMin < 30 OR effort is 'einfach'/'leicht'
|
||||
const isEasyEffort = recipe.effort === 'einfach' || recipe.effort === 'leicht';
|
||||
const isQuick = recipe.cookTimeMin != null && recipe.cookTimeMin < 30;
|
||||
if (isEasyEffort || isQuick) {
|
||||
tags.push({ id: 'aufwand-leicht', label: 'Aufwand: leicht', color: 'yellow' });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
@@ -1,10 +1,26 @@
|
||||
export interface TagItem {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tagType?: string;
|
||||
}
|
||||
|
||||
export interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
heroImageUrl?: string | null;
|
||||
tags?: TagItem[];
|
||||
}
|
||||
|
||||
export interface Slot {
|
||||
id?: string | null;
|
||||
slotDate?: string;
|
||||
recipe?: Recipe | null;
|
||||
}
|
||||
|
||||
export type SlotMap = Record<string, Slot>;
|
||||
|
||||
export interface Suggestion {
|
||||
recipe: Recipe;
|
||||
scoreDelta: number;
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
data-testid="image-area"
|
||||
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
|
||||
>
|
||||
{#if recipe.heroImageUrl}
|
||||
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
|
||||
{#if recipe.heroImagePreview}
|
||||
<img src={recipe.heroImagePreview} alt={recipe.name} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
data-testid="image-placeholder"
|
||||
|
||||
@@ -8,7 +8,7 @@ const mockRecipe = {
|
||||
name: 'Spaghetti Bolognese',
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined
|
||||
heroImagePreview: undefined
|
||||
};
|
||||
|
||||
describe('RecipeCard', () => {
|
||||
@@ -27,18 +27,18 @@ describe('RecipeCard', () => {
|
||||
expect(screen.getByText(/easy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows placeholder when no heroImageUrl', () => {
|
||||
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } });
|
||||
it('shows placeholder when no heroImagePreview', () => {
|
||||
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImagePreview: undefined } } });
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows image when heroImageUrl is provided', () => {
|
||||
it('shows image when heroImagePreview is provided', () => {
|
||||
render(RecipeCard, {
|
||||
props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } }
|
||||
props: { recipe: { ...mockRecipe, heroImagePreview: 'data:image/jpeg;base64,abc' } }
|
||||
});
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', '/uploads/test.jpg');
|
||||
expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc');
|
||||
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
|
||||
});
|
||||
|
||||
|
||||
@@ -23,13 +23,30 @@
|
||||
} = $props();
|
||||
|
||||
const effortOptions = [
|
||||
{ label: 'Leicht', value: 'Easy' },
|
||||
{ label: 'Mittel', value: 'Medium' },
|
||||
{ label: 'Schwer', value: 'Hard' }
|
||||
{ label: 'Leicht', value: 'easy' },
|
||||
{ label: 'Mittel', value: 'medium' },
|
||||
{ label: 'Schwer', value: 'hard' }
|
||||
];
|
||||
|
||||
const initial = (() => $state.snapshot(recipe))();
|
||||
|
||||
const TAG_TYPE_LABELS: Record<string, string> = {
|
||||
dietary: 'Ernährung',
|
||||
cuisine: 'Küche',
|
||||
protein: 'Protein',
|
||||
other: 'Sonstiges'
|
||||
};
|
||||
|
||||
const groupedCategories = $derived(
|
||||
Object.entries(
|
||||
categories.reduce<Record<string, typeof categories>>((acc, cat) => {
|
||||
const type = cat.tagType ?? 'other';
|
||||
(acc[type] ??= []).push(cat);
|
||||
return acc;
|
||||
}, {})
|
||||
)
|
||||
);
|
||||
|
||||
let name = $state(initial?.name ?? '');
|
||||
let serves = $state<number | ''>(initial?.serves ?? '');
|
||||
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
|
||||
@@ -43,6 +60,32 @@
|
||||
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
|
||||
);
|
||||
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
||||
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null);
|
||||
let imageError = $state<string | null>(null);
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
function handleImageChange(e: Event) {
|
||||
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
imageError = 'Datei zu groß. Maximal 5 MB erlaubt.';
|
||||
(e.currentTarget as HTMLInputElement).value = '';
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
imageError = 'Dateityp nicht unterstützt. Erlaubt: JPEG, PNG, GIF, WebP.';
|
||||
(e.currentTarget as HTMLInputElement).value = '';
|
||||
return;
|
||||
}
|
||||
imageError = null;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
heroImageUrl = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="POST" {action} use:enhance>
|
||||
@@ -100,7 +143,7 @@
|
||||
for="cookTimeMin"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Kochzeit
|
||||
Kochzeit (min)
|
||||
</label>
|
||||
<input
|
||||
id="cookTimeMin"
|
||||
@@ -140,6 +183,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Bild</p>
|
||||
{#if heroImageUrl}
|
||||
<img
|
||||
src={heroImageUrl}
|
||||
alt=""
|
||||
class="mb-[8px] max-h-[200px] w-full rounded-[var(--radius-md)] object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (heroImageUrl = null)}
|
||||
class="mb-[8px] text-[12px] text-[var(--color-error)] opacity-60 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
Bild entfernen
|
||||
</button>
|
||||
{/if}
|
||||
<label
|
||||
class="block w-full cursor-pointer rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] text-center text-[13px] text-[var(--color-text-muted)]"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={handleImageChange}
|
||||
class="sr-only"
|
||||
/>
|
||||
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
|
||||
</label>
|
||||
{#if imageError}
|
||||
<p class="mt-[6px] text-[12px] text-[var(--color-error)]">{imageError}</p>
|
||||
{:else}
|
||||
<p class="mt-[6px] text-[11px] text-[var(--color-text-muted)]">Max. 5 MB</p>
|
||||
{/if}
|
||||
<input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
|
||||
</div>
|
||||
|
||||
<!-- Ingredients -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
|
||||
@@ -227,9 +306,14 @@
|
||||
<div
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[20px]"
|
||||
>
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
|
||||
<p class="mb-[16px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
|
||||
{#each groupedCategories as [type, tags] (type)}
|
||||
<div class="mb-[16px] last:mb-0">
|
||||
<p class="mb-[8px] text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{TAG_TYPE_LABELS[type] ?? type}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
{#each categories as cat (cat.id)}
|
||||
{#each tags as cat (cat.id)}
|
||||
<label
|
||||
class={[
|
||||
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
|
||||
@@ -257,6 +341,8 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const editProps = {
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Medium',
|
||||
effort: 'medium',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
ingredients: [
|
||||
{ name: 'Spaghetti', quantity: 200, unit: 'g' }
|
||||
@@ -162,4 +162,53 @@ describe('RecipeForm', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Max. 5 MB hint below upload button', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByText('Max. 5 MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when selected file exceeds 5 MB', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
|
||||
const oversizedFile = new File(['x'.repeat(6 * 1024 * 1024)], 'big.jpg', { type: 'image/jpeg' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await user.upload(fileInput, oversizedFile);
|
||||
|
||||
expect(screen.getByText(/datei zu groß/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show file size error for file within 5 MB', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
|
||||
const okFile = new File(['x'.repeat(1 * 1024 * 1024)], 'small.jpg', { type: 'image/jpeg' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await user.upload(fileInput, okFile);
|
||||
|
||||
expect(screen.queryByText(/datei zu groß/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when selected file has unsupported type', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
|
||||
const bmpFile = new File(['content'], 'image.bmp', { type: 'image/bmp' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await user.upload(fileInput, bmpFile);
|
||||
|
||||
expect(screen.getByText(/dateityp/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show type error for supported image types', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
|
||||
const jpgFile = new File(['content'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await user.upload(fileInput, jpgFile);
|
||||
|
||||
expect(screen.queryByText(/dateityp/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ export type RecipeSummary = {
|
||||
name: string;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
heroImagePreview?: string;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</svelte:head>
|
||||
|
||||
{#if isOnboarding}
|
||||
<div class="flex min-h-screen bg-[var(--color-page)]">
|
||||
<div class="fixed inset-0 z-50 flex bg-[var(--color-page)]">
|
||||
<!-- Desktop sidebar -->
|
||||
<aside class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]">
|
||||
<ProgressSidebar currentStep={2} />
|
||||
@@ -44,8 +44,10 @@
|
||||
</main>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen flex-col bg-[var(--color-page)]">
|
||||
<div class="p-[16px_20px] md:p-[40px_56px]">
|
||||
<a href="/settings" class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] mb-4 inline-block">← Einstellungen</a>
|
||||
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px] text-[var(--color-text)]">Vorräte</h1>
|
||||
<StaplesManager categories={data.categories} context="settings" />
|
||||
<p class="mt-4 font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -79,4 +79,36 @@ describe('staples page — settings context (no ctx)', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders back-link "← Einstellungen" when ctx is null (default settings view)', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
|
||||
expect(backLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('back-link points to /settings', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
|
||||
expect(backLink).toHaveAttribute('href', '/settings');
|
||||
});
|
||||
|
||||
it('renders hint text about autosave', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||
expect(screen.getByText(/änderungen werden automatisch gespeichert/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hint text about next shopping list', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||
expect(screen.getByText(/gilt ab der nächsten einkaufsliste/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render back-link in onboarding context', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||
expect(screen.queryByRole('link', { name: /einstellungen/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render hint text in onboarding context', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||
expect(screen.queryByText(/änderungen werden automatisch gespeichert/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
18
frontend/src/routes/(app)/members/+page.server.ts
Normal file
18
frontend/src/routes/(app)/members/+page.server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
const api = apiClient(fetch);
|
||||
|
||||
const [membersRes, inviteRes] = await Promise.all([
|
||||
api.GET('/v1/households/mine/members'),
|
||||
api.GET('/v1/households/mine/invites')
|
||||
]);
|
||||
|
||||
return {
|
||||
members: membersRes.data ?? [],
|
||||
currentUserId: locals.benutzer!.id,
|
||||
activeInvite: inviteRes.data?.data ?? null,
|
||||
householdName: locals.haushalt?.name ?? ''
|
||||
};
|
||||
};
|
||||
@@ -1 +1,114 @@
|
||||
<h1 class="text-2xl font-medium p-6">Mitglieder</h1>
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import MemberGrid from './MemberGrid.svelte';
|
||||
import InvitePanel from './InvitePanel.svelte';
|
||||
import RemoveDialog from './RemoveDialog.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let members = $state(untrack(() => data.members as { userId: string; displayName: string; role: string; joinedAt: string }[]));
|
||||
let activeInvite = $state(untrack(() => data.activeInvite as { inviteCode: string; shareUrl: string; expiresAt: string } | null));
|
||||
let showInvitePanel = $state(false);
|
||||
let removeTarget: { userId: string; displayName: string; role: string; joinedAt: string } | null = $state(null);
|
||||
let toastMessage = $state('');
|
||||
let toastVisible = $state(false);
|
||||
|
||||
const currentUserRole = $derived(members.find((m) => m.userId === data.currentUserId)?.role ?? 'member');
|
||||
const isPlanner = $derived(currentUserRole === 'planner');
|
||||
|
||||
function showToast(message: string) {
|
||||
toastMessage = message;
|
||||
toastVisible = true;
|
||||
}
|
||||
|
||||
async function handleRoleChange(
|
||||
member: { userId: string; displayName: string; role: string; joinedAt: string },
|
||||
newRole: string
|
||||
) {
|
||||
const original = members.slice();
|
||||
members = members.map((m) => (m.userId === member.userId ? { ...m, role: newRole } : m));
|
||||
|
||||
const res = await fetch('/members/' + member.userId, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: newRole })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
members = original;
|
||||
showToast('Fehler beim Ändern der Rolle');
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(member: { userId: string; displayName: string; role: string; joinedAt: string }) {
|
||||
removeTarget = member;
|
||||
}
|
||||
|
||||
async function handleConfirmRemove() {
|
||||
if (!removeTarget) return;
|
||||
const target = removeTarget;
|
||||
const original = members.slice();
|
||||
removeTarget = null;
|
||||
members = members.filter((m) => m.userId !== target.userId);
|
||||
|
||||
const res = await fetch('/members/' + target.userId, { method: 'DELETE' });
|
||||
|
||||
if (!res.ok) {
|
||||
members = original;
|
||||
showToast('Fehler beim Entfernen des Mitglieds');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInviteClick() {
|
||||
if (!activeInvite) {
|
||||
const res = await fetch('/members/invites', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
activeInvite = await res.json();
|
||||
} else {
|
||||
showToast('Einladung konnte nicht erstellt werden');
|
||||
return;
|
||||
}
|
||||
}
|
||||
showInvitePanel = !showInvitePanel;
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
const res = await fetch('/members/invites', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
activeInvite = await res.json();
|
||||
} else {
|
||||
showToast('Link konnte nicht erneuert werden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
|
||||
|
||||
<div class="p-[16px_20px] md:p-[40px_56px]">
|
||||
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-1 text-[var(--color-text)]">Mitglieder</h1>
|
||||
<p class="text-[13px] text-[var(--color-text-muted)] mb-8">{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}</p>
|
||||
|
||||
<MemberGrid
|
||||
{members}
|
||||
currentUserId={data.currentUserId}
|
||||
{isPlanner}
|
||||
showInviteCard={isPlanner}
|
||||
onremove={handleRemove}
|
||||
onrolechange={handleRoleChange}
|
||||
oninviteclick={handleInviteClick}
|
||||
/>
|
||||
|
||||
{#if showInvitePanel && isPlanner && activeInvite}
|
||||
<InvitePanel invite={activeInvite} onregenerate={handleRegenerate} />
|
||||
{/if}
|
||||
|
||||
<RemoveDialog
|
||||
show={removeTarget !== null}
|
||||
member={removeTarget ?? { userId: '', displayName: '', role: '', joinedAt: '' }}
|
||||
onconfirm={handleConfirmRemove}
|
||||
oncancel={() => (removeTarget = null)}
|
||||
/>
|
||||
|
||||
<Toast message={toastMessage} visible={toastVisible} ondismiss={() => (toastVisible = false)} />
|
||||
</div>
|
||||
|
||||
84
frontend/src/routes/(app)/members/InviteCard.svelte
Normal file
84
frontend/src/routes/(app)/members/InviteCard.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
onclick
|
||||
}: {
|
||||
onclick: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="invite-card"
|
||||
{onclick}
|
||||
class="invite-card"
|
||||
>
|
||||
<div class="invite-plus">+</div>
|
||||
<div class="invite-label">Mitglied einladen</div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.invite-card {
|
||||
background: white;
|
||||
border: 1.5px dashed var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 24px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
min-height: 180px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-card:hover {
|
||||
border-color: var(--green-light);
|
||||
background: var(--green-tint);
|
||||
}
|
||||
|
||||
.invite-plus {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.invite-card:hover .invite-plus {
|
||||
background: var(--green-light);
|
||||
color: var(--green-dark);
|
||||
}
|
||||
|
||||
.invite-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.invite-card:hover .invite-label {
|
||||
color: var(--green-dark);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.invite-card {
|
||||
padding: 16px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.invite-plus {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.invite-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
23
frontend/src/routes/(app)/members/InviteCard.test.ts
Normal file
23
frontend/src/routes/(app)/members/InviteCard.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 InviteCard from './InviteCard.svelte';
|
||||
|
||||
describe('InviteCard', () => {
|
||||
it('renders the invite tile', () => {
|
||||
render(InviteCard, { props: { onclick: vi.fn() } });
|
||||
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a descriptive label', () => {
|
||||
render(InviteCard, { props: { onclick: vi.fn() } });
|
||||
expect(screen.getByText(/einladen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onclick when tile is clicked', async () => {
|
||||
const onclick = vi.fn();
|
||||
render(InviteCard, { props: { onclick } });
|
||||
await userEvent.click(screen.getByTestId('invite-card'));
|
||||
expect(onclick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
139
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal file
139
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
invite,
|
||||
onregenerate
|
||||
}: {
|
||||
invite: { inviteCode: string; shareUrl: string; expiresAt: string };
|
||||
onregenerate: () => void;
|
||||
} = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
function copy() {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(invite.shareUrl);
|
||||
}
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function formatExpiry(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
const isExpiringSoon = $derived(
|
||||
new Date(invite.expiresAt).getTime() - Date.now() <= 24 * 60 * 60 * 1000
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="invite-panel">
|
||||
<div class="invite-panel-title">Einladelink teilen</div>
|
||||
<div class="invite-panel-desc">Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.</div>
|
||||
|
||||
<div class="invite-link-row">
|
||||
<div class="invite-link-box">{invite.shareUrl || invite.inviteCode}</div>
|
||||
<button type="button" data-testid="copy-btn" class="btn-copy" onclick={copy}>
|
||||
{copied ? 'Kopiert ✓' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="invite-expiry">
|
||||
Läuft ab: <span class:yellow={isExpiringSoon}>{formatExpiry(invite.expiresAt)}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" data-testid="regenerate-btn" class="btn-regen" onclick={onregenerate}>
|
||||
Neuen Link generieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.invite-panel {
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 24px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.invite-panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.invite-panel-desc {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invite-link-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.invite-link-box {
|
||||
flex: 1;
|
||||
background: var(--color-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-copy:hover {
|
||||
background: var(--color-subtle);
|
||||
}
|
||||
|
||||
.invite-expiry {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.invite-expiry span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.invite-expiry span.yellow {
|
||||
background: var(--yellow-tint);
|
||||
color: var(--yellow-text);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.btn-regen {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-regen:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
39
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal file
39
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import InvitePanel from './InvitePanel.svelte';
|
||||
|
||||
const invite = {
|
||||
inviteCode: 'ABC123XY',
|
||||
shareUrl: 'https://example.com/join/ABC123XY',
|
||||
expiresAt: '2026-12-01T00:00:00Z'
|
||||
};
|
||||
|
||||
describe('InvitePanel', () => {
|
||||
it('shows the invite URL', () => {
|
||||
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||
expect(screen.getByText(/ABC123XY/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a copy button', () => {
|
||||
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a regenerate button', () => {
|
||||
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onregenerate when regenerate button is clicked', async () => {
|
||||
const onregenerate = vi.fn();
|
||||
render(InvitePanel, { props: { invite, onregenerate } });
|
||||
await userEvent.click(screen.getByTestId('regenerate-btn'));
|
||||
expect(onregenerate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows the panel title', () => {
|
||||
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||
expect(screen.getByText('Einladelink teilen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user