Compare commits
136 Commits
520dae5adf
...
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 | |||
| 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 | |||
| 6dd0b7ac93 |
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -31,3 +31,6 @@ build/
|
|||||||
|
|
||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.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
|
RUN ./mvnw package -DskipTests -B
|
||||||
|
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
RUN apk add --no-cache libwebp
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/target/*.jar app.jar
|
COPY --from=build /app/target/*.jar app.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -55,6 +55,16 @@
|
|||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.coobird</groupId>
|
||||||
|
<artifactId>thumbnailator</artifactId>
|
||||||
|
<version>0.4.21</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.twelvemonkeys.imageio</groupId>
|
||||||
|
<artifactId>imageio-webp</artifactId>
|
||||||
|
<version>3.13.1</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@@ -7,15 +7,10 @@ import jakarta.servlet.http.HttpSession;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/v1/auth")
|
@RequestMapping("/v1/auth")
|
||||||
@@ -32,7 +27,7 @@ public class AuthController {
|
|||||||
@Valid @RequestBody SignupRequest request,
|
@Valid @RequestBody SignupRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
UserResponse user = authService.signup(request);
|
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));
|
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,30 +36,10 @@ public class AuthController {
|
|||||||
@Valid @RequestBody LoginRequest request,
|
@Valid @RequestBody LoginRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
UserResponse user = authService.login(request);
|
UserResponse user = authService.login(request);
|
||||||
// Session fixation protection: invalidate old session before creating new one
|
authService.authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
|
||||||
var oldSession = httpRequest.getSession(false);
|
|
||||||
if (oldSession != null) {
|
|
||||||
oldSession.invalidate();
|
|
||||||
}
|
|
||||||
authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(user));
|
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")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
||||||
HttpSession session = httpRequest.getSession(false);
|
HttpSession session = httpRequest.getSession(false);
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ import com.recipeapp.common.ResourceNotFoundException;
|
|||||||
import com.recipeapp.common.ValidationException;
|
import com.recipeapp.common.ValidationException;
|
||||||
import com.recipeapp.household.HouseholdMemberRepository;
|
import com.recipeapp.household.HouseholdMemberRepository;
|
||||||
import com.recipeapp.household.entity.HouseholdMember;
|
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.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
@@ -82,6 +90,24 @@ public class AuthService {
|
|||||||
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
|
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) {
|
private UserResponse toUserResponse(UserAccount user) {
|
||||||
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
|
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
|
||||||
.map(member -> UserResponse.withHousehold(
|
.map(member -> UserResponse.withHousehold(
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
|
.requestMatchers("/v1/invites/**").permitAll()
|
||||||
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
|
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.exceptionHandling(ex -> ex
|
.exceptionHandling(ex -> ex
|
||||||
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||||
.sessionManagement(session -> session
|
.sessionManagement(session -> session
|
||||||
|
.sessionFixation().changeSessionId()
|
||||||
.maximumSessions(1));
|
.maximumSessions(1));
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.recipeapp.household;
|
package com.recipeapp.household;
|
||||||
|
|
||||||
|
import com.recipeapp.auth.AuthService;
|
||||||
import com.recipeapp.common.ApiResponse;
|
import com.recipeapp.common.ApiResponse;
|
||||||
import com.recipeapp.household.dto.*;
|
import com.recipeapp.household.dto.*;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -9,15 +11,19 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/v1")
|
@RequestMapping("/v1")
|
||||||
public class HouseholdController {
|
public class HouseholdController {
|
||||||
|
|
||||||
private final HouseholdService householdService;
|
private final HouseholdService householdService;
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
public HouseholdController(HouseholdService householdService) {
|
public HouseholdController(HouseholdService householdService, AuthService authService) {
|
||||||
this.householdService = householdService;
|
this.householdService = householdService;
|
||||||
|
this.authService = authService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/households")
|
@PostMapping("/households")
|
||||||
@@ -40,17 +46,49 @@ public class HouseholdController {
|
|||||||
return ResponseEntity.ok(members);
|
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")
|
@PostMapping("/households/mine/invites")
|
||||||
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
|
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
|
||||||
InviteResponse response = householdService.createInvite(principal.getName());
|
InviteResponse response = householdService.createInvite(principal.getName());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
|
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")
|
@PostMapping("/invites/{code}/accept")
|
||||||
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
||||||
Principal principal,
|
@PathVariable String code,
|
||||||
@PathVariable String code) {
|
@Valid @RequestBody AcceptInviteRequest request,
|
||||||
AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code);
|
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));
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
|
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
|
||||||
Optional<HouseholdInvite> findByInviteCode(String inviteCode);
|
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> {
|
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||||
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
||||||
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
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.ResourceNotFoundException;
|
||||||
import com.recipeapp.common.ValidationException;
|
import com.recipeapp.common.ValidationException;
|
||||||
import com.recipeapp.household.dto.*;
|
import com.recipeapp.household.dto.*;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import com.recipeapp.household.entity.Household;
|
import com.recipeapp.household.entity.Household;
|
||||||
import com.recipeapp.household.entity.HouseholdInvite;
|
import com.recipeapp.household.entity.HouseholdInvite;
|
||||||
import com.recipeapp.household.entity.HouseholdMember;
|
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.Ingredient;
|
||||||
import com.recipeapp.recipe.entity.IngredientCategory;
|
import com.recipeapp.recipe.entity.IngredientCategory;
|
||||||
import com.recipeapp.recipe.entity.Tag;
|
import com.recipeapp.recipe.entity.Tag;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class HouseholdService {
|
public class HouseholdService {
|
||||||
@@ -35,6 +39,10 @@ public class HouseholdService {
|
|||||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
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 SecureRandom RANDOM = new SecureRandom();
|
||||||
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
@@ -46,7 +54,8 @@ public class HouseholdService {
|
|||||||
IngredientRepository ingredientRepository,
|
IngredientRepository ingredientRepository,
|
||||||
IngredientCategoryRepository ingredientCategoryRepository,
|
IngredientCategoryRepository ingredientCategoryRepository,
|
||||||
TagRepository tagRepository,
|
TagRepository tagRepository,
|
||||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
VarietyScoreConfigRepository varietyScoreConfigRepository,
|
||||||
|
PasswordEncoder passwordEncoder) {
|
||||||
this.userAccountRepository = userAccountRepository;
|
this.userAccountRepository = userAccountRepository;
|
||||||
this.householdRepository = householdRepository;
|
this.householdRepository = householdRepository;
|
||||||
this.householdMemberRepository = householdMemberRepository;
|
this.householdMemberRepository = householdMemberRepository;
|
||||||
@@ -55,6 +64,7 @@ public class HouseholdService {
|
|||||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -91,42 +101,121 @@ public class HouseholdService {
|
|||||||
.toList();
|
.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
|
@Transactional
|
||||||
public InviteResponse createInvite(String userEmail) {
|
public InviteResponse createInvite(String userEmail) {
|
||||||
HouseholdMember member = findMembership(userEmail);
|
HouseholdMember member = findMembership(userEmail);
|
||||||
Household household = member.getHousehold();
|
Household household = member.getHousehold();
|
||||||
|
|
||||||
|
householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId())
|
||||||
|
.ifPresent(existing -> {
|
||||||
|
existing.setInvalidatedAt(Instant.now());
|
||||||
|
householdInviteRepository.saveAndFlush(existing);
|
||||||
|
});
|
||||||
|
|
||||||
String code = generateInviteCode();
|
String code = generateInviteCode();
|
||||||
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
||||||
|
|
||||||
HouseholdInvite invite = householdInviteRepository.save(
|
HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt);
|
||||||
new HouseholdInvite(household, code, expiresAt));
|
invite.setInvitedBy(member.getUser());
|
||||||
|
householdInviteRepository.save(invite);
|
||||||
|
|
||||||
return new InviteResponse(
|
return toInviteResponse(invite);
|
||||||
invite.getInviteCode(),
|
}
|
||||||
"https://yourapp.com/join/" + invite.getInviteCode(),
|
|
||||||
invite.getExpiresAt());
|
@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
|
@Transactional
|
||||||
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
|
public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) {
|
||||||
UserAccount user = findUser(userEmail);
|
if (userAccountRepository.existsByEmailIgnoreCase(email)) {
|
||||||
|
throw new ConflictException("Email already registered");
|
||||||
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
|
|
||||||
throw new ConflictException("User is already in a household");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
|
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
|
||||||
|
|
||||||
if ("used".equals(invite.getStatus())) {
|
if ("used".equals(invite.getStatus())
|
||||||
throw new ConflictException("Invite code already used");
|
|| invite.getInvalidatedAt() != null
|
||||||
}
|
|| invite.getExpiresAt().isBefore(Instant.now())) {
|
||||||
if (invite.getExpiresAt().isBefore(Instant.now())) {
|
throw new ResourceNotFoundException("Invite not found or invalid");
|
||||||
throw new ValidationException("Invite code has expired");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UserAccount user = userAccountRepository.save(
|
||||||
|
new UserAccount(email, name, passwordEncoder.encode(rawPassword)));
|
||||||
|
|
||||||
invite.setStatus("used");
|
invite.setStatus("used");
|
||||||
|
invite.setInvalidatedAt(Instant.now());
|
||||||
householdInviteRepository.save(invite);
|
householdInviteRepository.save(invite);
|
||||||
|
|
||||||
Household household = invite.getHousehold();
|
Household household = invite.getHousehold();
|
||||||
@@ -204,4 +293,11 @@ public class HouseholdService {
|
|||||||
member.getRole(),
|
member.getRole(),
|
||||||
member.getJoinedAt());
|
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;
|
package com.recipeapp.household.entity;
|
||||||
|
|
||||||
|
import com.recipeapp.auth.entity.UserAccount;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -16,6 +17,10 @@ public class HouseholdInvite {
|
|||||||
@JoinColumn(name = "household_id", nullable = false)
|
@JoinColumn(name = "household_id", nullable = false)
|
||||||
private Household household;
|
private Household household;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "invited_by")
|
||||||
|
private UserAccount invitedBy;
|
||||||
|
|
||||||
@Column(name = "invite_code", nullable = false, unique = true, length = 20)
|
@Column(name = "invite_code", nullable = false, unique = true, length = 20)
|
||||||
private String inviteCode;
|
private String inviteCode;
|
||||||
|
|
||||||
@@ -25,6 +30,9 @@ public class HouseholdInvite {
|
|||||||
@Column(name = "expires_at", nullable = false)
|
@Column(name = "expires_at", nullable = false)
|
||||||
private Instant expiresAt;
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "invalidated_at")
|
||||||
|
private Instant invalidatedAt;
|
||||||
|
|
||||||
protected HouseholdInvite() {}
|
protected HouseholdInvite() {}
|
||||||
|
|
||||||
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
|
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
|
||||||
@@ -35,8 +43,12 @@ public class HouseholdInvite {
|
|||||||
|
|
||||||
public UUID getId() { return id; }
|
public UUID getId() { return id; }
|
||||||
public Household getHousehold() { return household; }
|
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 getInviteCode() { return inviteCode; }
|
||||||
public String getStatus() { return status; }
|
public String getStatus() { return status; }
|
||||||
public void setStatus(String status) { this.status = status; }
|
public void setStatus(String status) { this.status = status; }
|
||||||
public Instant getExpiresAt() { return expiresAt; }
|
public Instant getExpiresAt() { return expiresAt; }
|
||||||
|
public Instant getInvalidatedAt() { return invalidatedAt; }
|
||||||
|
public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ public class PlanningService {
|
|||||||
plan, candidate, slotDate, config, recentlyCookedIds);
|
plan, candidate, slotDate, config, recentlyCookedIds);
|
||||||
double scoreDelta = simulatedScore - currentScore;
|
double scoreDelta = simulatedScore - currentScore;
|
||||||
boolean hasConflict = scoreDelta < 0;
|
boolean hasConflict = scoreDelta < 0;
|
||||||
return new SuggestionResponse.SuggestionItem(toSuggestionRecipe(candidate), scoreDelta, hasConflict);
|
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict);
|
||||||
})
|
})
|
||||||
.sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
|
.sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -422,11 +422,6 @@ public class PlanningService {
|
|||||||
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
|
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
private SuggestionResponse.SuggestionRecipe toSuggestionRecipe(Recipe recipe) {
|
|
||||||
return new SuggestionResponse.SuggestionRecipe(recipe.getId(), recipe.getName(),
|
|
||||||
recipe.getEffort(), recipe.getCookTimeMin());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean hasConsecutiveDays(List<LocalDate> days) {
|
private boolean hasConsecutiveDays(List<LocalDate> days) {
|
||||||
if (days.size() < 2) return false;
|
if (days.size() < 2) return false;
|
||||||
List<LocalDate> sorted = days.stream().sorted().toList();
|
List<LocalDate> sorted = days.stream().sorted().toList();
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package com.recipeapp.planning.dto;
|
package com.recipeapp.planning.dto;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record SuggestionResponse(List<SuggestionItem> suggestions) {
|
public record SuggestionResponse(List<SuggestionItem> suggestions) {
|
||||||
|
|
||||||
public record SuggestionRecipe(UUID id, String name, String effort, short cookTimeMin) {}
|
|
||||||
|
|
||||||
public record SuggestionItem(
|
public record SuggestionItem(
|
||||||
SuggestionRecipe recipe,
|
SlotResponse.SlotRecipe recipe,
|
||||||
double scoreDelta,
|
double scoreDelta,
|
||||||
boolean hasConflict
|
boolean hasConflict
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.recipeapp.recipe;
|
||||||
|
|
||||||
|
import net.coobird.thumbnailator.Thumbnails;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ImageCompressor {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ImageCompressor.class);
|
||||||
|
|
||||||
|
private static final int PREVIEW_WIDTH = 400;
|
||||||
|
private static final double PREVIEW_QUALITY = 0.6;
|
||||||
|
private static final String DATA_URI_PREFIX = "data:image/";
|
||||||
|
private static final String BASE64_MARKER = ";base64,";
|
||||||
|
private static final String OUTPUT_PREFIX = "data:image/jpeg;base64,";
|
||||||
|
|
||||||
|
public String compressToPreview(String dataUri) {
|
||||||
|
if (dataUri == null || dataUri.isBlank()) return null;
|
||||||
|
if (!dataUri.startsWith(DATA_URI_PREFIX)) return null;
|
||||||
|
|
||||||
|
int markerIdx = dataUri.indexOf(BASE64_MARKER);
|
||||||
|
if (markerIdx < 0) return null;
|
||||||
|
|
||||||
|
byte[] imageBytes;
|
||||||
|
try {
|
||||||
|
imageBytes = Base64.getDecoder().decode(dataUri.substring(markerIdx + BASE64_MARKER.length()));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||||
|
if (original == null) {
|
||||||
|
log.warn("ImageIO could not decode image — unsupported format (data URI prefix: {})",
|
||||||
|
dataUri.substring(0, Math.min(dataUri.indexOf(',') + 1, 40)));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int targetWidth = Math.min(original.getWidth(), PREVIEW_WIDTH);
|
||||||
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||||
|
Thumbnails.of(original)
|
||||||
|
.width(targetWidth)
|
||||||
|
.outputFormat("jpeg")
|
||||||
|
.outputQuality(PREVIEW_QUALITY)
|
||||||
|
.toOutputStream(bos);
|
||||||
|
return OUTPUT_PREFIX + Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to generate image preview", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,6 @@ public class RecipeController {
|
|||||||
Principal principal,
|
Principal principal,
|
||||||
@RequestParam(required = false) String search,
|
@RequestParam(required = false) String search,
|
||||||
@RequestParam(required = false) String effort,
|
@RequestParam(required = false) String effort,
|
||||||
@RequestParam(required = false) Boolean isChildFriendly,
|
|
||||||
@RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin,
|
@RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin,
|
||||||
@RequestParam(required = false) String sort,
|
@RequestParam(required = false) String sort,
|
||||||
@RequestParam(defaultValue = "20") int limit,
|
@RequestParam(defaultValue = "20") int limit,
|
||||||
@@ -37,9 +36,9 @@ public class RecipeController {
|
|||||||
|
|
||||||
UUID householdId = householdResolver.resolve(principal.getName());
|
UUID householdId = householdResolver.resolve(principal.getName());
|
||||||
List<RecipeSummaryResponse> recipes = recipeService.listRecipes(
|
List<RecipeSummaryResponse> recipes = recipeService.listRecipes(
|
||||||
householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset);
|
householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
|
||||||
long total = recipeService.countRecipes(
|
long total = recipeService.countRecipes(
|
||||||
householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
householdId, search, effort, cookTimeMaxMin);
|
||||||
|
|
||||||
var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total);
|
var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total);
|
||||||
var meta = new ApiResponse.Meta(pagination);
|
var meta = new ApiResponse.Meta(pagination);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.recipeapp.recipe;
|
package com.recipeapp.recipe;
|
||||||
|
|
||||||
import com.recipeapp.recipe.dto.RecipeSummaryResponse;
|
|
||||||
import com.recipeapp.recipe.entity.Recipe;
|
import com.recipeapp.recipe.entity.Recipe;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
@@ -17,22 +16,19 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
|||||||
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
|
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
|
SELECT r FROM Recipe r
|
||||||
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl)
|
LEFT JOIN FETCH r.tags
|
||||||
FROM Recipe r
|
|
||||||
WHERE r.household.id = :householdId
|
WHERE r.household.id = :householdId
|
||||||
AND r.deletedAt IS NULL
|
AND r.deletedAt IS NULL
|
||||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
|
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
|
||||||
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
|
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
|
||||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
|
||||||
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||||
ORDER BY r.createdAt DESC
|
ORDER BY r.createdAt DESC
|
||||||
""")
|
""")
|
||||||
List<RecipeSummaryResponse> findFiltered(
|
List<Recipe> findFiltered(
|
||||||
@Param("householdId") UUID householdId,
|
@Param("householdId") UUID householdId,
|
||||||
@Param("search") String search,
|
@Param("search") String search,
|
||||||
@Param("effort") String effort,
|
@Param("effort") String effort,
|
||||||
@Param("isChildFriendly") Boolean isChildFriendly,
|
|
||||||
@Param("cookTimeMaxMin") Integer cookTimeMaxMin,
|
@Param("cookTimeMaxMin") Integer cookTimeMaxMin,
|
||||||
@Param("sort") String sort,
|
@Param("sort") String sort,
|
||||||
@Param("limit") int limit,
|
@Param("limit") int limit,
|
||||||
@@ -45,13 +41,11 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
|||||||
AND r.deletedAt IS NULL
|
AND r.deletedAt IS NULL
|
||||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
|
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
|
||||||
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
|
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
|
||||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
|
||||||
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||||
""")
|
""")
|
||||||
long countFiltered(
|
long countFiltered(
|
||||||
@Param("householdId") UUID householdId,
|
@Param("householdId") UUID householdId,
|
||||||
@Param("search") String search,
|
@Param("search") String search,
|
||||||
@Param("effort") String effort,
|
@Param("effort") String effort,
|
||||||
@Param("isChildFriendly") Boolean isChildFriendly,
|
|
||||||
@Param("cookTimeMaxMin") Integer cookTimeMaxMin);
|
@Param("cookTimeMaxMin") Integer cookTimeMaxMin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.recipeapp.recipe;
|
|||||||
|
|
||||||
import com.recipeapp.common.ConflictException;
|
import com.recipeapp.common.ConflictException;
|
||||||
import com.recipeapp.common.ResourceNotFoundException;
|
import com.recipeapp.common.ResourceNotFoundException;
|
||||||
|
import com.recipeapp.common.ValidationException;
|
||||||
import com.recipeapp.household.HouseholdRepository;
|
import com.recipeapp.household.HouseholdRepository;
|
||||||
import com.recipeapp.household.entity.Household;
|
import com.recipeapp.household.entity.Household;
|
||||||
import com.recipeapp.recipe.dto.*;
|
import com.recipeapp.recipe.dto.*;
|
||||||
@@ -22,31 +23,39 @@ public class RecipeService {
|
|||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||||
private final HouseholdRepository householdRepository;
|
private final HouseholdRepository householdRepository;
|
||||||
|
private final ImageCompressor imageCompressor;
|
||||||
|
|
||||||
public RecipeService(RecipeRepository recipeRepository,
|
public RecipeService(RecipeRepository recipeRepository,
|
||||||
IngredientRepository ingredientRepository,
|
IngredientRepository ingredientRepository,
|
||||||
TagRepository tagRepository,
|
TagRepository tagRepository,
|
||||||
IngredientCategoryRepository ingredientCategoryRepository,
|
IngredientCategoryRepository ingredientCategoryRepository,
|
||||||
HouseholdRepository householdRepository) {
|
HouseholdRepository householdRepository,
|
||||||
|
ImageCompressor imageCompressor) {
|
||||||
this.recipeRepository = recipeRepository;
|
this.recipeRepository = recipeRepository;
|
||||||
this.ingredientRepository = ingredientRepository;
|
this.ingredientRepository = ingredientRepository;
|
||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||||
this.householdRepository = householdRepository;
|
this.householdRepository = householdRepository;
|
||||||
|
this.imageCompressor = imageCompressor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||||
Boolean isChildFriendly, Integer cookTimeMaxMin,
|
Integer cookTimeMaxMin, String sort, int limit, int offset) {
|
||||||
String sort, int limit, int offset) {
|
return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset)
|
||||||
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
|
.stream()
|
||||||
cookTimeMaxMin, sort, limit, offset);
|
.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)
|
@Transactional(readOnly = true)
|
||||||
public long countRecipes(UUID householdId, String search, String effort,
|
public long countRecipes(UUID householdId, String search, String effort, Integer cookTimeMaxMin) {
|
||||||
Boolean isChildFriendly, Integer cookTimeMaxMin) {
|
return recipeRepository.countFiltered(householdId, search, effort, cookTimeMaxMin);
|
||||||
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -60,11 +69,14 @@ public class RecipeService {
|
|||||||
Household household = householdRepository.findById(householdId)
|
Household household = householdRepository.findById(householdId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||||
|
|
||||||
|
validateHeroImageUrl(request.heroImageUrl());
|
||||||
|
|
||||||
Recipe recipe = new Recipe(household, request.name(),
|
Recipe recipe = new Recipe(household, request.name(),
|
||||||
request.serves() != null ? request.serves().shortValue() : 0,
|
request.serves() != null ? request.serves().shortValue() : 0,
|
||||||
request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0,
|
request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0,
|
||||||
request.effort(), false);
|
request.effort());
|
||||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||||
|
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
|
||||||
|
|
||||||
addIngredients(recipe, household, request.ingredients());
|
addIngredients(recipe, household, request.ingredients());
|
||||||
addSteps(recipe, request.steps());
|
addSteps(recipe, request.steps());
|
||||||
@@ -79,11 +91,14 @@ public class RecipeService {
|
|||||||
Recipe recipe = findRecipe(householdId, recipeId);
|
Recipe recipe = findRecipe(householdId, recipeId);
|
||||||
Household household = recipe.getHousehold();
|
Household household = recipe.getHousehold();
|
||||||
|
|
||||||
|
validateHeroImageUrl(request.heroImageUrl());
|
||||||
|
|
||||||
recipe.setName(request.name());
|
recipe.setName(request.name());
|
||||||
recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0);
|
recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0);
|
||||||
recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0);
|
recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0);
|
||||||
recipe.setEffort(request.effort());
|
recipe.setEffort(request.effort());
|
||||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||||
|
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
|
||||||
|
|
||||||
recipe.getIngredients().clear();
|
recipe.getIngredients().clear();
|
||||||
recipe.getSteps().clear();
|
recipe.getSteps().clear();
|
||||||
@@ -181,6 +196,18 @@ public class RecipeService {
|
|||||||
return new IngredientCategoryResponse(category.getId(), category.getName());
|
return new IngredientCategoryResponse(category.getId(), category.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Image validation ──
|
||||||
|
|
||||||
|
private static final java.util.regex.Pattern ALLOWED_IMAGE_PATTERN =
|
||||||
|
java.util.regex.Pattern.compile("data:image/(jpeg|jpg|png|gif|webp);base64,.*");
|
||||||
|
|
||||||
|
private void validateHeroImageUrl(String heroImageUrl) {
|
||||||
|
if (heroImageUrl == null || heroImageUrl.isBlank()) return;
|
||||||
|
if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).matches()) {
|
||||||
|
throw new ValidationException("Ungültiger Bildtyp. Erlaubt sind: JPEG, PNG, GIF, WebP.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Private helpers ──
|
// ── Private helpers ──
|
||||||
|
|
||||||
private Recipe findRecipe(UUID householdId, UUID recipeId) {
|
private Recipe findRecipe(UUID householdId, UUID recipeId) {
|
||||||
@@ -239,7 +266,7 @@ public class RecipeService {
|
|||||||
|
|
||||||
return new RecipeDetailResponse(
|
return new RecipeDetailResponse(
|
||||||
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
|
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
|
||||||
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(),
|
recipe.getEffort(), recipe.getHeroImageUrl(),
|
||||||
ingredients, steps, tags);
|
ingredients, steps, tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public record RecipeCreateRequest(
|
|||||||
Integer serves,
|
Integer serves,
|
||||||
Integer cookTimeMin,
|
Integer cookTimeMin,
|
||||||
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
|
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
|
||||||
String heroImageUrl,
|
@Size(max = 7_000_000) String heroImageUrl,
|
||||||
@NotEmpty @Valid List<IngredientEntry> ingredients,
|
@NotEmpty @Valid List<IngredientEntry> ingredients,
|
||||||
@Valid List<StepEntry> steps,
|
@Valid List<StepEntry> steps,
|
||||||
@NotEmpty List<UUID> tagIds
|
@NotEmpty List<UUID> tagIds
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ public record RecipeDetailResponse(
|
|||||||
short serves,
|
short serves,
|
||||||
short cookTimeMin,
|
short cookTimeMin,
|
||||||
String effort,
|
String effort,
|
||||||
boolean isChildFriendly,
|
|
||||||
String heroImageUrl,
|
String heroImageUrl,
|
||||||
List<IngredientItem> ingredients,
|
List<IngredientItem> ingredients,
|
||||||
List<StepItem> steps,
|
List<StepItem> steps,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.recipeapp.recipe.dto;
|
package com.recipeapp.recipe.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record RecipeSummaryResponse(
|
public record RecipeSummaryResponse(
|
||||||
@@ -8,6 +9,6 @@ public record RecipeSummaryResponse(
|
|||||||
short serves,
|
short serves,
|
||||||
short cookTimeMin,
|
short cookTimeMin,
|
||||||
String effort,
|
String effort,
|
||||||
boolean isChildFriendly,
|
String heroImageUrl,
|
||||||
String heroImageUrl
|
List<TagResponse> tags
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ public class Recipe {
|
|||||||
@Column(nullable = false, length = 10)
|
@Column(nullable = false, length = 10)
|
||||||
private String effort;
|
private String effort;
|
||||||
|
|
||||||
@Column(name = "is_child_friendly", nullable = false)
|
|
||||||
private boolean isChildFriendly;
|
|
||||||
|
|
||||||
@Column(name = "hero_image_url", columnDefinition = "text")
|
@Column(name = "hero_image_url", columnDefinition = "text")
|
||||||
private String heroImageUrl;
|
private String heroImageUrl;
|
||||||
|
|
||||||
|
@Column(name = "hero_image_preview", columnDefinition = "text")
|
||||||
|
private String heroImagePreview;
|
||||||
|
|
||||||
@Column(name = "deleted_at")
|
@Column(name = "deleted_at")
|
||||||
private Instant deletedAt;
|
private Instant deletedAt;
|
||||||
|
|
||||||
@@ -64,14 +64,12 @@ public class Recipe {
|
|||||||
|
|
||||||
protected Recipe() {}
|
protected Recipe() {}
|
||||||
|
|
||||||
public Recipe(Household household, String name, short serves, short cookTimeMin,
|
public Recipe(Household household, String name, short serves, short cookTimeMin, String effort) {
|
||||||
String effort, boolean isChildFriendly) {
|
|
||||||
this.household = household;
|
this.household = household;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.serves = serves;
|
this.serves = serves;
|
||||||
this.cookTimeMin = cookTimeMin;
|
this.cookTimeMin = cookTimeMin;
|
||||||
this.effort = effort;
|
this.effort = effort;
|
||||||
this.isChildFriendly = isChildFriendly;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
@@ -95,10 +93,10 @@ public class Recipe {
|
|||||||
public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; }
|
public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; }
|
||||||
public String getEffort() { return effort; }
|
public String getEffort() { return effort; }
|
||||||
public void setEffort(String effort) { this.effort = effort; }
|
public void setEffort(String effort) { this.effort = effort; }
|
||||||
public boolean isChildFriendly() { return isChildFriendly; }
|
|
||||||
public void setChildFriendly(boolean childFriendly) { isChildFriendly = childFriendly; }
|
|
||||||
public String getHeroImageUrl() { return heroImageUrl; }
|
public String getHeroImageUrl() { return heroImageUrl; }
|
||||||
public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; }
|
public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; }
|
||||||
|
public String getHeroImagePreview() { return heroImagePreview; }
|
||||||
|
public void setHeroImagePreview(String heroImagePreview) { this.heroImagePreview = heroImagePreview; }
|
||||||
public Instant getDeletedAt() { return deletedAt; }
|
public Instant getDeletedAt() { return deletedAt; }
|
||||||
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
|
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
|
||||||
public Instant getCreatedAt() { return createdAt; }
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ spring:
|
|||||||
flyway:
|
flyway:
|
||||||
locations: classpath:db/migration,classpath:db/seed
|
locations: classpath:db/migration,classpath:db/seed
|
||||||
out-of-order: true
|
out-of-order: true
|
||||||
|
|
||||||
|
app:
|
||||||
|
base-url: ${APP_BASE_URL:http://localhost:5173}
|
||||||
|
|||||||
@@ -19,5 +19,17 @@ spring:
|
|||||||
enabled: true
|
enabled: true
|
||||||
locations: classpath:db/migration
|
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:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
|
app:
|
||||||
|
base-url: http://localhost:5173
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -10,19 +10,17 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.http.MediaType;
|
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.MockMvc;
|
||||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.notNullValue;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -100,7 +98,7 @@ class AuthControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void signupShouldStoreSecurityContextInSession() throws Exception {
|
void signupShouldDelegateSessionCreationToAuthService() throws Exception {
|
||||||
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
|
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
|
||||||
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
|
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
|
||||||
|
|
||||||
@@ -109,14 +107,13 @@ class AuthControllerTest {
|
|||||||
mockMvc.perform(post("/v1/auth/signup")
|
mockMvc.perform(post("/v1/auth/signup")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated());
|
||||||
.andExpect(request().sessionAttribute(
|
|
||||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
|
||||||
notNullValue()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loginShouldStoreSecurityContextInSession() throws Exception {
|
void loginShouldDelegateSessionCreationToAuthService() throws Exception {
|
||||||
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
|
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
|
||||||
var response = UserResponse.withHousehold(
|
var response = UserResponse.withHousehold(
|
||||||
UUID.randomUUID(), "sarah@example.com", "Sarah",
|
UUID.randomUUID(), "sarah@example.com", "Sarah",
|
||||||
@@ -127,10 +124,9 @@ class AuthControllerTest {
|
|||||||
mockMvc.perform(post("/v1/auth/login")
|
mockMvc.perform(post("/v1/auth/login")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk());
|
||||||
.andExpect(request().sessionAttribute(
|
|
||||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
|
||||||
notNullValue()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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;
|
package com.recipeapp.household;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.recipeapp.auth.AuthService;
|
||||||
import com.recipeapp.common.GlobalExceptionHandler;
|
import com.recipeapp.common.GlobalExceptionHandler;
|
||||||
|
import com.recipeapp.common.ResourceNotFoundException;
|
||||||
|
import com.recipeapp.common.ConflictException;
|
||||||
import com.recipeapp.household.dto.*;
|
import com.recipeapp.household.dto.*;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -15,10 +18,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
@@ -32,6 +37,9 @@ class HouseholdControllerTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private HouseholdService householdService;
|
private HouseholdService householdService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AuthService authService;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private HouseholdController householdController;
|
private HouseholdController householdController;
|
||||||
|
|
||||||
@@ -104,16 +112,119 @@ class HouseholdControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldReturn200() throws Exception {
|
void getActiveInviteShouldReturn200WithInvite() throws Exception {
|
||||||
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
|
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")
|
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
|
||||||
.principal(() -> "tom@example.com"))
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.status").value("success"))
|
.andExpect(jsonPath("$.status").value("success"))
|
||||||
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
|
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
|
||||||
.andExpect(jsonPath("$.data.role").value("member"));
|
.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.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@@ -38,10 +41,16 @@ class HouseholdServiceTest {
|
|||||||
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
|
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
|
||||||
@Mock private TagRepository tagRepository;
|
@Mock private TagRepository tagRepository;
|
||||||
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
|
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||||
|
@Mock private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private HouseholdService householdService;
|
private HouseholdService householdService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
ReflectionTestUtils.setField(householdService, "baseUrl", "http://localhost:5173");
|
||||||
|
}
|
||||||
|
|
||||||
private UserAccount testUser() {
|
private UserAccount testUser() {
|
||||||
return new UserAccount("sarah@example.com", "Sarah", "hashed");
|
return new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||||
}
|
}
|
||||||
@@ -132,85 +141,164 @@ class HouseholdServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldAddUserAsMember() {
|
void createInviteShouldBuildShareUrlWithConfiguredBaseUrl() {
|
||||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
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 owner = testUser();
|
||||||
var household = new Household("Smith family", owner);
|
var household = new Household("Smith family", owner);
|
||||||
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
|
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(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.householdName()).isEqualTo("Smith family");
|
||||||
assertThat(result.role()).isEqualTo("member");
|
assertThat(result.inviterName()).isEqualTo("Sarah");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldThrowWhenAlreadyInHousehold() {
|
void getInviteInfoShouldThrow404WhenCodeNotFound() {
|
||||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
|
||||||
var household = new Household("Other", user);
|
|
||||||
var member = new HouseholdMember(household, user, "member");
|
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));
|
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
|
||||||
|
invite.setInvitedBy(owner);
|
||||||
|
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(member));
|
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);
|
.isInstanceOf(ConflictException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldThrowWhenCodeExpired() {
|
void acceptInviteShouldThrow404WhenCodeExpired() {
|
||||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
|
||||||
var owner = testUser();
|
var owner = testUser();
|
||||||
var household = new Household("Smith family", owner);
|
var household = new Household("Smith family", owner);
|
||||||
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
|
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
|
||||||
|
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
|
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "EXPIRED"))
|
assertThatThrownBy(() -> householdService.acceptInvite("EXPIRED", "Tom", "tom@example.com", "secret123"))
|
||||||
.isInstanceOf(ValidationException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldThrowWhenCodeAlreadyUsed() {
|
void acceptInviteShouldThrow404WhenCodeAlreadyUsed() {
|
||||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
|
||||||
var owner = testUser();
|
var owner = testUser();
|
||||||
var household = new Household("Smith family", owner);
|
var household = new Household("Smith family", owner);
|
||||||
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
|
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
|
||||||
invite.setStatus("used");
|
invite.setStatus("used");
|
||||||
|
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
|
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123"))
|
assertThatThrownBy(() -> householdService.acceptInvite("USED123", "Tom", "tom@example.com", "secret123"))
|
||||||
.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"))
|
|
||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldThrowWhenUserNotFound() {
|
void acceptInviteShouldThrow404WhenInviteNotFound() {
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty());
|
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);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +311,187 @@ class HouseholdServiceTest {
|
|||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.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
|
@Test
|
||||||
void getMembersShouldReturnAllMembers() {
|
void getMembersShouldReturnAllMembers() {
|
||||||
var user1 = testUser();
|
var user1 = testUser();
|
||||||
@@ -256,4 +525,23 @@ class HouseholdServiceTest {
|
|||||||
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
|
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
|
||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.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) {
|
private Recipe testRecipe(Household household, String name) {
|
||||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
|
var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
|
||||||
setId(r, Recipe.class, UUID.randomUUID());
|
setId(r, Recipe.class, UUID.randomUUID());
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class SuggestionsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Recipe createRecipe(String name) {
|
private Recipe createRecipe(String name) {
|
||||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true);
|
var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
|
||||||
setId(r, Recipe.class, UUID.randomUUID());
|
setId(r, Recipe.class, UUID.randomUUID());
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class VarietyScoreTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Recipe createRecipe(String name) {
|
private Recipe createRecipe(String name) {
|
||||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true);
|
var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
|
||||||
setId(r, Recipe.class, UUID.randomUUID());
|
setId(r, Recipe.class, UUID.randomUUID());
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class WeekPlanControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getSuggestionsShouldReturn200() throws Exception {
|
void getSuggestionsShouldReturn200() throws Exception {
|
||||||
var recipe = new SuggestionResponse.SuggestionRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15);
|
var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null);
|
||||||
var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
|
var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
|
||||||
var response = new SuggestionResponse(List.of(item));
|
var response = new SuggestionResponse(List.of(item));
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
@Test
|
||||||
void listRecipesShouldReturn200WithPagination() throws Exception {
|
void listRecipesShouldReturn200WithPagination() throws Exception {
|
||||||
|
var tag = new TagResponse(UUID.randomUUID(), "Rind", "protein");
|
||||||
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
|
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
|
||||||
(short) 4, (short) 45, "medium", true, null);
|
(short) 4, (short) 45, "medium", "https://example.com/img.jpg", List.of(tag));
|
||||||
|
|
||||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(),
|
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(),
|
||||||
isNull(), eq(20), eq(0)))
|
isNull(), eq(20), eq(0)))
|
||||||
.thenReturn(List.of(summary));
|
.thenReturn(List.of(summary));
|
||||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull()))
|
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull()))
|
||||||
.thenReturn(1L);
|
.thenReturn(1L);
|
||||||
|
|
||||||
mockMvc.perform(get("/v1/recipes")
|
mockMvc.perform(get("/v1/recipes")
|
||||||
@@ -62,6 +63,9 @@ class RecipeControllerTest {
|
|||||||
.param("offset", "0"))
|
.param("offset", "0"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.data[0].name").value("Spaghetti Bolognese"))
|
.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.total").value(1))
|
||||||
.andExpect(jsonPath("$.meta.pagination.hasMore").value(false));
|
.andExpect(jsonPath("$.meta.pagination.hasMore").value(false));
|
||||||
}
|
}
|
||||||
@@ -69,17 +73,16 @@ class RecipeControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void listRecipesWithFiltersShouldPassParams() throws Exception {
|
void listRecipesWithFiltersShouldPassParams() throws Exception {
|
||||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true),
|
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"),
|
||||||
eq(30), eq("-cookTimeMin"), eq(10), eq(5)))
|
eq(30), eq("-cookTimeMin"), eq(10), eq(5)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), eq(30)))
|
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(30)))
|
||||||
.thenReturn(0L);
|
.thenReturn(0L);
|
||||||
|
|
||||||
mockMvc.perform(get("/v1/recipes")
|
mockMvc.perform(get("/v1/recipes")
|
||||||
.principal(() -> "sarah@example.com")
|
.principal(() -> "sarah@example.com")
|
||||||
.param("search", "pasta")
|
.param("search", "pasta")
|
||||||
.param("effort", "easy")
|
.param("effort", "easy")
|
||||||
.param("isChildFriendly", "true")
|
|
||||||
.param("cookTimeMin.lte", "30")
|
.param("cookTimeMin.lte", "30")
|
||||||
.param("sort", "-cookTimeMin")
|
.param("sort", "-cookTimeMin")
|
||||||
.param("limit", "10")
|
.param("limit", "10")
|
||||||
@@ -162,6 +165,46 @@ class RecipeControllerTest {
|
|||||||
verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID);
|
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() {
|
private RecipeCreateRequest sampleCreateRequest() {
|
||||||
var ingredientId = UUID.randomUUID();
|
var ingredientId = UUID.randomUUID();
|
||||||
return new RecipeCreateRequest(
|
return new RecipeCreateRequest(
|
||||||
@@ -175,7 +218,7 @@ class RecipeControllerTest {
|
|||||||
private RecipeDetailResponse sampleDetail() {
|
private RecipeDetailResponse sampleDetail() {
|
||||||
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta");
|
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta");
|
||||||
return new RecipeDetailResponse(
|
return new RecipeDetailResponse(
|
||||||
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
|
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", null,
|
||||||
List.of(new RecipeDetailResponse.IngredientItem(
|
List.of(new RecipeDetailResponse.IngredientItem(
|
||||||
UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)),
|
UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)),
|
||||||
List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")),
|
List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class RecipeServiceTest {
|
|||||||
@Mock private TagRepository tagRepository;
|
@Mock private TagRepository tagRepository;
|
||||||
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
|
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
|
||||||
@Mock private HouseholdRepository householdRepository;
|
@Mock private HouseholdRepository householdRepository;
|
||||||
|
@Mock private ImageCompressor imageCompressor;
|
||||||
|
|
||||||
@InjectMocks private RecipeService recipeService;
|
@InjectMocks private RecipeService recipeService;
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ class RecipeServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Recipe testRecipe(Household household) {
|
private Recipe testRecipe(Household household) {
|
||||||
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true);
|
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium");
|
||||||
try {
|
try {
|
||||||
var field = Recipe.class.getDeclaredField("id");
|
var field = Recipe.class.getDeclaredField("id");
|
||||||
field.setAccessible(true);
|
field.setAccessible(true);
|
||||||
@@ -525,6 +526,29 @@ class RecipeServiceTest {
|
|||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRecipeWithNullServesAndCookTimeShouldStoreZero() {
|
||||||
|
var household = testHousehold();
|
||||||
|
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||||
|
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> {
|
||||||
|
Recipe r = i.getArgument(0);
|
||||||
|
try {
|
||||||
|
var field = Recipe.class.getDeclaredField("id");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(r, UUID.randomUUID());
|
||||||
|
} catch (Exception e) { throw new RuntimeException(e); }
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
var request = new RecipeCreateRequest("Soup", null, null, "easy", null,
|
||||||
|
List.of(), List.of(), List.of());
|
||||||
|
|
||||||
|
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
|
||||||
|
|
||||||
|
assertThat(result.serves()).isEqualTo((short) 0);
|
||||||
|
assertThat(result.cookTimeMin()).isEqualTo((short) 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tag/Category edge cases ──
|
// ── Tag/Category edge cases ──
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -547,6 +571,33 @@ class RecipeServiceTest {
|
|||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRecipeWithDisallowedImageTypeShouldThrowValidationException() {
|
||||||
|
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(testHousehold()));
|
||||||
|
|
||||||
|
var request = new RecipeCreateRequest(
|
||||||
|
"Test", null, null, "easy", "data:application/pdf;base64,abc",
|
||||||
|
List.of(), List.of(), List.of());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request))
|
||||||
|
.isInstanceOf(com.recipeapp.common.ValidationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRecipeWithAllowedImageTypeShouldNotThrow() {
|
||||||
|
var household = testHousehold();
|
||||||
|
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||||
|
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
// "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
|
@Test
|
||||||
void listTagsShouldReturnEmptyList() {
|
void listTagsShouldReturnEmptyList() {
|
||||||
when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of());
|
when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of());
|
||||||
@@ -555,4 +606,30 @@ class RecipeServiceTest {
|
|||||||
|
|
||||||
assertThat(result).isEmpty();
|
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) {
|
private Recipe testRecipe(Household household, String name) {
|
||||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
|
var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
|
||||||
setId(r, Recipe.class, UUID.randomUUID());
|
setId(r, Recipe.class, UUID.randomUUID());
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
--color-surface: #f5f4ee;
|
--color-surface: #f5f4ee;
|
||||||
--color-subtle: #edecea;
|
--color-subtle: #edecea;
|
||||||
--color-border: #d8d7d0;
|
--color-border: #d8d7d0;
|
||||||
|
--color-border-hover: #c0bfb8;
|
||||||
--color-text: #1c1c18;
|
--color-text: #1c1c18;
|
||||||
--color-text-muted: #6b6a63;
|
--color-text-muted: #6b6a63;
|
||||||
|
|
||||||
@@ -86,4 +87,28 @@
|
|||||||
--btn-font-size: 13px;
|
--btn-font-size: 13px;
|
||||||
--btn-font-weight: 500;
|
--btn-font-weight: 500;
|
||||||
--btn-letter-spacing: 0.04em;
|
--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);
|
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',
|
'allows public route %s without auth',
|
||||||
async (path) => {
|
async (path) => {
|
||||||
const { event, resolve } = createEvent(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'])(
|
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
|
||||||
'allows static asset %s without auth',
|
'allows static asset %s without auth',
|
||||||
async (path) => {
|
async (path) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit';
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { apiClient } from '$lib/server/api';
|
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'];
|
const STATIC_PREFIXES = ['/_app/', '/favicon'];
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@ function loginRedirect(pathname: string): never {
|
|||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
if (isPublicRoute(event.url.pathname)) {
|
if (isPublicRoute(event.url.pathname)) {
|
||||||
|
const isJoinRoute = event.url.pathname.startsWith('/join/');
|
||||||
|
if (isJoinRoute && event.cookies.get('JSESSIONID')) {
|
||||||
|
throw redirect(302, '/');
|
||||||
|
}
|
||||||
return resolve(event);
|
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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/v1/invites/{code}/accept": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -203,7 +219,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
get: operations["getActiveInvite"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post: operations["createInvite"];
|
post: operations["createInvite"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -212,6 +228,24 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/v1/cooking-logs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -552,7 +586,6 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
cookTimeMin?: number;
|
cookTimeMin?: number;
|
||||||
effort: string;
|
effort: string;
|
||||||
isChildFriendly?: boolean;
|
|
||||||
heroImageUrl?: string;
|
heroImageUrl?: string;
|
||||||
ingredients: components["schemas"]["IngredientEntry"][];
|
ingredients: components["schemas"]["IngredientEntry"][];
|
||||||
steps?: components["schemas"]["StepEntry"][];
|
steps?: components["schemas"]["StepEntry"][];
|
||||||
@@ -587,7 +620,6 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
cookTimeMin?: number;
|
cookTimeMin?: number;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
isChildFriendly?: boolean;
|
|
||||||
heroImageUrl?: string;
|
heroImageUrl?: string;
|
||||||
ingredients?: components["schemas"]["IngredientItem"][];
|
ingredients?: components["schemas"]["IngredientItem"][];
|
||||||
steps?: components["schemas"]["StepItem"][];
|
steps?: components["schemas"]["StepItem"][];
|
||||||
@@ -723,6 +755,20 @@ export interface components {
|
|||||||
data?: components["schemas"]["AcceptInviteResponse"];
|
data?: components["schemas"]["AcceptInviteResponse"];
|
||||||
meta?: components["schemas"]["Meta"];
|
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: {
|
Meta: {
|
||||||
pagination?: components["schemas"]["Pagination"];
|
pagination?: components["schemas"]["Pagination"];
|
||||||
};
|
};
|
||||||
@@ -765,6 +811,14 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
joinedAt?: string;
|
joinedAt?: string;
|
||||||
};
|
};
|
||||||
|
ChangeRoleRequest: {
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
ApiResponseMemberResponse: {
|
||||||
|
status?: string;
|
||||||
|
data?: components["schemas"]["MemberResponse"];
|
||||||
|
meta?: components["schemas"]["Meta"];
|
||||||
|
};
|
||||||
ApiResponseInviteResponse: {
|
ApiResponseInviteResponse: {
|
||||||
status?: string;
|
status?: string;
|
||||||
data?: components["schemas"]["InviteResponse"];
|
data?: components["schemas"]["InviteResponse"];
|
||||||
@@ -934,8 +988,7 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
cookTimeMin?: number;
|
cookTimeMin?: number;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
isChildFriendly?: boolean;
|
heroImagePreview?: string;
|
||||||
heroImageUrl?: string;
|
|
||||||
};
|
};
|
||||||
ApiResponseListAdminUserResponse: {
|
ApiResponseListAdminUserResponse: {
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -1322,7 +1375,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
acceptInvite: {
|
getInviteInfo: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -1332,6 +1385,37 @@ export interface operations {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: 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: {
|
responses: {
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
200: {
|
200: {
|
||||||
@@ -1342,6 +1426,16 @@ export interface operations {
|
|||||||
"*/*": components["schemas"]["ApiResponseAcceptInviteResponse"];
|
"*/*": 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: {
|
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: {
|
listAuditLog: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
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
|
// Shadows
|
||||||
'--shadow-card',
|
'--shadow-card',
|
||||||
'--shadow-raised',
|
'--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', () => {
|
describe('design token completeness', () => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('AppShell', () => {
|
|||||||
it('renders all navigation links from all nav variants', () => {
|
it('renders all navigation links from all nav variants', () => {
|
||||||
render(AppShell, { props: defaultProps });
|
render(AppShell, { props: defaultProps });
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
// Mobile: 4, Tablet: 4, Desktop: 5 = 13 total
|
// Mobile: 4, Tablet: 4, Desktop: 4 = 12 total
|
||||||
expect(links).toHaveLength(13);
|
expect(links).toHaveLength(12);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
{section.title}
|
{section.title}
|
||||||
</p>
|
</p>
|
||||||
{#each section.items as item (item.href)}
|
{#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
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
|||||||
@@ -28,17 +28,17 @@ describe('DesktopSidebar', () => {
|
|||||||
expect(screen.getByText('Einkauf')).toBeInTheDocument();
|
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' } });
|
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||||
expect(screen.getByText('Haushalt')).toBeInTheDocument();
|
expect(screen.getByText('Haushalt')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Mitglieder')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Einstellungen')).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' } });
|
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
expect(links).toHaveLength(5);
|
expect(links).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks active item with aria-current="page"', () => {
|
it('marks active item with aria-current="page"', () => {
|
||||||
@@ -59,3 +59,18 @@ describe('DesktopSidebar', () => {
|
|||||||
expect(widget).toBeInTheDocument();
|
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"
|
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)}
|
{#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
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
|||||||
@@ -53,3 +53,18 @@ describe('MobileTabBar', () => {
|
|||||||
expect(recipesLink).not.toHaveAttribute('aria-current');
|
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']);
|
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);
|
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', () => {
|
it('does not match unrelated route', () => {
|
||||||
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
|
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;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
extraPaths?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavSection {
|
export interface NavSection {
|
||||||
@@ -13,11 +14,15 @@ export const mobileNavItems: NavItem[] = [
|
|||||||
{ href: '/planner', label: 'Planer', icon: '📅' },
|
{ href: '/planner', label: 'Planer', icon: '📅' },
|
||||||
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
|
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
|
||||||
{ href: '/shopping', label: 'Einkauf', 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 {
|
export function isActiveRoute(href: string, pathname: string, extraPaths?: string[]): boolean {
|
||||||
return pathname === href || pathname.startsWith(href + '/');
|
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[] = [
|
export const desktopNavSections: NavSection[] = [
|
||||||
@@ -32,8 +37,7 @@ export const desktopNavSections: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: 'Haushalt',
|
title: 'Haushalt',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/members', label: 'Mitglieder', icon: '👥' },
|
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
|
||||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,30 +3,44 @@ import { render, screen } from '@testing-library/svelte';
|
|||||||
import VarietyWarningCards from './VarietyWarningCards.svelte';
|
import VarietyWarningCards from './VarietyWarningCards.svelte';
|
||||||
|
|
||||||
const warnings = [
|
const warnings = [
|
||||||
{ title: 'Chicken zweimal diese Woche', explanation: 'Mo, Mi — erwäge einen Tausch.' },
|
{
|
||||||
{ title: 'Tomaten in 3 Gerichten', explanation: 'Mo, Di, Mi — sorge für Abwechslung.' }
|
title: 'Chicken zweimal diese Woche',
|
||||||
|
items: [
|
||||||
|
{ dayShort: 'Mo', recipeName: 'Chicken Tikka', slotId: 1 },
|
||||||
|
{ dayShort: 'Mi', recipeName: 'Chicken Curry', slotId: 3 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tomaten in 3 Gerichten',
|
||||||
|
items: [
|
||||||
|
{ dayShort: 'Mo', recipeName: 'Pasta Pomodoro', slotId: 1 },
|
||||||
|
{ dayShort: 'Di', recipeName: 'Tomatensuppe', slotId: 2 },
|
||||||
|
{ dayShort: 'Mi', recipeName: 'Pizza Margherita', slotId: 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('VarietyWarningCards', () => {
|
describe('VarietyWarningCards', () => {
|
||||||
it('renders one card per warning', () => {
|
it('renders one card per warning', () => {
|
||||||
render(VarietyWarningCards, { props: { warnings } });
|
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
|
||||||
const cards = screen.getAllByTestId('warning-card');
|
const cards = screen.getAllByTestId('warning-card');
|
||||||
expect(cards.length).toBe(2);
|
expect(cards.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders warning titles', () => {
|
it('renders warning titles', () => {
|
||||||
render(VarietyWarningCards, { props: { warnings } });
|
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
|
||||||
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
|
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
|
||||||
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
|
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders warning explanations', () => {
|
it('renders warning explanations', () => {
|
||||||
render(VarietyWarningCards, { props: { warnings } });
|
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
|
||||||
expect(screen.getByText(/erwäge einen Tausch/)).toBeTruthy();
|
expect(screen.getByText('Chicken Tikka')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Chicken Curry')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders nothing when warnings is empty', () => {
|
it('renders nothing when warnings is empty', () => {
|
||||||
render(VarietyWarningCards, { props: { warnings: [] } });
|
render(VarietyWarningCards, { props: { warnings: [], weekStart: '2026-04-07' } });
|
||||||
expect(screen.queryAllByTestId('warning-card').length).toBe(0);
|
expect(screen.queryAllByTestId('warning-card').length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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 {
|
export interface Recipe {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
cookTimeMin?: number;
|
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 {
|
export interface Suggestion {
|
||||||
recipe: Recipe;
|
recipe: Recipe;
|
||||||
scoreDelta: number;
|
scoreDelta: number;
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
data-testid="image-area"
|
data-testid="image-area"
|
||||||
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
|
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
|
||||||
>
|
>
|
||||||
{#if recipe.heroImageUrl}
|
{#if recipe.heroImagePreview}
|
||||||
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
|
<img src={recipe.heroImagePreview} alt={recipe.name} class="w-full h-full object-cover" />
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
data-testid="image-placeholder"
|
data-testid="image-placeholder"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const mockRecipe = {
|
|||||||
name: 'Spaghetti Bolognese',
|
name: 'Spaghetti Bolognese',
|
||||||
cookTimeMin: 30,
|
cookTimeMin: 30,
|
||||||
effort: 'Easy',
|
effort: 'Easy',
|
||||||
heroImageUrl: undefined
|
heroImagePreview: undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('RecipeCard', () => {
|
describe('RecipeCard', () => {
|
||||||
@@ -27,18 +27,18 @@ describe('RecipeCard', () => {
|
|||||||
expect(screen.getByText(/easy/i)).toBeInTheDocument();
|
expect(screen.getByText(/easy/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows placeholder when no heroImageUrl', () => {
|
it('shows placeholder when no heroImagePreview', () => {
|
||||||
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } });
|
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImagePreview: undefined } } });
|
||||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||||
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
|
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows image when heroImageUrl is provided', () => {
|
it('shows image when heroImagePreview is provided', () => {
|
||||||
render(RecipeCard, {
|
render(RecipeCard, {
|
||||||
props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } }
|
props: { recipe: { ...mockRecipe, heroImagePreview: 'data:image/jpeg;base64,abc' } }
|
||||||
});
|
});
|
||||||
const img = screen.getByRole('img');
|
const img = screen.getByRole('img');
|
||||||
expect(img).toHaveAttribute('src', '/uploads/test.jpg');
|
expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc');
|
||||||
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
|
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,25 @@
|
|||||||
);
|
);
|
||||||
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
||||||
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null);
|
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null);
|
||||||
|
let imageError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||||
|
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
function handleImageChange(e: Event) {
|
function handleImageChange(e: Event) {
|
||||||
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
imageError = 'Datei zu groß. Maximal 5 MB erlaubt.';
|
||||||
|
(e.currentTarget as HTMLInputElement).value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
heroImageUrl = reader.result as string;
|
heroImageUrl = reader.result as string;
|
||||||
@@ -128,7 +143,7 @@
|
|||||||
for="cookTimeMin"
|
for="cookTimeMin"
|
||||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||||
>
|
>
|
||||||
Kochzeit
|
Kochzeit (min)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="cookTimeMin"
|
id="cookTimeMin"
|
||||||
@@ -180,7 +195,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (heroImageUrl = null)}
|
onclick={() => (heroImageUrl = null)}
|
||||||
class="mb-[8px] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
class="mb-[8px] text-[12px] text-[var(--color-error)] opacity-60 hover:opacity-100 cursor-pointer"
|
||||||
>
|
>
|
||||||
Bild entfernen
|
Bild entfernen
|
||||||
</button>
|
</button>
|
||||||
@@ -196,6 +211,11 @@
|
|||||||
/>
|
/>
|
||||||
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
|
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
|
||||||
</label>
|
</label>
|
||||||
|
{#if imageError}
|
||||||
|
<p class="mt-[6px] text-[12px] text-[var(--color-error)]">{imageError}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-[6px] text-[11px] text-[var(--color-text-muted)]">Max. 5 MB</p>
|
||||||
|
{/if}
|
||||||
<input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
|
<input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const editProps = {
|
|||||||
name: 'Spaghetti Bolognese',
|
name: 'Spaghetti Bolognese',
|
||||||
serves: 4,
|
serves: 4,
|
||||||
cookTimeMin: 30,
|
cookTimeMin: 30,
|
||||||
effort: 'Medium',
|
effort: 'medium',
|
||||||
heroImageUrl: undefined as string | undefined,
|
heroImageUrl: undefined as string | undefined,
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ name: 'Spaghetti', quantity: 200, unit: 'g' }
|
{ name: 'Spaghetti', quantity: 200, unit: 'g' }
|
||||||
@@ -162,4 +162,53 @@ describe('RecipeForm', () => {
|
|||||||
render(RecipeForm, { props: emptyProps });
|
render(RecipeForm, { props: emptyProps });
|
||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows Max. 5 MB hint below upload button', () => {
|
||||||
|
render(RecipeForm, { props: emptyProps });
|
||||||
|
expect(screen.getByText('Max. 5 MB')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when selected file exceeds 5 MB', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(RecipeForm, { props: emptyProps });
|
||||||
|
|
||||||
|
const oversizedFile = new File(['x'.repeat(6 * 1024 * 1024)], 'big.jpg', { type: 'image/jpeg' });
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
await user.upload(fileInput, oversizedFile);
|
||||||
|
|
||||||
|
expect(screen.getByText(/datei zu groß/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show file size error for file within 5 MB', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(RecipeForm, { props: emptyProps });
|
||||||
|
|
||||||
|
const okFile = new File(['x'.repeat(1 * 1024 * 1024)], 'small.jpg', { type: 'image/jpeg' });
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
await user.upload(fileInput, okFile);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/datei zu groß/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
name: string;
|
||||||
cookTimeMin?: number;
|
cookTimeMin?: number;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
heroImageUrl?: string;
|
heroImagePreview?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Tag = {
|
export type Tag = {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if isOnboarding}
|
{#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 -->
|
<!-- 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]">
|
<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} />
|
<ProgressSidebar currentStep={2} />
|
||||||
@@ -44,8 +44,10 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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>
|
<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" />
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -79,4 +79,36 @@ describe('staples page — settings context (no ctx)', () => {
|
|||||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||||
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
386
frontend/src/routes/(app)/members/MemberCard.svelte
Normal file
386
frontend/src/routes/(app)/members/MemberCard.svelte
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
type Member = {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
joinedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
member,
|
||||||
|
isCurrentUser,
|
||||||
|
isPlanner,
|
||||||
|
onremove,
|
||||||
|
onrolechange
|
||||||
|
}: {
|
||||||
|
member: Member;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
isPlanner: boolean;
|
||||||
|
onremove: (member: Member) => void;
|
||||||
|
onrolechange: (member: Member, newRole: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
let editingRole = $state(false);
|
||||||
|
|
||||||
|
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
|
||||||
|
|
||||||
|
const avatarBg = $derived(member.role === 'planner' ? 'var(--green-dark)' : 'var(--blue)');
|
||||||
|
|
||||||
|
const joinDateFormatted = $derived(
|
||||||
|
new Date(member.joinedAt).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let cardEl: HTMLElement | undefined = $state(undefined);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || !menuOpen) return;
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || !menuOpen) return;
|
||||||
|
|
||||||
|
function onClickAway(e: MouseEvent) {
|
||||||
|
if (cardEl && !cardEl.contains(e.target as Node)) {
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', onClickAway);
|
||||||
|
return () => document.removeEventListener('click', onClickAway);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article
|
||||||
|
bind:this={cardEl}
|
||||||
|
data-testid="member-card"
|
||||||
|
class="member-card"
|
||||||
|
class:own={isCurrentUser}
|
||||||
|
class:editing={editingRole}
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="avatar" style="background: {avatarBg};">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="member-name">{member.displayName}</div>
|
||||||
|
|
||||||
|
<!-- Role badge or inline role control -->
|
||||||
|
{#if editingRole}
|
||||||
|
<div role="group" class="role-control">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="role-control-btn"
|
||||||
|
class:active={member.role === 'planner'}
|
||||||
|
onclick={() => {
|
||||||
|
if (member.role !== 'planner') {
|
||||||
|
onrolechange(member, 'planner');
|
||||||
|
editingRole = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Planer</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="role-control-btn"
|
||||||
|
class:active={member.role === 'member'}
|
||||||
|
onclick={() => {
|
||||||
|
if (member.role !== 'member') {
|
||||||
|
onrolechange(member, 'member');
|
||||||
|
editingRole = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Mitglied</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="role-badge" class:planer={member.role === 'planner'} class:mitglied={member.role === 'member'}>
|
||||||
|
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Join date -->
|
||||||
|
<div class="join-date">seit {joinDateFormatted}</div>
|
||||||
|
|
||||||
|
<!-- Du badge (own card) or Abbrechen (when editing role) -->
|
||||||
|
{#if isCurrentUser}
|
||||||
|
<div class="self-badge-wrap">
|
||||||
|
<span class="self-badge">Du</span>
|
||||||
|
</div>
|
||||||
|
{:else if editingRole}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-btn"
|
||||||
|
onclick={() => { editingRole = false; }}
|
||||||
|
>Abbrechen</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Kebab button -->
|
||||||
|
{#if isPlanner && !isCurrentUser && !editingRole}
|
||||||
|
<button
|
||||||
|
data-testid="kebab-btn"
|
||||||
|
type="button"
|
||||||
|
class="kebab-btn"
|
||||||
|
onclick={() => { menuOpen = true; }}
|
||||||
|
aria-label="Optionen"
|
||||||
|
>⋯</button>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
{#if menuOpen}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => { menuOpen = false; editingRole = true; }}
|
||||||
|
><span class="dropdown-icon">🔄</span>Rolle ändern</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dropdown-item danger"
|
||||||
|
onclick={() => { menuOpen = false; onremove(member); }}
|
||||||
|
><span class="dropdown-icon">✕</span>Entfernen</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.member-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card.own {
|
||||||
|
border-color: var(--green-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card.editing {
|
||||||
|
border-color: #B5D4F4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.planer {
|
||||||
|
background: var(--green-tint);
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.mitglied {
|
||||||
|
background: var(--blue-tint);
|
||||||
|
color: var(--blue-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-badge-wrap {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--green-tint);
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-btn:hover {
|
||||||
|
background: var(--color-subtle);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.kebab-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card:hover .kebab-btn,
|
||||||
|
.member-card:focus-within .kebab-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 44px;
|
||||||
|
right: 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 10;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--color-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.danger {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.danger:hover {
|
||||||
|
background: var(--error-tint, #FDECEA);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control-btn:first-child {
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control-btn.active {
|
||||||
|
background: var(--green-dark);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.member-card {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
175
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal file
175
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import MemberCard from './MemberCard.svelte';
|
||||||
|
|
||||||
|
const plannerMember = {
|
||||||
|
userId: 'u1',
|
||||||
|
displayName: 'Sarah',
|
||||||
|
role: 'planner',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const regularMember = {
|
||||||
|
userId: 'u2',
|
||||||
|
displayName: 'Tom',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: '2024-02-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MemberCard', () => {
|
||||||
|
it('shows the member display name', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Sarah')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Du"-badge when isCurrentUser is true', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: true,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Du')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show kebab button when isCurrentUser is true', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: true,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('kebab-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show kebab button when viewer is not a planner', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('kebab-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows kebab button for other members when viewer is planner', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('kebab-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens dropdown when kebab is clicked', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
expect(screen.getByText('Rolle ändern')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Entfernen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onremove when "Entfernen" is clicked in dropdown', async () => {
|
||||||
|
const onremove = vi.fn();
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove,
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
await userEvent.click(screen.getByText('Entfernen'));
|
||||||
|
expect(onremove).toHaveBeenCalledWith(regularMember);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows SegmentedControl when "Rolle ändern" is clicked', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
await userEvent.click(screen.getByText('Rolle ändern'));
|
||||||
|
expect(screen.getByRole('group')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dropdown on Escape key', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
expect(screen.getByText('Entfernen')).toBeInTheDocument();
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByText('Entfernen')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows formatted join date', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/seit 01\.01\.2024/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Abbrechen button when editing role', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
await userEvent.click(screen.getByText('Rolle ändern'));
|
||||||
|
expect(screen.getByText(/abbrechen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal file
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MemberCard from './MemberCard.svelte';
|
||||||
|
import InviteCard from './InviteCard.svelte';
|
||||||
|
|
||||||
|
type Member = {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
joinedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
members,
|
||||||
|
currentUserId,
|
||||||
|
isPlanner,
|
||||||
|
showInviteCard,
|
||||||
|
onremove,
|
||||||
|
onrolechange,
|
||||||
|
oninviteclick
|
||||||
|
}: {
|
||||||
|
members: Member[];
|
||||||
|
currentUserId: string;
|
||||||
|
isPlanner: boolean;
|
||||||
|
showInviteCard: boolean;
|
||||||
|
onremove: (member: any) => void;
|
||||||
|
onrolechange: (member: any, newRole: string) => void;
|
||||||
|
oninviteclick: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sortedMembers = $derived(
|
||||||
|
[...members].sort((a, b) => {
|
||||||
|
if (a.userId === currentUserId) return -1;
|
||||||
|
if (b.userId === currentUserId) return 1;
|
||||||
|
return new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="member-grid">
|
||||||
|
{#each sortedMembers as m (m.userId)}
|
||||||
|
<MemberCard
|
||||||
|
member={m}
|
||||||
|
isCurrentUser={m.userId === currentUserId}
|
||||||
|
{isPlanner}
|
||||||
|
{onremove}
|
||||||
|
onrolechange={(m, role) => onrolechange(m, role)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if isPlanner && showInviteCard}
|
||||||
|
<InviteCard onclick={oninviteclick} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.member-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.member-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal file
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import MemberGrid from './MemberGrid.svelte';
|
||||||
|
|
||||||
|
const members = [
|
||||||
|
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
|
||||||
|
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' },
|
||||||
|
{ userId: 'u3', displayName: 'Anna', role: 'member', joinedAt: '2024-03-01T00:00:00Z' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('MemberGrid', () => {
|
||||||
|
it('renders all member cards', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u1',
|
||||||
|
isPlanner: true,
|
||||||
|
showInviteCard: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Sarah')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tom')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Anna')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows invite card when showInviteCard is true and isPlanner is true', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u1',
|
||||||
|
isPlanner: true,
|
||||||
|
showInviteCard: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides invite card when isPlanner is false', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u2',
|
||||||
|
isPlanner: false,
|
||||||
|
showInviteCard: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('invite-card')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Du"-badge on the current user card', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u1',
|
||||||
|
isPlanner: true,
|
||||||
|
showInviteCard: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Du')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal file
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
show,
|
||||||
|
member,
|
||||||
|
onconfirm,
|
||||||
|
oncancel
|
||||||
|
}: {
|
||||||
|
show: boolean;
|
||||||
|
member: { userId: string; displayName: string; role: string; joinedAt: string };
|
||||||
|
onconfirm: () => void;
|
||||||
|
oncancel: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isMobile = () => typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
{#if isMobile()}
|
||||||
|
<BottomSheet open={show} onclose={oncancel}>
|
||||||
|
<div data-testid="remove-dialog" style="padding: 24px 24px 32px;">
|
||||||
|
<h2 style="margin: 0 0 8px; font-size: 15px; font-weight: 500;">Mitglied entfernen?</h2>
|
||||||
|
<p style="font-size: 12px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
|
||||||
|
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={oncancel}
|
||||||
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 12px; font-weight: 500; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="confirm-remove-btn"
|
||||||
|
onclick={onconfirm}
|
||||||
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 12px; font-weight: 500; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="dialog-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,.45); display: flex; align-items: center; justify-content: center;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="remove-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="remove-dialog-title"
|
||||||
|
tabindex="-1"
|
||||||
|
style="background: white; border-radius: var(--radius-xl); padding: 28px 32px; max-width: 380px; width: 100%; box-shadow: var(--shadow-raised);"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="remove-dialog-title" style="font-size: 16px; font-weight: 500; margin: 0 0 8px;">Mitglied entfernen?</h2>
|
||||||
|
<p style="font-size: 13px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
|
||||||
|
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={oncancel}
|
||||||
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 13px; font-weight: 500; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="confirm-remove-btn"
|
||||||
|
onclick={onconfirm}
|
||||||
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 13px; font-weight: 500; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal file
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import RemoveDialog from './RemoveDialog.svelte';
|
||||||
|
|
||||||
|
const member = {
|
||||||
|
userId: 'u2',
|
||||||
|
displayName: 'Tom',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: '2024-02-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RemoveDialog', () => {
|
||||||
|
it('is not rendered when show is false', () => {
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: false, member, onconfirm: vi.fn(), oncancel: vi.fn() }
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('remove-dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the member displayName in dialog', () => {
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm: vi.fn(), oncancel: vi.fn() }
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('remove-dialog')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Tom/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onconfirm when confirm button is clicked', async () => {
|
||||||
|
const onconfirm = vi.fn();
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm, oncancel: vi.fn() }
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('confirm-remove-btn'));
|
||||||
|
expect(onconfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls oncancel when cancel button is clicked', async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm: vi.fn(), oncancel }
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /abbrechen/i }));
|
||||||
|
expect(oncancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call oncancel when backdrop is clicked', async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm: vi.fn(), oncancel }
|
||||||
|
});
|
||||||
|
const backdrop = screen.getByTestId('dialog-backdrop');
|
||||||
|
await userEvent.click(backdrop);
|
||||||
|
expect(oncancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal file
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ fetch, params }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { response } = await api.DELETE('/v1/households/mine/members/{userId}', {
|
||||||
|
params: { path: { userId: params.userId } }
|
||||||
|
});
|
||||||
|
return new Response(null, { status: response?.status ?? 204 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ fetch, params, request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, response } = await api.PATCH('/v1/households/mine/members/{userId}', {
|
||||||
|
params: { path: { userId: params.userId } },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
return json(data, { status: response?.status ?? 200 });
|
||||||
|
};
|
||||||
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal file
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
|
||||||
|
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
const mockPatch = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ DELETE: mockDelete, PATCH: mockPatch })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const USER_UUID = '22222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
describe('members server routes', () => {
|
||||||
|
let DELETE: any;
|
||||||
|
let PATCH: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockDelete.mockReset();
|
||||||
|
mockPatch.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+server');
|
||||||
|
DELETE = mod.DELETE;
|
||||||
|
PATCH = mod.PATCH;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE proxies to backend and returns 204', async () => {
|
||||||
|
mockDelete.mockResolvedValue({ response: { status: 204 } });
|
||||||
|
const event = {
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { userId: USER_UUID },
|
||||||
|
request: { json: vi.fn() }
|
||||||
|
} as any;
|
||||||
|
const res = await DELETE(event);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH proxies to backend and returns member response', async () => {
|
||||||
|
mockPatch.mockResolvedValue({
|
||||||
|
data: { status: 'success', data: { userId: USER_UUID, displayName: 'Tom', role: 'planner', joinedAt: '' } },
|
||||||
|
response: { status: 200 }
|
||||||
|
});
|
||||||
|
const event = {
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { userId: USER_UUID },
|
||||||
|
request: { json: async () => ({ role: 'planner' }) }
|
||||||
|
} as any;
|
||||||
|
const res = await PATCH(event);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal file
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ fetch }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, response } = await api.POST('/v1/households/mine/invites');
|
||||||
|
return json(data?.data, { status: response?.status ?? 201 });
|
||||||
|
};
|
||||||
33
frontend/src/routes/(app)/members/invites/server.test.ts
Normal file
33
frontend/src/routes/(app)/members/invites/server.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
|
||||||
|
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ POST: mockPost })
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('invites server route', () => {
|
||||||
|
let POST: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockPost.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+server');
|
||||||
|
POST = mod.POST;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST returns unwrapped InviteResponse', async () => {
|
||||||
|
const invite = { inviteCode: 'ABC123', shareUrl: 'https://x.com/join/ABC123', expiresAt: '2026-12-01T00:00:00Z' };
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { status: 'success', data: invite },
|
||||||
|
response: { status: 200 }
|
||||||
|
});
|
||||||
|
const event = { fetch: vi.fn() } as any;
|
||||||
|
const res = await POST(event);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.inviteCode).toBe('ABC123');
|
||||||
|
expect(body.shareUrl).toBe('https://x.com/join/ABC123');
|
||||||
|
expect(body.expiresAt).toBe('2026-12-01T00:00:00Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal file
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: vi.fn(() => ({
|
||||||
|
GET: vi.fn()
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
|
||||||
|
|
||||||
|
describe('members page.server load', () => {
|
||||||
|
let load: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
load = mod.load;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns members and currentUserId', async () => {
|
||||||
|
const mockGet = vi.fn().mockImplementation((path: string) => {
|
||||||
|
if (path === '/v1/households/mine/members') {
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
|
||||||
|
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === '/v1/households/mine/invites') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
inviteCode: 'ABC123',
|
||||||
|
shareUrl: 'https://x.com/join/ABC123',
|
||||||
|
expiresAt: '2024-12-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiClient } = await import('$lib/server/api');
|
||||||
|
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.members).toHaveLength(2);
|
||||||
|
expect(result.currentUserId).toBe('u1');
|
||||||
|
expect(result.activeInvite).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null activeInvite when no active invite exists', async () => {
|
||||||
|
const mockGet = vi.fn().mockImplementation((path: string) => {
|
||||||
|
if (path === '/v1/households/mine/members') return { data: [] };
|
||||||
|
if (path === '/v1/households/mine/invites') return { data: null };
|
||||||
|
return { data: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiClient } = await import('$lib/server/api');
|
||||||
|
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.activeInvite).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import type { PageServerLoad, Actions } from './$types';
|
|||||||
import { apiClient } from '$lib/server/api';
|
import { apiClient } from '$lib/server/api';
|
||||||
import { getWeekStart } from '$lib/planner/week';
|
import { getWeekStart } from '$lib/planner/week';
|
||||||
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
|
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
|
||||||
|
import type { TagItem } from '$lib/planner/types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
const weekParam = url.searchParams.get('week');
|
const weekParam = url.searchParams.get('week');
|
||||||
@@ -21,7 +22,8 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
|
|||||||
name: r.name!,
|
name: r.name!,
|
||||||
cookTimeMin: r.cookTimeMin,
|
cookTimeMin: r.cookTimeMin,
|
||||||
effort: r.effort,
|
effort: r.effort,
|
||||||
heroImageUrl: r.heroImageUrl
|
heroImageUrl: r.heroImageUrl,
|
||||||
|
tags: (r.tags ?? []).map((t: TagItem) => ({ id: t.id, name: t.name, tagType: t.tagType }))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (weekPlanResult.error || !weekPlanResult.data?.id) {
|
if (weekPlanResult.error || !weekPlanResult.data?.id) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user