From 9ec703abcdcfd562a6d960f03142c5d85b215b56 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Wed, 1 Apr 2026 21:56:51 +0200 Subject: [PATCH] Implement Recipe, Planning, Shopping, Pantry, and Admin domains Outside-in TDD for all 5 remaining domains (128 tests total): - Recipe: CRUD, ingredients autocomplete/patch, tags, categories (27 tests) - Planning: week plans, slots, confirm, suggestions, variety score, cooking logs (24 tests) - Shopping: generate from plan, publish, check/add/remove items (15 tests) - Pantry: CRUD with expiry sorting (11 tests) - Admin: user management, password reset, audit logging (13 tests) Co-Authored-By: Claude Sonnet 4.6 --- .../admin/AdminAuditLogRepository.java | 17 + .../com/recipeapp/admin/AdminController.java | 73 ++++ .../com/recipeapp/admin/AdminService.java | 21 ++ .../com/recipeapp/admin/AdminServiceImpl.java | 166 +++++++++ .../admin/AdminUserQueryRepository.java | 19 + .../admin/dto/AdminUserResponse.java | 13 + .../recipeapp/admin/dto/AuditLogResponse.java | 16 + .../admin/dto/CreateUserRequest.java | 12 + .../admin/dto/ResetPasswordRequest.java | 9 + .../admin/dto/ResetPasswordResponse.java | 6 + .../admin/dto/UpdateUserRequest.java | 8 + .../recipeapp/admin/entity/AdminAuditLog.java | 60 +++ .../recipeapp/pantry/PantryController.java | 56 +++ .../pantry/PantryItemRepository.java | 17 + .../com/recipeapp/pantry/PantryService.java | 17 + .../recipeapp/pantry/PantryServiceImpl.java | 98 +++++ .../pantry/dto/CreatePantryItemRequest.java | 14 + .../pantry/dto/PantryItemResponse.java | 18 + .../pantry/dto/UpdatePantryItemRequest.java | 11 + .../recipeapp/pantry/entity/PantryItem.java | 68 ++++ .../planning/CookingLogController.java | 44 +++ .../planning/CookingLogRepository.java | 14 + .../recipeapp/planning/PlanningService.java | 29 ++ .../planning/PlanningServiceImpl.java | 333 +++++++++++++++++ .../planning/WeekPlanController.java | 91 +++++ .../planning/WeekPlanRepository.java | 13 + .../planning/WeekPlanSlotRepository.java | 9 + .../planning/dto/CookingLogResponse.java | 6 + .../planning/dto/CreateCookingLogRequest.java | 7 + .../planning/dto/CreateSlotRequest.java | 7 + .../planning/dto/CreateWeekPlanRequest.java | 6 + .../recipeapp/planning/dto/SlotResponse.java | 12 + .../planning/dto/SuggestionResponse.java | 12 + .../planning/dto/UpdateSlotRequest.java | 6 + .../planning/dto/VarietyScoreResponse.java | 14 + .../planning/dto/WeekPlanResponse.java | 14 + .../recipeapp/planning/entity/CookingLog.java | 47 +++ .../recipeapp/planning/entity/WeekPlan.java | 50 +++ .../planning/entity/WeekPlanSlot.java | 40 ++ .../recipeapp/recipe/HouseholdResolver.java | 31 ++ .../recipe/IngredientCategoryController.java | 39 ++ .../recipe/IngredientCategoryRepository.java | 2 + .../recipe/IngredientController.java | 41 +++ .../recipe/IngredientRepository.java | 3 + .../recipeapp/recipe/RecipeController.java | 79 ++++ .../recipeapp/recipe/RecipeRepository.java | 57 +++ .../com/recipeapp/recipe/RecipeService.java | 35 ++ .../recipeapp/recipe/RecipeServiceImpl.java | 263 +++++++++++++ .../com/recipeapp/recipe/TagController.java | 39 ++ .../com/recipeapp/recipe/TagRepository.java | 1 + .../dto/IngredientCategoryCreateRequest.java | 8 + .../dto/IngredientCategoryResponse.java | 5 + .../recipe/dto/IngredientPatchRequest.java | 9 + .../recipe/dto/IngredientResponse.java | 10 + .../recipe/dto/RecipeCreateRequest.java | 32 ++ .../recipe/dto/RecipeDetailResponse.java | 33 ++ .../recipe/dto/RecipeSummaryResponse.java | 13 + .../recipe/dto/TagCreateRequest.java | 10 + .../com/recipeapp/recipe/dto/TagResponse.java | 5 + .../com/recipeapp/recipe/entity/Recipe.java | 112 ++++++ .../recipe/entity/RecipeIngredient.java | 52 +++ .../recipeapp/recipe/entity/RecipeStep.java | 38 ++ .../shopping/ShoppingListController.java | 68 ++++ .../shopping/ShoppingListItemRepository.java | 9 + .../shopping/ShoppingListRepository.java | 9 + .../recipeapp/shopping/ShoppingService.java | 21 ++ .../shopping/ShoppingServiceImpl.java | 270 ++++++++++++++ .../shopping/dto/AddItemRequest.java | 11 + .../shopping/dto/CheckItemRequest.java | 3 + .../shopping/dto/PublishResponse.java | 6 + .../dto/ShoppingListItemResponse.java | 19 + .../shopping/dto/ShoppingListResponse.java | 13 + .../shopping/entity/ShoppingList.java | 51 +++ .../shopping/entity/ShoppingListItem.java | 71 ++++ .../recipeapp/admin/AdminControllerTest.java | 119 ++++++ .../com/recipeapp/admin/AdminServiceTest.java | 170 +++++++++ .../pantry/PantryControllerTest.java | 116 ++++++ .../recipeapp/pantry/PantryServiceTest.java | 181 +++++++++ .../planning/CookingLogControllerTest.java | 81 ++++ .../planning/PlanningServiceTest.java | 280 ++++++++++++++ .../planning/WeekPlanControllerTest.java | 186 ++++++++++ .../IngredientCategoryControllerTest.java | 74 ++++ .../recipe/IngredientControllerTest.java | 79 ++++ .../recipe/RecipeControllerTest.java | 184 ++++++++++ .../recipeapp/recipe/RecipeServiceTest.java | 347 ++++++++++++++++++ .../recipeapp/recipe/TagControllerTest.java | 76 ++++ .../shopping/ShoppingListControllerTest.java | 148 ++++++++ .../shopping/ShoppingServiceTest.java | 285 ++++++++++++++ 88 files changed, 5267 insertions(+) create mode 100644 backend/src/main/java/com/recipeapp/admin/AdminAuditLogRepository.java create mode 100644 backend/src/main/java/com/recipeapp/admin/AdminController.java create mode 100644 backend/src/main/java/com/recipeapp/admin/AdminService.java create mode 100644 backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java create mode 100644 backend/src/main/java/com/recipeapp/admin/AdminUserQueryRepository.java create mode 100644 backend/src/main/java/com/recipeapp/admin/dto/AdminUserResponse.java create mode 100644 backend/src/main/java/com/recipeapp/admin/dto/AuditLogResponse.java create mode 100644 backend/src/main/java/com/recipeapp/admin/dto/CreateUserRequest.java create mode 100644 backend/src/main/java/com/recipeapp/admin/dto/ResetPasswordRequest.java create mode 100644 backend/src/main/java/com/recipeapp/admin/dto/ResetPasswordResponse.java create mode 100644 backend/src/main/java/com/recipeapp/admin/dto/UpdateUserRequest.java create mode 100644 backend/src/main/java/com/recipeapp/admin/entity/AdminAuditLog.java create mode 100644 backend/src/main/java/com/recipeapp/pantry/PantryController.java create mode 100644 backend/src/main/java/com/recipeapp/pantry/PantryItemRepository.java create mode 100644 backend/src/main/java/com/recipeapp/pantry/PantryService.java create mode 100644 backend/src/main/java/com/recipeapp/pantry/PantryServiceImpl.java create mode 100644 backend/src/main/java/com/recipeapp/pantry/dto/CreatePantryItemRequest.java create mode 100644 backend/src/main/java/com/recipeapp/pantry/dto/PantryItemResponse.java create mode 100644 backend/src/main/java/com/recipeapp/pantry/dto/UpdatePantryItemRequest.java create mode 100644 backend/src/main/java/com/recipeapp/pantry/entity/PantryItem.java create mode 100644 backend/src/main/java/com/recipeapp/planning/CookingLogController.java create mode 100644 backend/src/main/java/com/recipeapp/planning/CookingLogRepository.java create mode 100644 backend/src/main/java/com/recipeapp/planning/PlanningService.java create mode 100644 backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java create mode 100644 backend/src/main/java/com/recipeapp/planning/WeekPlanController.java create mode 100644 backend/src/main/java/com/recipeapp/planning/WeekPlanRepository.java create mode 100644 backend/src/main/java/com/recipeapp/planning/WeekPlanSlotRepository.java create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/CookingLogResponse.java create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/CreateCookingLogRequest.java create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/CreateSlotRequest.java create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/CreateWeekPlanRequest.java create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/SlotResponse.java create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/UpdateSlotRequest.java create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/VarietyScoreResponse.java create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/WeekPlanResponse.java create mode 100644 backend/src/main/java/com/recipeapp/planning/entity/CookingLog.java create mode 100644 backend/src/main/java/com/recipeapp/planning/entity/WeekPlan.java create mode 100644 backend/src/main/java/com/recipeapp/planning/entity/WeekPlanSlot.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/IngredientCategoryController.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/IngredientController.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/RecipeController.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/RecipeService.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/RecipeServiceImpl.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/TagController.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/dto/IngredientCategoryCreateRequest.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/dto/IngredientCategoryResponse.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/dto/IngredientPatchRequest.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/dto/IngredientResponse.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/dto/RecipeDetailResponse.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/dto/TagCreateRequest.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/dto/TagResponse.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/entity/RecipeIngredient.java create mode 100644 backend/src/main/java/com/recipeapp/recipe/entity/RecipeStep.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/ShoppingListItemRepository.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/ShoppingListRepository.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/ShoppingService.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/ShoppingServiceImpl.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/dto/AddItemRequest.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/dto/CheckItemRequest.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/dto/PublishResponse.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListItemResponse.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java create mode 100644 backend/src/main/java/com/recipeapp/shopping/entity/ShoppingListItem.java create mode 100644 backend/src/test/java/com/recipeapp/admin/AdminControllerTest.java create mode 100644 backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java create mode 100644 backend/src/test/java/com/recipeapp/pantry/PantryControllerTest.java create mode 100644 backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java create mode 100644 backend/src/test/java/com/recipeapp/planning/CookingLogControllerTest.java create mode 100644 backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java create mode 100644 backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java create mode 100644 backend/src/test/java/com/recipeapp/recipe/IngredientCategoryControllerTest.java create mode 100644 backend/src/test/java/com/recipeapp/recipe/IngredientControllerTest.java create mode 100644 backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java create mode 100644 backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java create mode 100644 backend/src/test/java/com/recipeapp/recipe/TagControllerTest.java create mode 100644 backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java create mode 100644 backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java diff --git a/backend/src/main/java/com/recipeapp/admin/AdminAuditLogRepository.java b/backend/src/main/java/com/recipeapp/admin/AdminAuditLogRepository.java new file mode 100644 index 0000000..8fe94d6 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/AdminAuditLogRepository.java @@ -0,0 +1,17 @@ +package com.recipeapp.admin; + +import com.recipeapp.admin.entity.AdminAuditLog; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface AdminAuditLogRepository extends JpaRepository { + + List findByTargetUserIdOrderByPerformedAtDesc(UUID targetUserId, Pageable pageable); + + List findAllByOrderByPerformedAtDesc(Pageable pageable); + + long countByTargetUserId(UUID targetUserId); +} diff --git a/backend/src/main/java/com/recipeapp/admin/AdminController.java b/backend/src/main/java/com/recipeapp/admin/AdminController.java new file mode 100644 index 0000000..06fbc3e --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/AdminController.java @@ -0,0 +1,73 @@ +package com.recipeapp.admin; + +import com.recipeapp.admin.dto.*; +import com.recipeapp.common.ApiResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/v1/admin") +public class AdminController { + + private final AdminService adminService; + + public AdminController(AdminService adminService) { + this.adminService = adminService; + } + + @GetMapping("/users") + public ResponseEntity>> listUsers( + @RequestParam(defaultValue = "50") int limit, + @RequestParam(defaultValue = "0") int offset, + @RequestParam(required = false) String search, + @RequestParam(required = false) Boolean isActive, + Principal principal) { + var result = adminService.listUsers(search, isActive, limit, offset); + var pagination = new ApiResponse.Pagination(result.total(), limit, offset, + offset + limit < result.total()); + var meta = new ApiResponse.Meta(pagination); + return ResponseEntity.ok(ApiResponse.success(result.users(), meta)); + } + + @PostMapping("/users") + public ResponseEntity> createUser( + @Valid @RequestBody CreateUserRequest request, + Principal principal) { + var response = adminService.createUser(request, principal.getName()); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); + } + + @PatchMapping("/users/{id}") + public ResponseEntity> updateUser( + @PathVariable UUID id, + @RequestBody UpdateUserRequest request, + Principal principal) { + var response = adminService.updateUser(id, request, principal.getName()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @PostMapping("/users/{id}/reset-password") + public ResponseEntity> resetPassword( + @PathVariable UUID id, + @Valid @RequestBody ResetPasswordRequest request, + Principal principal) { + var response = adminService.resetPassword(id, request, principal.getName()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @GetMapping("/audit-log") + public ResponseEntity>> listAuditLog( + @RequestParam(defaultValue = "50") int limit, + @RequestParam(defaultValue = "0") int offset, + @RequestParam(required = false) UUID targetUserId, + Principal principal) { + var logs = adminService.listAuditLog(targetUserId, limit, offset); + return ResponseEntity.ok(ApiResponse.success(logs)); + } +} diff --git a/backend/src/main/java/com/recipeapp/admin/AdminService.java b/backend/src/main/java/com/recipeapp/admin/AdminService.java new file mode 100644 index 0000000..4127e1f --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/AdminService.java @@ -0,0 +1,21 @@ +package com.recipeapp.admin; + +import com.recipeapp.admin.dto.*; + +import java.util.List; +import java.util.UUID; + +public interface AdminService { + + ListUsersResult listUsers(String search, Boolean isActive, int limit, int offset); + + AdminUserResponse createUser(CreateUserRequest request, String adminEmail); + + AdminUserResponse updateUser(UUID userId, UpdateUserRequest request, String adminEmail); + + ResetPasswordResponse resetPassword(UUID userId, ResetPasswordRequest request, String adminEmail); + + List listAuditLog(UUID targetUserId, int limit, int offset); + + record ListUsersResult(List users, long total) {} +} diff --git a/backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java b/backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java new file mode 100644 index 0000000..f453bc2 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java @@ -0,0 +1,166 @@ +package com.recipeapp.admin; + +import com.recipeapp.admin.dto.*; +import com.recipeapp.admin.entity.AdminAuditLog; +import com.recipeapp.auth.UserAccountRepository; +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.common.ConflictException; +import com.recipeapp.common.ResourceNotFoundException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Service +@Transactional +public class AdminServiceImpl implements AdminService { + + private final UserAccountRepository userAccountRepository; + private final AdminAuditLogRepository auditLogRepository; + private final AdminUserQueryRepository adminUserQueryRepository; + private final PasswordEncoder passwordEncoder; + + public AdminServiceImpl(UserAccountRepository userAccountRepository, + AdminAuditLogRepository auditLogRepository, + AdminUserQueryRepository adminUserQueryRepository, + PasswordEncoder passwordEncoder) { + this.userAccountRepository = userAccountRepository; + this.auditLogRepository = auditLogRepository; + this.adminUserQueryRepository = adminUserQueryRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional(readOnly = true) + public ListUsersResult listUsers(String search, Boolean isActive, int limit, int offset) { + Pageable pageable = PageRequest.of(offset / limit, limit); + var users = adminUserQueryRepository.findUsersFiltered(search, isActive, pageable); + long total = adminUserQueryRepository.countUsersFiltered(search, isActive); + var responses = users.stream().map(this::toAdminUserResponse).toList(); + return new ListUsersResult(responses, total); + } + + @Override + public AdminUserResponse createUser(CreateUserRequest request, String adminEmail) { + if (userAccountRepository.existsByEmailIgnoreCase(request.email())) { + throw new ConflictException("A user with this email already exists"); + } + + var admin = resolveAdmin(adminEmail); + String hashedPassword = passwordEncoder.encode(request.tempPassword()); + var user = new UserAccount(request.email(), request.displayName(), hashedPassword); + if (request.systemRole() != null) { + user.setSystemRole(request.systemRole()); + } + user = userAccountRepository.save(user); + + auditLogRepository.save(new AdminAuditLog( + admin.getId(), user.getId(), "create_account", + Map.of("email", request.email(), "displayName", request.displayName()), null)); + + return toAdminUserResponse(user); + } + + @Override + public AdminUserResponse updateUser(UUID userId, UpdateUserRequest request, String adminEmail) { + var admin = resolveAdmin(adminEmail); + var user = userAccountRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + + List actions = new ArrayList<>(); + Map detail = new HashMap<>(); + + if (request.displayName() != null) { + detail.put("displayName", request.displayName()); + user.setDisplayName(request.displayName()); + actions.add("update_account"); + } + if (request.email() != null) { + if (!request.email().equalsIgnoreCase(user.getEmail()) + && userAccountRepository.existsByEmailIgnoreCase(request.email())) { + throw new ConflictException("A user with this email already exists"); + } + detail.put("email", request.email()); + user.setEmail(request.email()); + actions.add("update_account"); + } + if (request.systemRole() != null && !request.systemRole().equals(user.getSystemRole())) { + detail.put("systemRole", request.systemRole()); + detail.put("previousSystemRole", user.getSystemRole()); + user.setSystemRole(request.systemRole()); + actions.add("change_system_role"); + } + if (request.isActive() != null && request.isActive() != user.isActive()) { + detail.put("isActive", request.isActive()); + user.setActive(request.isActive()); + actions.add(request.isActive() ? "reactivate_account" : "deactivate_account"); + } + + user = userAccountRepository.save(user); + + String action = actions.isEmpty() ? "update_account" : actions.getLast(); + auditLogRepository.save(new AdminAuditLog( + admin.getId(), user.getId(), action, detail, null)); + + return toAdminUserResponse(user); + } + + @Override + public ResetPasswordResponse resetPassword(UUID userId, ResetPasswordRequest request, String adminEmail) { + var admin = resolveAdmin(adminEmail); + var user = userAccountRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + + user.setPasswordHash(passwordEncoder.encode(request.tempPassword())); + userAccountRepository.save(user); + + Map detail = new HashMap<>(); + if (request.reason() != null) { + detail.put("reason", request.reason()); + } + auditLogRepository.save(new AdminAuditLog( + admin.getId(), user.getId(), "reset_password", detail, null)); + + return new ResetPasswordResponse("Password reset successfully", true); + } + + @Override + @Transactional(readOnly = true) + public List listAuditLog(UUID targetUserId, int limit, int offset) { + Pageable pageable = PageRequest.of(offset / limit, limit); + List logs; + if (targetUserId != null) { + logs = auditLogRepository.findByTargetUserIdOrderByPerformedAtDesc(targetUserId, pageable); + } else { + logs = auditLogRepository.findAllByOrderByPerformedAtDesc(pageable); + } + + return logs.stream().map(log -> { + String adminEmail = userAccountRepository.findById(log.getAdminId()) + .map(UserAccount::getEmail).orElse(null); + String targetEmail = userAccountRepository.findById(log.getTargetUserId()) + .map(UserAccount::getEmail).orElse(null); + return new AuditLogResponse( + log.getId(), log.getAdminId(), adminEmail, + log.getTargetUserId(), targetEmail, + log.getAction(), log.getDetail(), log.getPerformedAt()); + }).toList(); + } + + private UserAccount resolveAdmin(String adminEmail) { + return userAccountRepository.findByEmailIgnoreCase(adminEmail) + .orElseThrow(() -> new ResourceNotFoundException("Admin user not found")); + } + + private AdminUserResponse toAdminUserResponse(UserAccount user) { + return new AdminUserResponse( + user.getId(), user.getEmail(), user.getDisplayName(), + user.getSystemRole(), user.isActive(), user.getCreatedAt()); + } +} diff --git a/backend/src/main/java/com/recipeapp/admin/AdminUserQueryRepository.java b/backend/src/main/java/com/recipeapp/admin/AdminUserQueryRepository.java new file mode 100644 index 0000000..0643212 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/AdminUserQueryRepository.java @@ -0,0 +1,19 @@ +package com.recipeapp.admin; + +import com.recipeapp.auth.entity.UserAccount; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.UUID; + +public interface AdminUserQueryRepository extends JpaRepository { + + @Query("SELECT u FROM UserAccount u WHERE (:search IS NULL OR LOWER(u.email) LIKE LOWER(CONCAT('%', :search, '%')) OR LOWER(u.displayName) LIKE LOWER(CONCAT('%', :search, '%'))) AND (:isActive IS NULL OR u.isActive = :isActive)") + List findUsersFiltered(@Param("search") String search, @Param("isActive") Boolean isActive, Pageable pageable); + + @Query("SELECT COUNT(u) FROM UserAccount u WHERE (:search IS NULL OR LOWER(u.email) LIKE LOWER(CONCAT('%', :search, '%')) OR LOWER(u.displayName) LIKE LOWER(CONCAT('%', :search, '%'))) AND (:isActive IS NULL OR u.isActive = :isActive)") + long countUsersFiltered(@Param("search") String search, @Param("isActive") Boolean isActive); +} diff --git a/backend/src/main/java/com/recipeapp/admin/dto/AdminUserResponse.java b/backend/src/main/java/com/recipeapp/admin/dto/AdminUserResponse.java new file mode 100644 index 0000000..844108f --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/dto/AdminUserResponse.java @@ -0,0 +1,13 @@ +package com.recipeapp.admin.dto; + +import java.time.Instant; +import java.util.UUID; + +public record AdminUserResponse( + UUID id, + String email, + String displayName, + String systemRole, + boolean isActive, + Instant createdAt +) {} diff --git a/backend/src/main/java/com/recipeapp/admin/dto/AuditLogResponse.java b/backend/src/main/java/com/recipeapp/admin/dto/AuditLogResponse.java new file mode 100644 index 0000000..2e76c19 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/dto/AuditLogResponse.java @@ -0,0 +1,16 @@ +package com.recipeapp.admin.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +public record AuditLogResponse( + UUID id, + UUID adminId, + String adminEmail, + UUID targetUserId, + String targetEmail, + String action, + Map detail, + Instant performedAt +) {} diff --git a/backend/src/main/java/com/recipeapp/admin/dto/CreateUserRequest.java b/backend/src/main/java/com/recipeapp/admin/dto/CreateUserRequest.java new file mode 100644 index 0000000..a2279d6 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/dto/CreateUserRequest.java @@ -0,0 +1,12 @@ +package com.recipeapp.admin.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateUserRequest( + @NotBlank @Email String email, + @NotBlank @Size(max = 100) String displayName, + @NotBlank @Size(min = 8) String tempPassword, + String systemRole +) {} diff --git a/backend/src/main/java/com/recipeapp/admin/dto/ResetPasswordRequest.java b/backend/src/main/java/com/recipeapp/admin/dto/ResetPasswordRequest.java new file mode 100644 index 0000000..4bdcb7c --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/dto/ResetPasswordRequest.java @@ -0,0 +1,9 @@ +package com.recipeapp.admin.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ResetPasswordRequest( + @NotBlank @Size(min = 8) String tempPassword, + String reason +) {} diff --git a/backend/src/main/java/com/recipeapp/admin/dto/ResetPasswordResponse.java b/backend/src/main/java/com/recipeapp/admin/dto/ResetPasswordResponse.java new file mode 100644 index 0000000..64b29ae --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/dto/ResetPasswordResponse.java @@ -0,0 +1,6 @@ +package com.recipeapp.admin.dto; + +public record ResetPasswordResponse( + String message, + boolean mustChangePassword +) {} diff --git a/backend/src/main/java/com/recipeapp/admin/dto/UpdateUserRequest.java b/backend/src/main/java/com/recipeapp/admin/dto/UpdateUserRequest.java new file mode 100644 index 0000000..ae1fc1b --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/dto/UpdateUserRequest.java @@ -0,0 +1,8 @@ +package com.recipeapp.admin.dto; + +public record UpdateUserRequest( + String displayName, + String email, + String systemRole, + Boolean isActive +) {} diff --git a/backend/src/main/java/com/recipeapp/admin/entity/AdminAuditLog.java b/backend/src/main/java/com/recipeapp/admin/entity/AdminAuditLog.java new file mode 100644 index 0000000..51161e3 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/admin/entity/AdminAuditLog.java @@ -0,0 +1,60 @@ +package com.recipeapp.admin.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Entity +@Table(name = "admin_audit_log") +public class AdminAuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "admin_id", nullable = false) + private UUID adminId; + + @Column(name = "target_user_id", nullable = false) + private UUID targetUserId; + + @Column(nullable = false, length = 30) + private String action; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map detail; + + @Column(name = "ip_address", columnDefinition = "inet") + private String ipAddress; + + @Column(name = "performed_at", nullable = false, updatable = false) + private Instant performedAt; + + protected AdminAuditLog() {} + + public AdminAuditLog(UUID adminId, UUID targetUserId, String action, Map detail, String ipAddress) { + this.adminId = adminId; + this.targetUserId = targetUserId; + this.action = action; + this.detail = detail; + this.ipAddress = ipAddress; + } + + @PrePersist + void onCreate() { + performedAt = Instant.now(); + } + + public UUID getId() { return id; } + public UUID getAdminId() { return adminId; } + public UUID getTargetUserId() { return targetUserId; } + public String getAction() { return action; } + public Map getDetail() { return detail; } + public String getIpAddress() { return ipAddress; } + public Instant getPerformedAt() { return performedAt; } +} diff --git a/backend/src/main/java/com/recipeapp/pantry/PantryController.java b/backend/src/main/java/com/recipeapp/pantry/PantryController.java new file mode 100644 index 0000000..6f64177 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/pantry/PantryController.java @@ -0,0 +1,56 @@ +package com.recipeapp.pantry; + +import com.recipeapp.pantry.dto.*; +import com.recipeapp.recipe.HouseholdResolver; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/v1/pantry-items") +public class PantryController { + + private final PantryService pantryService; + private final HouseholdResolver householdResolver; + + public PantryController(PantryService pantryService, HouseholdResolver householdResolver) { + this.pantryService = pantryService; + this.householdResolver = householdResolver; + } + + @GetMapping + public List listItems(Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + return pantryService.listItems(householdId); + } + + @PostMapping + public ResponseEntity createItem( + Principal principal, + @Valid @RequestBody CreatePantryItemRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + PantryItemResponse response = pantryService.createItem(householdId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PatchMapping("/{id}") + public PantryItemResponse updateItem( + Principal principal, + @PathVariable UUID id, + @Valid @RequestBody UpdatePantryItemRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + return pantryService.updateItem(householdId, id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteItem(Principal principal, @PathVariable UUID id) { + UUID householdId = householdResolver.resolve(principal.getName()); + pantryService.deleteItem(householdId, id); + } +} diff --git a/backend/src/main/java/com/recipeapp/pantry/PantryItemRepository.java b/backend/src/main/java/com/recipeapp/pantry/PantryItemRepository.java new file mode 100644 index 0000000..a39e9c1 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/pantry/PantryItemRepository.java @@ -0,0 +1,17 @@ +package com.recipeapp.pantry; + +import com.recipeapp.pantry.entity.PantryItem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface PantryItemRepository extends JpaRepository { + + @Query("SELECT p FROM PantryItem p WHERE p.household.id = :householdId ORDER BY p.bestBefore ASC NULLS LAST") + List findByHouseholdIdOrderByBestBeforeAscNullsLast(UUID householdId); + + Optional findByIdAndHouseholdId(UUID id, UUID householdId); +} diff --git a/backend/src/main/java/com/recipeapp/pantry/PantryService.java b/backend/src/main/java/com/recipeapp/pantry/PantryService.java new file mode 100644 index 0000000..33c5e25 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/pantry/PantryService.java @@ -0,0 +1,17 @@ +package com.recipeapp.pantry; + +import com.recipeapp.pantry.dto.*; + +import java.util.List; +import java.util.UUID; + +public interface PantryService { + + List listItems(UUID householdId); + + PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request); + + PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request); + + void deleteItem(UUID householdId, UUID itemId); +} diff --git a/backend/src/main/java/com/recipeapp/pantry/PantryServiceImpl.java b/backend/src/main/java/com/recipeapp/pantry/PantryServiceImpl.java new file mode 100644 index 0000000..e5518d2 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/pantry/PantryServiceImpl.java @@ -0,0 +1,98 @@ +package com.recipeapp.pantry; + +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ValidationException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.pantry.dto.*; +import com.recipeapp.pantry.entity.PantryItem; +import com.recipeapp.recipe.IngredientRepository; +import com.recipeapp.recipe.dto.RecipeDetailResponse.CategoryRef; +import com.recipeapp.recipe.entity.Ingredient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@Transactional +public class PantryServiceImpl implements PantryService { + + private final PantryItemRepository pantryItemRepository; + private final HouseholdRepository householdRepository; + private final IngredientRepository ingredientRepository; + + public PantryServiceImpl(PantryItemRepository pantryItemRepository, + HouseholdRepository householdRepository, + IngredientRepository ingredientRepository) { + this.pantryItemRepository = pantryItemRepository; + this.householdRepository = householdRepository; + this.ingredientRepository = ingredientRepository; + } + + @Override + @Transactional(readOnly = true) + public List listItems(UUID householdId) { + return pantryItemRepository.findByHouseholdIdOrderByBestBeforeAscNullsLast(householdId) + .stream() + .map(this::toResponse) + .toList(); + } + + @Override + public PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request) { + Household household = householdRepository.findById(householdId) + .orElseThrow(() -> new ResourceNotFoundException("Household not found")); + + Ingredient ingredient = null; + if (request.ingredientId() != null) { + ingredient = ingredientRepository.findById(request.ingredientId()) + .orElseThrow(() -> new ResourceNotFoundException("Ingredient not found")); + } + + if (request.ingredientId() == null && (request.customName() == null || request.customName().isBlank())) { + throw new ValidationException("Either ingredientId or customName must be provided"); + } + + PantryItem item = new PantryItem(household, ingredient, request.customName(), + request.quantity(), request.unit(), request.bestBefore(), request.openedOn()); + item = pantryItemRepository.save(item); + return toResponse(item); + } + + @Override + public PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request) { + PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId) + .orElseThrow(() -> new ResourceNotFoundException("Pantry item not found")); + + if (request.quantity() != null) item.setQuantity(request.quantity()); + if (request.unit() != null) item.setUnit(request.unit()); + if (request.bestBefore() != null) item.setBestBefore(request.bestBefore()); + if (request.openedOn() != null) item.setOpenedOn(request.openedOn()); + + item = pantryItemRepository.save(item); + return toResponse(item); + } + + @Override + public void deleteItem(UUID householdId, UUID itemId) { + PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId) + .orElseThrow(() -> new ResourceNotFoundException("Pantry item not found")); + pantryItemRepository.delete(item); + } + + private PantryItemResponse toResponse(PantryItem item) { + UUID ingredientId = item.getIngredient() != null ? item.getIngredient().getId() : null; + String name = item.getIngredient() != null ? item.getIngredient().getName() : item.getCustomName(); + CategoryRef category = null; + if (item.getIngredient() != null && item.getIngredient().getCategory() != null) { + category = new CategoryRef( + item.getIngredient().getCategory().getId(), + item.getIngredient().getCategory().getName()); + } + return new PantryItemResponse( + item.getId(), ingredientId, name, category, + item.getQuantity(), item.getUnit(), item.getBestBefore(), item.getOpenedOn()); + } +} diff --git a/backend/src/main/java/com/recipeapp/pantry/dto/CreatePantryItemRequest.java b/backend/src/main/java/com/recipeapp/pantry/dto/CreatePantryItemRequest.java new file mode 100644 index 0000000..2478924 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/pantry/dto/CreatePantryItemRequest.java @@ -0,0 +1,14 @@ +package com.recipeapp.pantry.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public record CreatePantryItemRequest( + UUID ingredientId, + String customName, + BigDecimal quantity, + String unit, + LocalDate bestBefore, + LocalDate openedOn +) {} diff --git a/backend/src/main/java/com/recipeapp/pantry/dto/PantryItemResponse.java b/backend/src/main/java/com/recipeapp/pantry/dto/PantryItemResponse.java new file mode 100644 index 0000000..8ca982a --- /dev/null +++ b/backend/src/main/java/com/recipeapp/pantry/dto/PantryItemResponse.java @@ -0,0 +1,18 @@ +package com.recipeapp.pantry.dto; + +import com.recipeapp.recipe.dto.RecipeDetailResponse.CategoryRef; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public record PantryItemResponse( + UUID id, + UUID ingredientId, + String name, + CategoryRef category, + BigDecimal quantity, + String unit, + LocalDate bestBefore, + LocalDate openedOn +) {} diff --git a/backend/src/main/java/com/recipeapp/pantry/dto/UpdatePantryItemRequest.java b/backend/src/main/java/com/recipeapp/pantry/dto/UpdatePantryItemRequest.java new file mode 100644 index 0000000..b7af08c --- /dev/null +++ b/backend/src/main/java/com/recipeapp/pantry/dto/UpdatePantryItemRequest.java @@ -0,0 +1,11 @@ +package com.recipeapp.pantry.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record UpdatePantryItemRequest( + BigDecimal quantity, + String unit, + LocalDate bestBefore, + LocalDate openedOn +) {} diff --git a/backend/src/main/java/com/recipeapp/pantry/entity/PantryItem.java b/backend/src/main/java/com/recipeapp/pantry/entity/PantryItem.java new file mode 100644 index 0000000..03f8f79 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/pantry/entity/PantryItem.java @@ -0,0 +1,68 @@ +package com.recipeapp.pantry.entity; + +import com.recipeapp.household.entity.Household; +import com.recipeapp.recipe.entity.Ingredient; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "pantry_item") +public class PantryItem { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "household_id", nullable = false) + private Household household; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ingredient_id") + private Ingredient ingredient; + + @Column(name = "custom_name", length = 200) + private String customName; + + @Column(precision = 8, scale = 2) + private BigDecimal quantity; + + @Column(length = 20) + private String unit; + + @Column(name = "best_before") + private LocalDate bestBefore; + + @Column(name = "opened_on") + private LocalDate openedOn; + + protected PantryItem() {} + + public PantryItem(Household household, Ingredient ingredient, String customName, + BigDecimal quantity, String unit, LocalDate bestBefore, LocalDate openedOn) { + this.household = household; + this.ingredient = ingredient; + this.customName = customName; + this.quantity = quantity; + this.unit = unit; + this.bestBefore = bestBefore; + this.openedOn = openedOn; + } + + public UUID getId() { return id; } + public Household getHousehold() { return household; } + public Ingredient getIngredient() { return ingredient; } + public void setIngredient(Ingredient ingredient) { this.ingredient = ingredient; } + public String getCustomName() { return customName; } + public void setCustomName(String customName) { this.customName = customName; } + public BigDecimal getQuantity() { return quantity; } + public void setQuantity(BigDecimal quantity) { this.quantity = quantity; } + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + public LocalDate getBestBefore() { return bestBefore; } + public void setBestBefore(LocalDate bestBefore) { this.bestBefore = bestBefore; } + public LocalDate getOpenedOn() { return openedOn; } + public void setOpenedOn(LocalDate openedOn) { this.openedOn = openedOn; } +} diff --git a/backend/src/main/java/com/recipeapp/planning/CookingLogController.java b/backend/src/main/java/com/recipeapp/planning/CookingLogController.java new file mode 100644 index 0000000..c0635d8 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/CookingLogController.java @@ -0,0 +1,44 @@ +package com.recipeapp.planning; + +import com.recipeapp.planning.dto.*; +import com.recipeapp.recipe.HouseholdResolver; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/v1/cooking-logs") +public class CookingLogController { + + private final PlanningService planningService; + private final HouseholdResolver householdResolver; + + public CookingLogController(PlanningService planningService, HouseholdResolver householdResolver) { + this.planningService = planningService; + this.householdResolver = householdResolver; + } + + @PostMapping + public ResponseEntity createCookingLog( + Principal principal, + @Valid @RequestBody CreateCookingLogRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + UUID userId = householdResolver.resolveUserId(principal.getName()); + CookingLogResponse response = planningService.createCookingLog(householdId, userId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping + public List listCookingLogs( + Principal principal, + @RequestParam(defaultValue = "30") int limit, + @RequestParam(defaultValue = "0") int offset) { + UUID householdId = householdResolver.resolve(principal.getName()); + return planningService.listCookingLogs(householdId, limit, offset); + } +} diff --git a/backend/src/main/java/com/recipeapp/planning/CookingLogRepository.java b/backend/src/main/java/com/recipeapp/planning/CookingLogRepository.java new file mode 100644 index 0000000..4ed585a --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/CookingLogRepository.java @@ -0,0 +1,14 @@ +package com.recipeapp.planning; + +import com.recipeapp.planning.entity.CookingLog; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public interface CookingLogRepository extends JpaRepository { + List findByHouseholdIdOrderByCookedOnDesc(UUID householdId, Pageable pageable); + List findByHouseholdIdAndCookedOnAfter(UUID householdId, LocalDate after); +} diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java new file mode 100644 index 0000000..f2ac5f5 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -0,0 +1,29 @@ +package com.recipeapp.planning; + +import com.recipeapp.planning.dto.*; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public interface PlanningService { + + WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart); + + WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart); + + SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request); + + SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request); + + void deleteSlot(UUID householdId, UUID planId, UUID slotId); + + WeekPlanResponse confirmPlan(UUID householdId, UUID planId); + + SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate); + + VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId); + + CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request); + + List listCookingLogs(UUID householdId, int limit, int offset); +} diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java b/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java new file mode 100644 index 0000000..40df2a8 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java @@ -0,0 +1,333 @@ +package com.recipeapp.planning; + +import com.recipeapp.auth.UserAccountRepository; +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.common.ConflictException; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ValidationException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.planning.dto.*; +import com.recipeapp.planning.entity.*; +import com.recipeapp.recipe.RecipeRepository; +import com.recipeapp.recipe.entity.Recipe; +import com.recipeapp.recipe.entity.RecipeIngredient; +import com.recipeapp.recipe.entity.Tag; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class PlanningServiceImpl implements PlanningService { + + private final WeekPlanRepository weekPlanRepository; + private final WeekPlanSlotRepository weekPlanSlotRepository; + private final CookingLogRepository cookingLogRepository; + private final RecipeRepository recipeRepository; + private final HouseholdRepository householdRepository; + private final UserAccountRepository userAccountRepository; + + public PlanningServiceImpl(WeekPlanRepository weekPlanRepository, + WeekPlanSlotRepository weekPlanSlotRepository, + CookingLogRepository cookingLogRepository, + RecipeRepository recipeRepository, + HouseholdRepository householdRepository, + UserAccountRepository userAccountRepository) { + this.weekPlanRepository = weekPlanRepository; + this.weekPlanSlotRepository = weekPlanSlotRepository; + this.cookingLogRepository = cookingLogRepository; + this.recipeRepository = recipeRepository; + this.householdRepository = householdRepository; + this.userAccountRepository = userAccountRepository; + } + + @Override + @Transactional(readOnly = true) + public WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart) { + WeekPlan plan = weekPlanRepository.findByHouseholdIdAndWeekStart(householdId, weekStart) + .orElseThrow(() -> new ResourceNotFoundException("Week plan not found")); + return toWeekPlanResponse(plan); + } + + @Override + @Transactional + public WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart) { + if (weekStart.getDayOfWeek() != DayOfWeek.MONDAY) { + throw new ValidationException("weekStart must be a Monday"); + } + if (weekPlanRepository.existsByHouseholdIdAndWeekStart(householdId, weekStart)) { + throw new ConflictException("Week plan already exists for this week"); + } + Household household = householdRepository.findById(householdId) + .orElseThrow(() -> new ResourceNotFoundException("Household not found")); + WeekPlan plan = weekPlanRepository.save(new WeekPlan(household, weekStart)); + return toWeekPlanResponse(plan); + } + + @Override + @Transactional + public SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request) { + WeekPlan plan = findPlan(planId, householdId); + Recipe recipe = findRecipe(request.recipeId(), householdId); + WeekPlanSlot slot = weekPlanSlotRepository.save( + new WeekPlanSlot(plan, recipe, request.slotDate())); + return toSlotResponse(slot); + } + + @Override + @Transactional + public SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request) { + findPlan(planId, householdId); + WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId) + .orElseThrow(() -> new ResourceNotFoundException("Slot not found")); + Recipe recipe = findRecipe(request.recipeId(), householdId); + slot.setRecipe(recipe); + return toSlotResponse(slot); + } + + @Override + @Transactional + public void deleteSlot(UUID householdId, UUID planId, UUID slotId) { + findPlan(planId, householdId); + WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId) + .orElseThrow(() -> new ResourceNotFoundException("Slot not found")); + weekPlanSlotRepository.delete(slot); + } + + @Override + @Transactional + public WeekPlanResponse confirmPlan(UUID householdId, UUID planId) { + WeekPlan plan = findPlan(planId, householdId); + if ("confirmed".equals(plan.getStatus())) { + throw new ValidationException("Plan is already confirmed"); + } + if (plan.getSlots().isEmpty()) { + throw new ValidationException("Plan has no slots"); + } + plan.setStatus("confirmed"); + plan.setConfirmedAt(Instant.now()); + return toWeekPlanResponse(plan); + } + + @Override + @Transactional(readOnly = true) + public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate) { + WeekPlan plan = findPlan(planId, householdId); + + // Collect recipes already in this plan + Set usedRecipeIds = plan.getSlots().stream() + .map(s -> s.getRecipe().getId()) + .collect(Collectors.toSet()); + + // Collect proteins used in adjacent days + Set adjacentProteins = new HashSet<>(); + for (WeekPlanSlot slot : plan.getSlots()) { + if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) { + for (Tag tag : slot.getRecipe().getTags()) { + if ("protein".equals(tag.getTagType())) { + adjacentProteins.add(tag.getName().toLowerCase()); + } + } + } + } + + // Collect ingredients used in adjacent days + Set adjacentIngredientIds = new HashSet<>(); + for (WeekPlanSlot slot : plan.getSlots()) { + if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) { + for (RecipeIngredient ri : slot.getRecipe().getIngredients()) { + adjacentIngredientIds.add(ri.getIngredient().getId()); + } + } + } + + // Recent cooking logs (last 14 days) + List recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter( + householdId, slotDate.minusDays(14)); + Set recentlyCookedIds = recentLogs.stream() + .map(cl -> cl.getRecipe().getId()) + .collect(Collectors.toSet()); + + // Count effort levels in plan + Map effortCounts = plan.getSlots().stream() + .collect(Collectors.groupingBy(s -> s.getRecipe().getEffort(), Collectors.counting())); + + // Get all household recipes, score and pick top 5 + List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); + + List suggestions = allRecipes.stream() + .filter(r -> !usedRecipeIds.contains(r.getId())) + .map(recipe -> { + List fitReasons = new ArrayList<>(); + List warnings = new ArrayList<>(); + + if (!recentlyCookedIds.contains(recipe.getId())) { + fitReasons.add("not_cooked_recently"); + } + + boolean hasProteinRepeat = recipe.getTags().stream() + .filter(t -> "protein".equals(t.getTagType())) + .anyMatch(t -> adjacentProteins.contains(t.getName().toLowerCase())); + if (!hasProteinRepeat) { + fitReasons.add("no_protein_repeat"); + } + + String effort = recipe.getEffort(); + long currentCount = effortCounts.getOrDefault(effort, 0L); + if (currentCount < 3) { + fitReasons.add("effort_balance"); + } + + boolean sharesIngredient = recipe.getIngredients().stream() + .anyMatch(ri -> adjacentIngredientIds.contains(ri.getIngredient().getId())); + if (sharesIngredient) { + warnings.add("shares_ingredient_with_adjacent_day"); + } + + return new SuggestionResponse.SuggestionItem( + toSlotRecipe(recipe), fitReasons, warnings); + }) + .sorted((a, b) -> b.fitReasons().size() - a.fitReasons().size()) + .limit(5) + .toList(); + + return new SuggestionResponse(suggestions); + } + + @Override + @Transactional(readOnly = true) + public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) { + WeekPlan plan = findPlan(planId, householdId); + List slots = plan.getSlots(); + + if (slots.isEmpty()) { + return new VarietyScoreResponse(0, List.of(), List.of(), Map.of()); + } + + // Effort balance + Map effortBalance = new LinkedHashMap<>(); + for (WeekPlanSlot slot : slots) { + effortBalance.merge(slot.getRecipe().getEffort(), 1, Integer::sum); + } + + // Ingredient overlaps (same ingredient on consecutive days) + Map> ingredientDays = new LinkedHashMap<>(); + for (WeekPlanSlot slot : slots) { + for (RecipeIngredient ri : slot.getRecipe().getIngredients()) { + ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>()) + .add(slot.getSlotDate()); + } + } + List overlaps = ingredientDays.entrySet().stream() + .filter(e -> hasConsecutiveDays(e.getValue())) + .map(e -> new VarietyScoreResponse.IngredientOverlap(e.getKey(), e.getValue())) + .toList(); + + // Protein repeats (same protein on consecutive days) + Map> proteinDays = new LinkedHashMap<>(); + for (WeekPlanSlot slot : slots) { + for (Tag tag : slot.getRecipe().getTags()) { + if ("protein".equals(tag.getTagType())) { + proteinDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()) + .add(slot.getSlotDate()); + } + } + } + List proteinRepeats = proteinDays.entrySet().stream() + .filter(e -> hasConsecutiveDays(e.getValue())) + .map(Map.Entry::getKey) + .toList(); + + // Score: start at 10, deduct for issues + double score = 10.0; + score -= overlaps.size() * 0.5; + score -= proteinRepeats.size() * 1.0; + // Deduct for effort imbalance + int maxEffort = effortBalance.values().stream().mapToInt(Integer::intValue).max().orElse(0); + int minEffort = effortBalance.values().stream().mapToInt(Integer::intValue).min().orElse(0); + if (maxEffort - minEffort > 2) { + score -= 1.0; + } + score = Math.max(0, Math.min(10, score)); + + return new VarietyScoreResponse(score, overlaps, proteinRepeats, effortBalance); + } + + @Override + @Transactional + public CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request) { + Recipe recipe = recipeRepository.findById(request.recipeId()) + .orElseThrow(() -> new ResourceNotFoundException("Recipe not found")); + Household household = householdRepository.findById(householdId) + .orElseThrow(() -> new ResourceNotFoundException("Household not found")); + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + + LocalDate cookedOn = request.cookedOn() != null ? request.cookedOn() : LocalDate.now(); + CookingLog log = cookingLogRepository.save(new CookingLog(recipe, household, cookedOn, user)); + + return new CookingLogResponse(log.getId(), recipe.getId(), recipe.getName(), + log.getCookedOn(), user.getId()); + } + + @Override + @Transactional(readOnly = true) + public List listCookingLogs(UUID householdId, int limit, int offset) { + return cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc( + householdId, PageRequest.of(offset / Math.max(limit, 1), Math.max(limit, 1))) + .stream() + .map(cl -> new CookingLogResponse(cl.getId(), cl.getRecipe().getId(), + cl.getRecipe().getName(), cl.getCookedOn(), cl.getCookedBy().getId())) + .toList(); + } + + // ── Helpers ── + + private WeekPlan findPlan(UUID planId, UUID householdId) { + WeekPlan plan = weekPlanRepository.findById(planId) + .orElseThrow(() -> new ResourceNotFoundException("Week plan not found")); + if (!plan.getHousehold().getId().equals(householdId)) { + throw new ResourceNotFoundException("Week plan not found"); + } + return plan; + } + + private Recipe findRecipe(UUID recipeId, UUID householdId) { + return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId) + .orElseThrow(() -> new ResourceNotFoundException("Recipe not found")); + } + + private WeekPlanResponse toWeekPlanResponse(WeekPlan plan) { + List slots = plan.getSlots().stream() + .map(this::toSlotResponse) + .toList(); + return new WeekPlanResponse(plan.getId(), plan.getWeekStart(), plan.getStatus(), + plan.getConfirmedAt(), slots); + } + + private SlotResponse toSlotResponse(WeekPlanSlot slot) { + return new SlotResponse(slot.getId(), slot.getSlotDate(), toSlotRecipe(slot.getRecipe())); + } + + private SlotResponse.SlotRecipe toSlotRecipe(Recipe recipe) { + return new SlotResponse.SlotRecipe(recipe.getId(), recipe.getName(), recipe.getEffort(), + recipe.getCookTimeMin(), recipe.getHeroImageUrl()); + } + + private boolean hasConsecutiveDays(List days) { + if (days.size() < 2) return false; + List sorted = days.stream().sorted().toList(); + for (int i = 1; i < sorted.size(); i++) { + if (sorted.get(i).toEpochDay() - sorted.get(i - 1).toEpochDay() == 1) { + return true; + } + } + return false; + } +} diff --git a/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java new file mode 100644 index 0000000..305230b --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java @@ -0,0 +1,91 @@ +package com.recipeapp.planning; + +import com.recipeapp.planning.dto.*; +import com.recipeapp.recipe.HouseholdResolver; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.time.LocalDate; +import java.util.UUID; + +@RestController +@RequestMapping("/v1/week-plans") +public class WeekPlanController { + + private final PlanningService planningService; + private final HouseholdResolver householdResolver; + + public WeekPlanController(PlanningService planningService, HouseholdResolver householdResolver) { + this.planningService = planningService; + this.householdResolver = householdResolver; + } + + @GetMapping + public WeekPlanResponse getWeekPlan(Principal principal, @RequestParam LocalDate weekStart) { + UUID householdId = householdResolver.resolve(principal.getName()); + return planningService.getWeekPlan(householdId, weekStart); + } + + @PostMapping + public ResponseEntity createWeekPlan( + Principal principal, + @Valid @RequestBody CreateWeekPlanRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + WeekPlanResponse response = planningService.createWeekPlan(householdId, request.weekStart()); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/{id}/slots") + public ResponseEntity addSlot( + Principal principal, + @PathVariable UUID id, + @Valid @RequestBody CreateSlotRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + SlotResponse response = planningService.addSlot(householdId, id, request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PatchMapping("/{planId}/slots/{slotId}") + public SlotResponse updateSlot( + Principal principal, + @PathVariable UUID planId, + @PathVariable UUID slotId, + @Valid @RequestBody UpdateSlotRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + return planningService.updateSlot(householdId, planId, slotId, request); + } + + @DeleteMapping("/{planId}/slots/{slotId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteSlot( + Principal principal, + @PathVariable UUID planId, + @PathVariable UUID slotId) { + UUID householdId = householdResolver.resolve(principal.getName()); + planningService.deleteSlot(householdId, planId, slotId); + } + + @PostMapping("/{id}/confirm") + public WeekPlanResponse confirmPlan(Principal principal, @PathVariable UUID id) { + UUID householdId = householdResolver.resolve(principal.getName()); + return planningService.confirmPlan(householdId, id); + } + + @GetMapping("/{id}/suggestions") + public SuggestionResponse getSuggestions( + Principal principal, + @PathVariable UUID id, + @RequestParam LocalDate slotDate) { + UUID householdId = householdResolver.resolve(principal.getName()); + return planningService.getSuggestions(householdId, id, slotDate); + } + + @GetMapping("/{id}/variety-score") + public VarietyScoreResponse getVarietyScore(Principal principal, @PathVariable UUID id) { + UUID householdId = householdResolver.resolve(principal.getName()); + return planningService.getVarietyScore(householdId, id); + } +} diff --git a/backend/src/main/java/com/recipeapp/planning/WeekPlanRepository.java b/backend/src/main/java/com/recipeapp/planning/WeekPlanRepository.java new file mode 100644 index 0000000..75b33e8 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/WeekPlanRepository.java @@ -0,0 +1,13 @@ +package com.recipeapp.planning; + +import com.recipeapp.planning.entity.WeekPlan; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +public interface WeekPlanRepository extends JpaRepository { + Optional findByHouseholdIdAndWeekStart(UUID householdId, LocalDate weekStart); + boolean existsByHouseholdIdAndWeekStart(UUID householdId, LocalDate weekStart); +} diff --git a/backend/src/main/java/com/recipeapp/planning/WeekPlanSlotRepository.java b/backend/src/main/java/com/recipeapp/planning/WeekPlanSlotRepository.java new file mode 100644 index 0000000..130b72b --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/WeekPlanSlotRepository.java @@ -0,0 +1,9 @@ +package com.recipeapp.planning; + +import com.recipeapp.planning.entity.WeekPlanSlot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface WeekPlanSlotRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/recipeapp/planning/dto/CookingLogResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/CookingLogResponse.java new file mode 100644 index 0000000..433f2da --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/CookingLogResponse.java @@ -0,0 +1,6 @@ +package com.recipeapp.planning.dto; + +import java.time.LocalDate; +import java.util.UUID; + +public record CookingLogResponse(UUID id, UUID recipeId, String recipeName, LocalDate cookedOn, UUID cookedBy) {} diff --git a/backend/src/main/java/com/recipeapp/planning/dto/CreateCookingLogRequest.java b/backend/src/main/java/com/recipeapp/planning/dto/CreateCookingLogRequest.java new file mode 100644 index 0000000..c454eef --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/CreateCookingLogRequest.java @@ -0,0 +1,7 @@ +package com.recipeapp.planning.dto; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.UUID; + +public record CreateCookingLogRequest(@NotNull UUID recipeId, LocalDate cookedOn) {} diff --git a/backend/src/main/java/com/recipeapp/planning/dto/CreateSlotRequest.java b/backend/src/main/java/com/recipeapp/planning/dto/CreateSlotRequest.java new file mode 100644 index 0000000..d5ed594 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/CreateSlotRequest.java @@ -0,0 +1,7 @@ +package com.recipeapp.planning.dto; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.UUID; + +public record CreateSlotRequest(@NotNull LocalDate slotDate, @NotNull UUID recipeId) {} diff --git a/backend/src/main/java/com/recipeapp/planning/dto/CreateWeekPlanRequest.java b/backend/src/main/java/com/recipeapp/planning/dto/CreateWeekPlanRequest.java new file mode 100644 index 0000000..d43a9fc --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/CreateWeekPlanRequest.java @@ -0,0 +1,6 @@ +package com.recipeapp.planning.dto; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record CreateWeekPlanRequest(@NotNull LocalDate weekStart) {} diff --git a/backend/src/main/java/com/recipeapp/planning/dto/SlotResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/SlotResponse.java new file mode 100644 index 0000000..e4e4a00 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/SlotResponse.java @@ -0,0 +1,12 @@ +package com.recipeapp.planning.dto; + +import java.time.LocalDate; +import java.util.UUID; + +public record SlotResponse( + UUID id, + LocalDate slotDate, + SlotRecipe recipe +) { + public record SlotRecipe(UUID id, String name, String effort, short cookTimeMin, String heroImageUrl) {} +} diff --git a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java new file mode 100644 index 0000000..660f20a --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java @@ -0,0 +1,12 @@ +package com.recipeapp.planning.dto; + +import java.util.List; + +public record SuggestionResponse(List suggestions) { + + public record SuggestionItem( + SlotResponse.SlotRecipe recipe, + List fitReasons, + List warnings + ) {} +} diff --git a/backend/src/main/java/com/recipeapp/planning/dto/UpdateSlotRequest.java b/backend/src/main/java/com/recipeapp/planning/dto/UpdateSlotRequest.java new file mode 100644 index 0000000..6acc1c2 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/UpdateSlotRequest.java @@ -0,0 +1,6 @@ +package com.recipeapp.planning.dto; + +import jakarta.validation.constraints.NotNull; +import java.util.UUID; + +public record UpdateSlotRequest(@NotNull UUID recipeId) {} diff --git a/backend/src/main/java/com/recipeapp/planning/dto/VarietyScoreResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/VarietyScoreResponse.java new file mode 100644 index 0000000..569d88f --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/VarietyScoreResponse.java @@ -0,0 +1,14 @@ +package com.recipeapp.planning.dto; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public record VarietyScoreResponse( + double score, + List ingredientOverlaps, + List proteinRepeats, + Map effortBalance +) { + public record IngredientOverlap(String ingredientName, List days) {} +} diff --git a/backend/src/main/java/com/recipeapp/planning/dto/WeekPlanResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/WeekPlanResponse.java new file mode 100644 index 0000000..e98b5ce --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/WeekPlanResponse.java @@ -0,0 +1,14 @@ +package com.recipeapp.planning.dto; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public record WeekPlanResponse( + UUID id, + LocalDate weekStart, + String status, + Instant confirmedAt, + List slots +) {} diff --git a/backend/src/main/java/com/recipeapp/planning/entity/CookingLog.java b/backend/src/main/java/com/recipeapp/planning/entity/CookingLog.java new file mode 100644 index 0000000..8517a6c --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/entity/CookingLog.java @@ -0,0 +1,47 @@ +package com.recipeapp.planning.entity; + +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.household.entity.Household; +import com.recipeapp.recipe.entity.Recipe; +import jakarta.persistence.*; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "cooking_log") +public class CookingLog { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipe_id", nullable = false) + private Recipe recipe; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "household_id", nullable = false) + private Household household; + + @Column(name = "cooked_on", nullable = false) + private LocalDate cookedOn; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cooked_by", nullable = false) + private UserAccount cookedBy; + + protected CookingLog() {} + + public CookingLog(Recipe recipe, Household household, LocalDate cookedOn, UserAccount cookedBy) { + this.recipe = recipe; + this.household = household; + this.cookedOn = cookedOn; + this.cookedBy = cookedBy; + } + + public UUID getId() { return id; } + public Recipe getRecipe() { return recipe; } + public Household getHousehold() { return household; } + public LocalDate getCookedOn() { return cookedOn; } + public UserAccount getCookedBy() { return cookedBy; } +} diff --git a/backend/src/main/java/com/recipeapp/planning/entity/WeekPlan.java b/backend/src/main/java/com/recipeapp/planning/entity/WeekPlan.java new file mode 100644 index 0000000..dd753d7 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/entity/WeekPlan.java @@ -0,0 +1,50 @@ +package com.recipeapp.planning.entity; + +import com.recipeapp.household.entity.Household; +import jakarta.persistence.*; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "week_plan") +public class WeekPlan { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "household_id", nullable = false) + private Household household; + + @Column(name = "week_start", nullable = false) + private LocalDate weekStart; + + @Column(nullable = false, length = 10) + private String status = "draft"; + + @Column(name = "confirmed_at") + private Instant confirmedAt; + + @OneToMany(mappedBy = "weekPlan", cascade = CascadeType.ALL, orphanRemoval = true) + private List slots = new ArrayList<>(); + + protected WeekPlan() {} + + public WeekPlan(Household household, LocalDate weekStart) { + this.household = household; + this.weekStart = weekStart; + } + + public UUID getId() { return id; } + public Household getHousehold() { return household; } + public LocalDate getWeekStart() { return weekStart; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Instant getConfirmedAt() { return confirmedAt; } + public void setConfirmedAt(Instant confirmedAt) { this.confirmedAt = confirmedAt; } + public List getSlots() { return slots; } +} diff --git a/backend/src/main/java/com/recipeapp/planning/entity/WeekPlanSlot.java b/backend/src/main/java/com/recipeapp/planning/entity/WeekPlanSlot.java new file mode 100644 index 0000000..39983a7 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/entity/WeekPlanSlot.java @@ -0,0 +1,40 @@ +package com.recipeapp.planning.entity; + +import com.recipeapp.recipe.entity.Recipe; +import jakarta.persistence.*; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "week_plan_slot") +public class WeekPlanSlot { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "week_plan_id", nullable = false) + private WeekPlan weekPlan; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipe_id", nullable = false) + private Recipe recipe; + + @Column(name = "slot_date", nullable = false) + private LocalDate slotDate; + + protected WeekPlanSlot() {} + + public WeekPlanSlot(WeekPlan weekPlan, Recipe recipe, LocalDate slotDate) { + this.weekPlan = weekPlan; + this.recipe = recipe; + this.slotDate = slotDate; + } + + public UUID getId() { return id; } + public WeekPlan getWeekPlan() { return weekPlan; } + public Recipe getRecipe() { return recipe; } + public void setRecipe(Recipe recipe) { this.recipe = recipe; } + public LocalDate getSlotDate() { return slotDate; } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java b/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java new file mode 100644 index 0000000..54d0249 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java @@ -0,0 +1,31 @@ +package com.recipeapp.recipe; + +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.household.HouseholdMemberRepository; +import com.recipeapp.household.entity.HouseholdMember; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class HouseholdResolver { + + private final HouseholdMemberRepository householdMemberRepository; + + public HouseholdResolver(HouseholdMemberRepository householdMemberRepository) { + this.householdMemberRepository = householdMemberRepository; + } + + public UUID resolve(String userEmail) { + return findMembership(userEmail).getHousehold().getId(); + } + + public UUID resolveUserId(String userEmail) { + return findMembership(userEmail).getUser().getId(); + } + + private HouseholdMember findMembership(String userEmail) { + return householdMemberRepository.findByUserEmailIgnoreCase(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User is not in a household")); + } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryController.java b/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryController.java new file mode 100644 index 0000000..f90da1a --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryController.java @@ -0,0 +1,39 @@ +package com.recipeapp.recipe; + +import com.recipeapp.recipe.dto.*; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/v1/ingredient-categories") +public class IngredientCategoryController { + + private final RecipeService recipeService; + private final HouseholdResolver householdResolver; + + public IngredientCategoryController(RecipeService recipeService, HouseholdResolver householdResolver) { + this.recipeService = recipeService; + this.householdResolver = householdResolver; + } + + @GetMapping + public List listCategories(Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + return recipeService.listCategories(householdId); + } + + @PostMapping + public ResponseEntity createCategory( + Principal principal, + @Valid @RequestBody IngredientCategoryCreateRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + IngredientCategoryResponse response = recipeService.createCategory(householdId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryRepository.java b/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryRepository.java index a0e989a..b8003d4 100644 --- a/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryRepository.java +++ b/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryRepository.java @@ -8,4 +8,6 @@ import java.util.UUID; public interface IngredientCategoryRepository extends JpaRepository { List findByHouseholdIdOrderBySortOrder(UUID householdId); + boolean existsByHouseholdIdAndNameIgnoreCase(UUID householdId, String name); + long countByHouseholdId(UUID householdId); } diff --git a/backend/src/main/java/com/recipeapp/recipe/IngredientController.java b/backend/src/main/java/com/recipeapp/recipe/IngredientController.java new file mode 100644 index 0000000..4f62fd0 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/IngredientController.java @@ -0,0 +1,41 @@ +package com.recipeapp.recipe; + +import com.recipeapp.recipe.dto.*; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/v1/ingredients") +public class IngredientController { + + private final RecipeService recipeService; + private final HouseholdResolver householdResolver; + + public IngredientController(RecipeService recipeService, HouseholdResolver householdResolver) { + this.recipeService = recipeService; + this.householdResolver = householdResolver; + } + + @GetMapping + public List searchIngredients( + Principal principal, + @RequestParam(required = false) String search, + @RequestParam(required = false) Boolean isStaple) { + UUID householdId = householdResolver.resolve(principal.getName()); + return recipeService.searchIngredients(householdId, search, isStaple); + } + + @PatchMapping("/{id}") + public IngredientResponse patchIngredient( + Principal principal, + @PathVariable UUID id, + @Valid @RequestBody IngredientPatchRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + return recipeService.patchIngredient(householdId, id, request); + } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/IngredientRepository.java b/backend/src/main/java/com/recipeapp/recipe/IngredientRepository.java index dab72d8..db296c7 100644 --- a/backend/src/main/java/com/recipeapp/recipe/IngredientRepository.java +++ b/backend/src/main/java/com/recipeapp/recipe/IngredientRepository.java @@ -8,4 +8,7 @@ import java.util.UUID; public interface IngredientRepository extends JpaRepository { List findByHouseholdId(UUID householdId); + List findByHouseholdIdAndNameContainingIgnoreCase(UUID householdId, String name); + List findByHouseholdIdAndIsStaple(UUID householdId, boolean isStaple); + List findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple(UUID householdId, String name, boolean isStaple); } diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeController.java b/backend/src/main/java/com/recipeapp/recipe/RecipeController.java new file mode 100644 index 0000000..445cabe --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeController.java @@ -0,0 +1,79 @@ +package com.recipeapp.recipe; + +import com.recipeapp.common.ApiResponse; +import com.recipeapp.recipe.dto.*; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.security.Principal; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/v1/recipes") +public class RecipeController { + + private final RecipeService recipeService; + private final HouseholdResolver householdResolver; + + public RecipeController(RecipeService recipeService, HouseholdResolver householdResolver) { + this.recipeService = recipeService; + this.householdResolver = householdResolver; + } + + @GetMapping + public ResponseEntity>> listRecipes( + Principal principal, + @RequestParam(required = false) String search, + @RequestParam(required = false) String effort, + @RequestParam(required = false) Boolean isChildFriendly, + @RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin, + @RequestParam(required = false) String sort, + @RequestParam(defaultValue = "20") int limit, + @RequestParam(defaultValue = "0") int offset) { + + UUID householdId = householdResolver.resolve(principal.getName()); + List recipes = recipeService.listRecipes( + householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset); + long total = recipeService.countRecipes( + householdId, search, effort, isChildFriendly, cookTimeMaxMin); + + var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total); + var meta = new ApiResponse.Meta(pagination); + return ResponseEntity.ok(ApiResponse.success(recipes, meta)); + } + + @GetMapping("/{id}") + public RecipeDetailResponse getRecipe(Principal principal, @PathVariable UUID id) { + UUID householdId = householdResolver.resolve(principal.getName()); + return recipeService.getRecipe(householdId, id); + } + + @PostMapping + public ResponseEntity createRecipe( + Principal principal, + @Valid @RequestBody RecipeCreateRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + RecipeDetailResponse detail = recipeService.createRecipe(householdId, request); + return ResponseEntity.created(URI.create("/v1/recipes/" + detail.id())).body(detail); + } + + @PutMapping("/{id}") + public RecipeDetailResponse updateRecipe( + Principal principal, + @PathVariable UUID id, + @Valid @RequestBody RecipeCreateRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + return recipeService.updateRecipe(householdId, id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteRecipe(Principal principal, @PathVariable UUID id) { + UUID householdId = householdResolver.resolve(principal.getName()); + recipeService.deleteRecipe(householdId, id); + } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java b/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java new file mode 100644 index 0000000..e8a858b --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java @@ -0,0 +1,57 @@ +package com.recipeapp.recipe; + +import com.recipeapp.recipe.dto.RecipeSummaryResponse; +import com.recipeapp.recipe.entity.Recipe; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface RecipeRepository extends JpaRepository { + + Optional findByIdAndHouseholdIdAndDeletedAtIsNull(UUID id, UUID householdId); + + List findByHouseholdIdAndDeletedAtIsNull(UUID householdId); + + @Query(""" + SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse( + r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl) + FROM Recipe r + WHERE r.household.id = :householdId + AND r.deletedAt IS NULL + AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%'))) + AND (:effort IS NULL OR r.effort = :effort) + AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly) + AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin) + ORDER BY r.createdAt DESC + """) + List findFiltered( + @Param("householdId") UUID householdId, + @Param("search") String search, + @Param("effort") String effort, + @Param("isChildFriendly") Boolean isChildFriendly, + @Param("cookTimeMaxMin") Integer cookTimeMaxMin, + @Param("sort") String sort, + @Param("limit") int limit, + @Param("offset") int offset); + + @Query(""" + SELECT COUNT(r) + FROM Recipe r + WHERE r.household.id = :householdId + AND r.deletedAt IS NULL + AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%'))) + AND (:effort IS NULL OR r.effort = :effort) + AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly) + AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin) + """) + long countFiltered( + @Param("householdId") UUID householdId, + @Param("search") String search, + @Param("effort") String effort, + @Param("isChildFriendly") Boolean isChildFriendly, + @Param("cookTimeMaxMin") Integer cookTimeMaxMin); +} diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java new file mode 100644 index 0000000..0123eb9 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java @@ -0,0 +1,35 @@ +package com.recipeapp.recipe; + +import com.recipeapp.recipe.dto.*; +import java.util.List; +import java.util.UUID; + +public interface RecipeService { + + List listRecipes(UUID householdId, String search, String effort, + Boolean isChildFriendly, Integer cookTimeMaxMin, + String sort, int limit, int offset); + + long countRecipes(UUID householdId, String search, String effort, + Boolean isChildFriendly, Integer cookTimeMaxMin); + + RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId); + + RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request); + + RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request); + + void deleteRecipe(UUID householdId, UUID recipeId); + + List searchIngredients(UUID householdId, String search, Boolean isStaple); + + IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request); + + List listTags(UUID householdId); + + TagResponse createTag(UUID householdId, TagCreateRequest request); + + List listCategories(UUID householdId); + + IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request); +} diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeServiceImpl.java b/backend/src/main/java/com/recipeapp/recipe/RecipeServiceImpl.java new file mode 100644 index 0000000..00513ce --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeServiceImpl.java @@ -0,0 +1,263 @@ +package com.recipeapp.recipe; + +import com.recipeapp.common.ConflictException; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.recipe.dto.*; +import com.recipeapp.recipe.entity.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class RecipeServiceImpl implements RecipeService { + + private final RecipeRepository recipeRepository; + private final IngredientRepository ingredientRepository; + private final TagRepository tagRepository; + private final IngredientCategoryRepository ingredientCategoryRepository; + private final HouseholdRepository householdRepository; + + public RecipeServiceImpl(RecipeRepository recipeRepository, + IngredientRepository ingredientRepository, + TagRepository tagRepository, + IngredientCategoryRepository ingredientCategoryRepository, + HouseholdRepository householdRepository) { + this.recipeRepository = recipeRepository; + this.ingredientRepository = ingredientRepository; + this.tagRepository = tagRepository; + this.ingredientCategoryRepository = ingredientCategoryRepository; + this.householdRepository = householdRepository; + } + + @Override + @Transactional(readOnly = true) + public List listRecipes(UUID householdId, String search, String effort, + Boolean isChildFriendly, Integer cookTimeMaxMin, + String sort, int limit, int offset) { + return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly, + cookTimeMaxMin, sort, limit, offset); + } + + @Override + @Transactional(readOnly = true) + public long countRecipes(UUID householdId, String search, String effort, + Boolean isChildFriendly, Integer cookTimeMaxMin) { + return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin); + } + + @Override + @Transactional(readOnly = true) + public RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId) { + Recipe recipe = findRecipe(householdId, recipeId); + return toDetailResponse(recipe); + } + + @Override + @Transactional + public RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request) { + Household household = householdRepository.findById(householdId) + .orElseThrow(() -> new ResourceNotFoundException("Household not found")); + + Recipe recipe = new Recipe(household, request.name(), request.serves(), + request.cookTimeMin(), request.effort(), request.isChildFriendly()); + recipe.setHeroImageUrl(request.heroImageUrl()); + + addIngredients(recipe, household, request.ingredients()); + addSteps(recipe, request.steps()); + addTags(recipe, request.tagIds()); + + recipe = recipeRepository.save(recipe); + return toDetailResponse(recipe); + } + + @Override + @Transactional + public RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request) { + Recipe recipe = findRecipe(householdId, recipeId); + Household household = recipe.getHousehold(); + + recipe.setName(request.name()); + recipe.setServes(request.serves()); + recipe.setCookTimeMin(request.cookTimeMin()); + recipe.setEffort(request.effort()); + recipe.setChildFriendly(request.isChildFriendly()); + recipe.setHeroImageUrl(request.heroImageUrl()); + + recipe.getIngredients().clear(); + recipe.getSteps().clear(); + + addIngredients(recipe, household, request.ingredients()); + addSteps(recipe, request.steps()); + addTags(recipe, request.tagIds()); + + recipe = recipeRepository.save(recipe); + return toDetailResponse(recipe); + } + + @Override + @Transactional + public void deleteRecipe(UUID householdId, UUID recipeId) { + Recipe recipe = findRecipe(householdId, recipeId); + recipe.setDeletedAt(Instant.now()); + } + + // ── Ingredients ── + + @Override + @Transactional(readOnly = true) + public List searchIngredients(UUID householdId, String search, Boolean isStaple) { + List ingredients; + if (search != null && isStaple != null) { + ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple( + householdId, search, isStaple); + } else if (search != null) { + ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(householdId, search); + } else if (isStaple != null) { + ingredients = ingredientRepository.findByHouseholdIdAndIsStaple(householdId, isStaple); + } else { + ingredients = ingredientRepository.findByHouseholdId(householdId); + } + return ingredients.stream().map(this::toIngredientResponse).toList(); + } + + @Override + @Transactional + public IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request) { + Ingredient ingredient = ingredientRepository.findById(ingredientId) + .orElseThrow(() -> new ResourceNotFoundException("Ingredient not found")); + + if (request.name() != null) { + ingredient.setName(request.name()); + } + if (request.isStaple() != null) { + ingredient.setStaple(request.isStaple()); + } + if (request.categoryId() != null) { + IngredientCategory category = ingredientCategoryRepository.findById(request.categoryId()) + .orElseThrow(() -> new ResourceNotFoundException("Category not found")); + ingredient.setCategory(category); + } + return toIngredientResponse(ingredient); + } + + // ── Tags ── + + @Override + @Transactional(readOnly = true) + public List listTags(UUID householdId) { + return tagRepository.findByHouseholdId(householdId).stream() + .map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType())) + .toList(); + } + + @Override + @Transactional + public TagResponse createTag(UUID householdId, TagCreateRequest request) { + if (tagRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) { + throw new ConflictException("Tag already exists"); + } + Household household = householdRepository.findById(householdId) + .orElseThrow(() -> new ResourceNotFoundException("Household not found")); + Tag tag = tagRepository.save(new Tag(household, request.name(), request.tagType())); + return new TagResponse(tag.getId(), tag.getName(), tag.getTagType()); + } + + // ── Ingredient Categories ── + + @Override + @Transactional(readOnly = true) + public List listCategories(UUID householdId) { + return ingredientCategoryRepository.findByHouseholdIdOrderBySortOrder(householdId).stream() + .map(c -> new IngredientCategoryResponse(c.getId(), c.getName())) + .toList(); + } + + @Override + @Transactional + public IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request) { + if (ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) { + throw new ConflictException("Category already exists"); + } + Household household = householdRepository.findById(householdId) + .orElseThrow(() -> new ResourceNotFoundException("Household not found")); + short nextSort = (short) (ingredientCategoryRepository.countByHouseholdId(householdId) + 1); + IngredientCategory category = ingredientCategoryRepository.save( + new IngredientCategory(household, request.name(), nextSort)); + return new IngredientCategoryResponse(category.getId(), category.getName()); + } + + // ── Private helpers ── + + private Recipe findRecipe(UUID householdId, UUID recipeId) { + return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId) + .orElseThrow(() -> new ResourceNotFoundException("Recipe not found")); + } + + private void addIngredients(Recipe recipe, Household household, List entries) { + if (entries == null) return; + for (var entry : entries) { + Ingredient ingredient; + if (entry.ingredientId() != null) { + ingredient = ingredientRepository.findById(entry.ingredientId()) + .orElseThrow(() -> new ResourceNotFoundException("Ingredient not found")); + } else { + ingredient = ingredientRepository.save(new Ingredient(household, entry.newIngredientName(), false)); + } + recipe.getIngredients().add(new RecipeIngredient( + recipe, ingredient, entry.quantity(), entry.unit(), entry.sortOrder())); + } + } + + private void addSteps(Recipe recipe, List entries) { + if (entries == null) return; + for (var entry : entries) { + recipe.getSteps().add(new RecipeStep(recipe, entry.stepNumber(), entry.instruction())); + } + } + + private void addTags(Recipe recipe, List tagIds) { + if (tagIds == null || tagIds.isEmpty()) return; + List tags = tagRepository.findAllById(tagIds); + recipe.setTags(new HashSet<>(tags)); + } + + private RecipeDetailResponse toDetailResponse(Recipe recipe) { + var ingredients = recipe.getIngredients().stream() + .map(ri -> { + Ingredient ing = ri.getIngredient(); + RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null + ? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName()) + : null; + return new RecipeDetailResponse.IngredientItem( + ing.getId(), ing.getName(), catRef, + ri.getQuantity(), ri.getUnit(), ri.getSortOrder()); + }) + .toList(); + + var steps = recipe.getSteps().stream() + .map(s -> new RecipeDetailResponse.StepItem(s.getStepNumber(), s.getInstruction())) + .toList(); + + var tags = recipe.getTags().stream() + .map(t -> new RecipeDetailResponse.TagItem(t.getId(), t.getName(), t.getTagType())) + .toList(); + + return new RecipeDetailResponse( + recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(), + recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(), + ingredients, steps, tags); + } + + private IngredientResponse toIngredientResponse(Ingredient ing) { + RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null + ? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName()) + : null; + return new IngredientResponse(ing.getId(), ing.getName(), catRef, ing.isStaple()); + } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/TagController.java b/backend/src/main/java/com/recipeapp/recipe/TagController.java new file mode 100644 index 0000000..7b66766 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/TagController.java @@ -0,0 +1,39 @@ +package com.recipeapp.recipe; + +import com.recipeapp.recipe.dto.*; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/v1/tags") +public class TagController { + + private final RecipeService recipeService; + private final HouseholdResolver householdResolver; + + public TagController(RecipeService recipeService, HouseholdResolver householdResolver) { + this.recipeService = recipeService; + this.householdResolver = householdResolver; + } + + @GetMapping + public List listTags(Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + return recipeService.listTags(householdId); + } + + @PostMapping + public ResponseEntity createTag( + Principal principal, + @Valid @RequestBody TagCreateRequest request) { + UUID householdId = householdResolver.resolve(principal.getName()); + TagResponse response = recipeService.createTag(householdId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/TagRepository.java b/backend/src/main/java/com/recipeapp/recipe/TagRepository.java index 47a9216..098f37f 100644 --- a/backend/src/main/java/com/recipeapp/recipe/TagRepository.java +++ b/backend/src/main/java/com/recipeapp/recipe/TagRepository.java @@ -8,4 +8,5 @@ import java.util.UUID; public interface TagRepository extends JpaRepository { List findByHouseholdId(UUID householdId); + boolean existsByHouseholdIdAndNameIgnoreCase(UUID householdId, String name); } diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/IngredientCategoryCreateRequest.java b/backend/src/main/java/com/recipeapp/recipe/dto/IngredientCategoryCreateRequest.java new file mode 100644 index 0000000..4172491 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/dto/IngredientCategoryCreateRequest.java @@ -0,0 +1,8 @@ +package com.recipeapp.recipe.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record IngredientCategoryCreateRequest( + @NotBlank @Size(max = 50) String name +) {} diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/IngredientCategoryResponse.java b/backend/src/main/java/com/recipeapp/recipe/dto/IngredientCategoryResponse.java new file mode 100644 index 0000000..b6dc52d --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/dto/IngredientCategoryResponse.java @@ -0,0 +1,5 @@ +package com.recipeapp.recipe.dto; + +import java.util.UUID; + +public record IngredientCategoryResponse(UUID id, String name) {} diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/IngredientPatchRequest.java b/backend/src/main/java/com/recipeapp/recipe/dto/IngredientPatchRequest.java new file mode 100644 index 0000000..9f7beac --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/dto/IngredientPatchRequest.java @@ -0,0 +1,9 @@ +package com.recipeapp.recipe.dto; + +import java.util.UUID; + +public record IngredientPatchRequest( + String name, + Boolean isStaple, + UUID categoryId +) {} diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/IngredientResponse.java b/backend/src/main/java/com/recipeapp/recipe/dto/IngredientResponse.java new file mode 100644 index 0000000..58aa1ac --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/dto/IngredientResponse.java @@ -0,0 +1,10 @@ +package com.recipeapp.recipe.dto; + +import java.util.UUID; + +public record IngredientResponse( + UUID id, + String name, + RecipeDetailResponse.CategoryRef category, + boolean isStaple +) {} diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java new file mode 100644 index 0000000..cba85c0 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java @@ -0,0 +1,32 @@ +package com.recipeapp.recipe.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +public record RecipeCreateRequest( + @NotBlank @Size(max = 200) String name, + @Min(1) @Max(20) short serves, + @Min(0) short cookTimeMin, + @NotBlank @Pattern(regexp = "easy|medium|hard") String effort, + boolean isChildFriendly, + @Size(max = 500) String heroImageUrl, + @NotEmpty @Valid List ingredients, + @Valid List steps, + @NotEmpty List tagIds +) { + public record IngredientEntry( + UUID ingredientId, + @Size(max = 200) String newIngredientName, + @NotNull @DecimalMin("0.01") BigDecimal quantity, + @NotBlank @Size(max = 20) String unit, + short sortOrder + ) {} + + public record StepEntry( + @Min(1) short stepNumber, + @NotBlank String instruction + ) {} +} diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeDetailResponse.java b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeDetailResponse.java new file mode 100644 index 0000000..68bbc48 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeDetailResponse.java @@ -0,0 +1,33 @@ +package com.recipeapp.recipe.dto; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +public record RecipeDetailResponse( + UUID id, + String name, + short serves, + short cookTimeMin, + String effort, + boolean isChildFriendly, + String heroImageUrl, + List ingredients, + List steps, + List tags +) { + public record IngredientItem( + UUID ingredientId, + String name, + CategoryRef category, + BigDecimal quantity, + String unit, + short sortOrder + ) {} + + public record CategoryRef(UUID id, String name) {} + + public record StepItem(short stepNumber, String instruction) {} + + public record TagItem(UUID id, String name, String tagType) {} +} diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java new file mode 100644 index 0000000..93ca6a7 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java @@ -0,0 +1,13 @@ +package com.recipeapp.recipe.dto; + +import java.util.UUID; + +public record RecipeSummaryResponse( + UUID id, + String name, + short serves, + short cookTimeMin, + String effort, + boolean isChildFriendly, + String heroImageUrl +) {} diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/TagCreateRequest.java b/backend/src/main/java/com/recipeapp/recipe/dto/TagCreateRequest.java new file mode 100644 index 0000000..a372c42 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/dto/TagCreateRequest.java @@ -0,0 +1,10 @@ +package com.recipeapp.recipe.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record TagCreateRequest( + @NotBlank @Size(max = 50) String name, + @NotBlank @Pattern(regexp = "protein|dietary|cuisine|other") String tagType +) {} diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/TagResponse.java b/backend/src/main/java/com/recipeapp/recipe/dto/TagResponse.java new file mode 100644 index 0000000..3440745 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/dto/TagResponse.java @@ -0,0 +1,5 @@ +package com.recipeapp.recipe.dto; + +import java.util.UUID; + +public record TagResponse(UUID id, String name, String tagType) {} diff --git a/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java b/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java new file mode 100644 index 0000000..f15c1e5 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java @@ -0,0 +1,112 @@ +package com.recipeapp.recipe.entity; + +import com.recipeapp.household.entity.Household; +import jakarta.persistence.*; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Entity +@Table(name = "recipe") +public class Recipe { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "household_id", nullable = false) + private Household household; + + @Column(nullable = false, length = 200) + private String name; + + @Column(nullable = false) + private short serves; + + @Column(name = "cook_time_min", nullable = false) + private short cookTimeMin; + + @Column(nullable = false, length = 10) + private String effort; + + @Column(name = "is_child_friendly", nullable = false) + private boolean isChildFriendly; + + @Column(name = "hero_image_url", length = 500) + private String heroImageUrl; + + @Column(name = "deleted_at") + private Instant deletedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder") + private List ingredients = new ArrayList<>(); + + @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("stepNumber") + private List steps = new ArrayList<>(); + + @ManyToMany + @JoinTable(name = "recipe_tag", + joinColumns = @JoinColumn(name = "recipe_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id")) + private Set tags = new HashSet<>(); + + protected Recipe() {} + + public Recipe(Household household, String name, short serves, short cookTimeMin, + String effort, boolean isChildFriendly) { + this.household = household; + this.name = name; + this.serves = serves; + this.cookTimeMin = cookTimeMin; + this.effort = effort; + this.isChildFriendly = isChildFriendly; + } + + @PrePersist + void onCreate() { + createdAt = Instant.now(); + updatedAt = Instant.now(); + } + + @PreUpdate + void onUpdate() { + updatedAt = Instant.now(); + } + + public UUID getId() { return id; } + public Household getHousehold() { return household; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public short getServes() { return serves; } + public void setServes(short serves) { this.serves = serves; } + public short getCookTimeMin() { return cookTimeMin; } + public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; } + public String getEffort() { return effort; } + public void setEffort(String effort) { this.effort = effort; } + public boolean isChildFriendly() { return isChildFriendly; } + public void setChildFriendly(boolean childFriendly) { isChildFriendly = childFriendly; } + public String getHeroImageUrl() { return heroImageUrl; } + public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; } + public Instant getDeletedAt() { return deletedAt; } + public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + public List getIngredients() { return ingredients; } + public List getSteps() { return steps; } + public Set getTags() { return tags; } + public void setTags(Set tags) { this.tags = tags; } + + public boolean isDeleted() { return deletedAt != null; } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/entity/RecipeIngredient.java b/backend/src/main/java/com/recipeapp/recipe/entity/RecipeIngredient.java new file mode 100644 index 0000000..1a17f57 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/entity/RecipeIngredient.java @@ -0,0 +1,52 @@ +package com.recipeapp.recipe.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.util.UUID; + +@Entity +@Table(name = "recipe_ingredient") +public class RecipeIngredient { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipe_id", nullable = false) + private Recipe recipe; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ingredient_id", nullable = false) + private Ingredient ingredient; + + @Column(nullable = false, precision = 8, scale = 2) + private BigDecimal quantity; + + @Column(nullable = false, length = 20) + private String unit; + + @Column(name = "sort_order", nullable = false) + private short sortOrder; + + protected RecipeIngredient() {} + + public RecipeIngredient(Recipe recipe, Ingredient ingredient, BigDecimal quantity, + String unit, short sortOrder) { + this.recipe = recipe; + this.ingredient = ingredient; + this.quantity = quantity; + this.unit = unit; + this.sortOrder = sortOrder; + } + + public UUID getId() { return id; } + public Recipe getRecipe() { return recipe; } + public Ingredient getIngredient() { return ingredient; } + public BigDecimal getQuantity() { return quantity; } + public void setQuantity(BigDecimal quantity) { this.quantity = quantity; } + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + public short getSortOrder() { return sortOrder; } + public void setSortOrder(short sortOrder) { this.sortOrder = sortOrder; } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/entity/RecipeStep.java b/backend/src/main/java/com/recipeapp/recipe/entity/RecipeStep.java new file mode 100644 index 0000000..08f8148 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/entity/RecipeStep.java @@ -0,0 +1,38 @@ +package com.recipeapp.recipe.entity; + +import jakarta.persistence.*; +import java.util.UUID; + +@Entity +@Table(name = "recipe_step") +public class RecipeStep { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipe_id", nullable = false) + private Recipe recipe; + + @Column(name = "step_number", nullable = false) + private short stepNumber; + + @Column(nullable = false, columnDefinition = "text") + private String instruction; + + protected RecipeStep() {} + + public RecipeStep(Recipe recipe, short stepNumber, String instruction) { + this.recipe = recipe; + this.stepNumber = stepNumber; + this.instruction = instruction; + } + + public UUID getId() { return id; } + public Recipe getRecipe() { return recipe; } + public short getStepNumber() { return stepNumber; } + public void setStepNumber(short stepNumber) { this.stepNumber = stepNumber; } + public String getInstruction() { return instruction; } + public void setInstruction(String instruction) { this.instruction = instruction; } +} diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java new file mode 100644 index 0000000..ca25fa4 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingListController.java @@ -0,0 +1,68 @@ +package com.recipeapp.shopping; + +import com.recipeapp.recipe.HouseholdResolver; +import com.recipeapp.shopping.dto.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.UUID; + +@RestController +public class ShoppingListController { + + private final ShoppingService shoppingService; + private final HouseholdResolver householdResolver; + + public ShoppingListController(ShoppingService shoppingService, HouseholdResolver householdResolver) { + this.shoppingService = shoppingService; + this.householdResolver = householdResolver; + } + + @PostMapping("/v1/week-plans/{id}/shopping-list") + @ResponseStatus(HttpStatus.CREATED) + public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + return shoppingService.generateFromPlan(householdId, id); + } + + @GetMapping("/v1/shopping-lists/{id}") + public ShoppingListResponse getShoppingList(@PathVariable UUID id, Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + return shoppingService.getShoppingList(householdId, id); + } + + @PostMapping("/v1/shopping-lists/{id}/publish") + public PublishResponse publish(@PathVariable UUID id, Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + return shoppingService.publish(householdId, id); + } + + @PatchMapping("/v1/shopping-lists/{listId}/items/{itemId}") + public ShoppingListItemResponse checkItem(@PathVariable UUID listId, + @PathVariable UUID itemId, + @RequestBody CheckItemRequest request, + Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + UUID userId = householdResolver.resolveUserId(principal.getName()); + return shoppingService.checkItem(householdId, listId, itemId, request, userId); + } + + @PostMapping("/v1/shopping-lists/{id}/items") + @ResponseStatus(HttpStatus.CREATED) + public ShoppingListItemResponse addItem(@PathVariable UUID id, + @RequestBody AddItemRequest request, + Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + return shoppingService.addItem(householdId, id, request); + } + + @DeleteMapping("/v1/shopping-lists/{listId}/items/{itemId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteItem(@PathVariable UUID listId, + @PathVariable UUID itemId, + Principal principal) { + UUID householdId = householdResolver.resolve(principal.getName()); + shoppingService.deleteItem(householdId, listId, itemId); + } +} diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingListItemRepository.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingListItemRepository.java new file mode 100644 index 0000000..0c29e28 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingListItemRepository.java @@ -0,0 +1,9 @@ +package com.recipeapp.shopping; + +import com.recipeapp.shopping.entity.ShoppingListItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface ShoppingListItemRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingListRepository.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingListRepository.java new file mode 100644 index 0000000..bf2382c --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingListRepository.java @@ -0,0 +1,9 @@ +package com.recipeapp.shopping; + +import com.recipeapp.shopping.entity.ShoppingList; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface ShoppingListRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java new file mode 100644 index 0000000..da6aa58 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java @@ -0,0 +1,21 @@ +package com.recipeapp.shopping; + +import com.recipeapp.shopping.dto.*; + +import java.util.UUID; + +public interface ShoppingService { + + ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId); + + ShoppingListResponse getShoppingList(UUID householdId, UUID shoppingListId); + + PublishResponse publish(UUID householdId, UUID shoppingListId); + + ShoppingListItemResponse checkItem(UUID householdId, UUID listId, UUID itemId, + CheckItemRequest request, UUID userId); + + ShoppingListItemResponse addItem(UUID householdId, UUID shoppingListId, AddItemRequest request); + + void deleteItem(UUID householdId, UUID listId, UUID itemId); +} diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingServiceImpl.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingServiceImpl.java new file mode 100644 index 0000000..ea8d541 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingServiceImpl.java @@ -0,0 +1,270 @@ +package com.recipeapp.shopping; + +import com.recipeapp.auth.UserAccountRepository; +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ValidationException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.planning.WeekPlanRepository; +import com.recipeapp.planning.entity.WeekPlan; +import com.recipeapp.recipe.IngredientRepository; +import com.recipeapp.recipe.entity.Ingredient; +import com.recipeapp.recipe.entity.RecipeIngredient; +import com.recipeapp.shopping.dto.*; +import com.recipeapp.shopping.entity.ShoppingList; +import com.recipeapp.shopping.entity.ShoppingListItem; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional +public class ShoppingServiceImpl implements ShoppingService { + + private final ShoppingListRepository shoppingListRepository; + private final ShoppingListItemRepository shoppingListItemRepository; + private final WeekPlanRepository weekPlanRepository; + private final HouseholdRepository householdRepository; + private final IngredientRepository ingredientRepository; + private final UserAccountRepository userAccountRepository; + + public ShoppingServiceImpl(ShoppingListRepository shoppingListRepository, + ShoppingListItemRepository shoppingListItemRepository, + WeekPlanRepository weekPlanRepository, + HouseholdRepository householdRepository, + IngredientRepository ingredientRepository, + UserAccountRepository userAccountRepository) { + this.shoppingListRepository = shoppingListRepository; + this.shoppingListItemRepository = shoppingListItemRepository; + this.weekPlanRepository = weekPlanRepository; + this.householdRepository = householdRepository; + this.ingredientRepository = ingredientRepository; + this.userAccountRepository = userAccountRepository; + } + + @Override + public ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId) { + WeekPlan weekPlan = weekPlanRepository.findById(weekPlanId) + .orElseThrow(() -> new ResourceNotFoundException("Week plan not found")); + + if (!weekPlan.getHousehold().getId().equals(householdId)) { + throw new ResourceNotFoundException("Week plan not found"); + } + + var household = weekPlan.getHousehold(); + + ShoppingList shoppingList = new ShoppingList(household, weekPlan); + shoppingList = shoppingListRepository.save(shoppingList); + + // Aggregate ingredients across all slots/recipes + // Key: ingredientId + unit -> merged data + Map merged = new LinkedHashMap<>(); + + for (var slot : weekPlan.getSlots()) { + var recipe = slot.getRecipe(); + for (RecipeIngredient ri : recipe.getIngredients()) { + Ingredient ingredient = ri.getIngredient(); + + // Filter out staples + if (ingredient.isStaple()) { + continue; + } + + String key = ingredient.getId().toString() + "|" + ri.getUnit(); + merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit())) + .addQuantity(ri.getQuantity()) + .addRecipeId(recipe.getId()); + } + } + + // Create shopping list items + for (MergedIngredient mi : merged.values()) { + ShoppingListItem item = new ShoppingListItem( + shoppingList, + mi.ingredient, + null, + mi.totalQuantity, + mi.unit, + mi.recipeIds.stream().distinct().toArray(UUID[]::new) + ); + shoppingList.getItems().add(item); + } + + shoppingListRepository.save(shoppingList); + + return toResponse(shoppingList); + } + + @Override + @Transactional(readOnly = true) + public ShoppingListResponse getShoppingList(UUID householdId, UUID shoppingListId) { + ShoppingList list = findList(householdId, shoppingListId); + return toResponse(list); + } + + @Override + public PublishResponse publish(UUID householdId, UUID shoppingListId) { + ShoppingList list = findList(householdId, shoppingListId); + + if (!"draft".equals(list.getStatus())) { + throw new ValidationException("Shopping list is already published"); + } + + list.setStatus("published"); + list.setPublishedAt(Instant.now()); + shoppingListRepository.save(list); + + return new PublishResponse(list.getId(), list.getStatus(), list.getPublishedAt()); + } + + @Override + public ShoppingListItemResponse checkItem(UUID householdId, UUID listId, UUID itemId, + CheckItemRequest request, UUID userId) { + ShoppingList list = findList(householdId, listId); + ShoppingListItem item = findItem(list, itemId); + + item.setChecked(request.isChecked()); + + if (request.isChecked()) { + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + item.setCheckedBy(user); + } else { + item.setCheckedBy(null); + } + + shoppingListItemRepository.save(item); + return toItemResponse(item); + } + + @Override + public ShoppingListItemResponse addItem(UUID householdId, UUID shoppingListId, AddItemRequest request) { + ShoppingList list = findList(householdId, shoppingListId); + + Ingredient ingredient = null; + if (request.ingredientId() != null) { + ingredient = ingredientRepository.findById(request.ingredientId()) + .orElseThrow(() -> new ResourceNotFoundException("Ingredient not found")); + } + + ShoppingListItem item = new ShoppingListItem( + list, + ingredient, + request.customName(), + request.quantity(), + request.unit(), + new UUID[0] + ); + + item = shoppingListItemRepository.save(item); + list.getItems().add(item); + + return toItemResponse(item); + } + + @Override + public void deleteItem(UUID householdId, UUID listId, UUID itemId) { + ShoppingList list = findList(householdId, listId); + + if ("published".equals(list.getStatus())) { + throw new ValidationException("Cannot delete items from a published shopping list"); + } + + ShoppingListItem item = findItem(list, itemId); + list.getItems().remove(item); + shoppingListItemRepository.delete(item); + } + + // ── Helpers ── + + private ShoppingList findList(UUID householdId, UUID shoppingListId) { + ShoppingList list = shoppingListRepository.findById(shoppingListId) + .orElseThrow(() -> new ResourceNotFoundException("Shopping list not found")); + + if (!list.getHousehold().getId().equals(householdId)) { + throw new ResourceNotFoundException("Shopping list not found"); + } + + return list; + } + + private ShoppingListItem findItem(ShoppingList list, UUID itemId) { + return list.getItems().stream() + .filter(i -> i.getId().equals(itemId)) + .findFirst() + .orElseThrow(() -> new ResourceNotFoundException("Shopping list item not found")); + } + + private ShoppingListResponse toResponse(ShoppingList list) { + List items = list.getItems().stream() + .map(this::toItemResponse) + .toList(); + + return new ShoppingListResponse( + list.getId(), + list.getWeekPlan().getId(), + list.getStatus(), + list.getPublishedAt(), + items + ); + } + + private ShoppingListItemResponse toItemResponse(ShoppingListItem item) { + String name; + ShoppingListItemResponse.CategoryRef categoryRef = null; + UUID ingredientId = null; + + if (item.getIngredient() != null) { + ingredientId = item.getIngredient().getId(); + name = item.getIngredient().getName(); + if (item.getIngredient().getCategory() != null) { + categoryRef = new ShoppingListItemResponse.CategoryRef( + item.getIngredient().getCategory().getId(), + item.getIngredient().getCategory().getName() + ); + } + } else { + name = item.getCustomName(); + } + + return new ShoppingListItemResponse( + item.getId(), + ingredientId, + name, + categoryRef, + item.getQuantity(), + item.getUnit(), + item.isChecked(), + item.getCheckedBy() != null ? item.getCheckedBy().getId() : null, + item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of() + ); + } + + private static class MergedIngredient { + final Ingredient ingredient; + final String unit; + BigDecimal totalQuantity = BigDecimal.ZERO; + final List recipeIds = new ArrayList<>(); + + MergedIngredient(Ingredient ingredient, String unit) { + this.ingredient = ingredient; + this.unit = unit; + } + + MergedIngredient addQuantity(BigDecimal qty) { + if (qty != null) { + this.totalQuantity = this.totalQuantity.add(qty); + } + return this; + } + + MergedIngredient addRecipeId(UUID recipeId) { + this.recipeIds.add(recipeId); + return this; + } + } +} diff --git a/backend/src/main/java/com/recipeapp/shopping/dto/AddItemRequest.java b/backend/src/main/java/com/recipeapp/shopping/dto/AddItemRequest.java new file mode 100644 index 0000000..4f2e5ef --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/dto/AddItemRequest.java @@ -0,0 +1,11 @@ +package com.recipeapp.shopping.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record AddItemRequest( + UUID ingredientId, + String customName, + BigDecimal quantity, + String unit +) {} diff --git a/backend/src/main/java/com/recipeapp/shopping/dto/CheckItemRequest.java b/backend/src/main/java/com/recipeapp/shopping/dto/CheckItemRequest.java new file mode 100644 index 0000000..b078a35 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/dto/CheckItemRequest.java @@ -0,0 +1,3 @@ +package com.recipeapp.shopping.dto; + +public record CheckItemRequest(boolean isChecked) {} diff --git a/backend/src/main/java/com/recipeapp/shopping/dto/PublishResponse.java b/backend/src/main/java/com/recipeapp/shopping/dto/PublishResponse.java new file mode 100644 index 0000000..ab895f9 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/dto/PublishResponse.java @@ -0,0 +1,6 @@ +package com.recipeapp.shopping.dto; + +import java.time.Instant; +import java.util.UUID; + +public record PublishResponse(UUID id, String status, Instant publishedAt) {} diff --git a/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListItemResponse.java b/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListItemResponse.java new file mode 100644 index 0000000..22d1bb3 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListItemResponse.java @@ -0,0 +1,19 @@ +package com.recipeapp.shopping.dto; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +public record ShoppingListItemResponse( + UUID id, + UUID ingredientId, + String name, + CategoryRef category, + BigDecimal quantity, + String unit, + boolean isChecked, + UUID checkedBy, + List sourceRecipes +) { + public record CategoryRef(UUID id, String name) {} +} diff --git a/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java b/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java new file mode 100644 index 0000000..f87de5a --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/dto/ShoppingListResponse.java @@ -0,0 +1,13 @@ +package com.recipeapp.shopping.dto; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record ShoppingListResponse( + UUID id, + UUID weekPlanId, + String status, + Instant publishedAt, + List items +) {} diff --git a/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java b/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java new file mode 100644 index 0000000..41b491e --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingList.java @@ -0,0 +1,51 @@ +package com.recipeapp.shopping.entity; + +import com.recipeapp.household.entity.Household; +import com.recipeapp.planning.entity.WeekPlan; +import jakarta.persistence.*; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "shopping_list") +public class ShoppingList { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "household_id", nullable = false) + private Household household; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "week_plan_id", nullable = false) + private WeekPlan weekPlan; + + @Column(nullable = false, length = 10) + private String status = "draft"; + + @Column(name = "published_at") + private Instant publishedAt; + + @OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + protected ShoppingList() {} + + public ShoppingList(Household household, WeekPlan weekPlan) { + this.household = household; + this.weekPlan = weekPlan; + } + + public UUID getId() { return id; } + public Household getHousehold() { return household; } + public WeekPlan getWeekPlan() { return weekPlan; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Instant getPublishedAt() { return publishedAt; } + public void setPublishedAt(Instant publishedAt) { this.publishedAt = publishedAt; } + public List getItems() { return items; } +} diff --git a/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingListItem.java b/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingListItem.java new file mode 100644 index 0000000..b2d71c2 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/shopping/entity/ShoppingListItem.java @@ -0,0 +1,71 @@ +package com.recipeapp.shopping.entity; + +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.recipe.entity.Ingredient; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.util.UUID; + +@Entity +@Table(name = "shopping_list_item") +public class ShoppingListItem { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shopping_list_id", nullable = false) + private ShoppingList shoppingList; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ingredient_id") + private Ingredient ingredient; + + @Column(name = "custom_name", length = 200) + private String customName; + + @Column(precision = 8, scale = 2) + private BigDecimal quantity; + + @Column(length = 20) + private String unit; + + @Column(name = "is_checked", nullable = false) + private boolean isChecked = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "checked_by") + private UserAccount checkedBy; + + @Column(name = "source_recipes", columnDefinition = "uuid[]") + private UUID[] sourceRecipes; + + protected ShoppingListItem() {} + + public ShoppingListItem(ShoppingList shoppingList, Ingredient ingredient, String customName, + BigDecimal quantity, String unit, UUID[] sourceRecipes) { + this.shoppingList = shoppingList; + this.ingredient = ingredient; + this.customName = customName; + this.quantity = quantity; + this.unit = unit; + this.sourceRecipes = sourceRecipes; + } + + public UUID getId() { return id; } + public ShoppingList getShoppingList() { return shoppingList; } + public Ingredient getIngredient() { return ingredient; } + public String getCustomName() { return customName; } + public void setCustomName(String customName) { this.customName = customName; } + public BigDecimal getQuantity() { return quantity; } + public void setQuantity(BigDecimal quantity) { this.quantity = quantity; } + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + public boolean isChecked() { return isChecked; } + public void setChecked(boolean checked) { isChecked = checked; } + public UserAccount getCheckedBy() { return checkedBy; } + public void setCheckedBy(UserAccount checkedBy) { this.checkedBy = checkedBy; } + public UUID[] getSourceRecipes() { return sourceRecipes; } + public void setSourceRecipes(UUID[] sourceRecipes) { this.sourceRecipes = sourceRecipes; } +} diff --git a/backend/src/test/java/com/recipeapp/admin/AdminControllerTest.java b/backend/src/test/java/com/recipeapp/admin/AdminControllerTest.java new file mode 100644 index 0000000..5bcb4a7 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/admin/AdminControllerTest.java @@ -0,0 +1,119 @@ +package com.recipeapp.admin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.recipeapp.admin.dto.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.security.Principal; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class AdminControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + private AdminService adminService; + + @InjectMocks + private AdminController adminController; + + private final String adminEmail = "admin@example.com"; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(adminController).build(); + } + + @Test + void listUsers_returnsPagedUsers() throws Exception { + var user = new AdminUserResponse(UUID.randomUUID(), "jane@example.com", "Jane", "user", true, Instant.now()); + when(adminService.listUsers(isNull(), isNull(), eq(50), eq(0))) + .thenReturn(new AdminService.ListUsersResult(List.of(user), 1)); + + mockMvc.perform(get("/v1/admin/users") + .principal(() -> adminEmail)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data[0].email").value("jane@example.com")) + .andExpect(jsonPath("$.meta.pagination.total").value(1)); + } + + @Test + void createUser_returns201() throws Exception { + var request = new CreateUserRequest("new@example.com", "New User", "TempPass1!", "user"); + var response = new AdminUserResponse(UUID.randomUUID(), "new@example.com", "New User", "user", true, Instant.now()); + when(adminService.createUser(any(CreateUserRequest.class), eq(adminEmail))) + .thenReturn(response); + + mockMvc.perform(post("/v1/admin/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .principal(() -> adminEmail)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.email").value("new@example.com")); + } + + @Test + void updateUser_returns200() throws Exception { + UUID userId = UUID.randomUUID(); + var request = new UpdateUserRequest("Updated Name", null, null, null); + var response = new AdminUserResponse(userId, "jane@example.com", "Updated Name", "user", true, Instant.now()); + when(adminService.updateUser(eq(userId), any(UpdateUserRequest.class), eq(adminEmail))) + .thenReturn(response); + + mockMvc.perform(patch("/v1/admin/users/{id}", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .principal(() -> adminEmail)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.displayName").value("Updated Name")); + } + + @Test + void resetPassword_returns200() throws Exception { + UUID userId = UUID.randomUUID(); + var request = new ResetPasswordRequest("NewTemp123!", "User forgot password"); + var response = new ResetPasswordResponse("Password reset successfully", true); + when(adminService.resetPassword(eq(userId), any(ResetPasswordRequest.class), eq(adminEmail))) + .thenReturn(response); + + mockMvc.perform(post("/v1/admin/users/{id}/reset-password", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .principal(() -> adminEmail)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value("Password reset successfully")) + .andExpect(jsonPath("$.data.mustChangePassword").value(true)); + } + + @Test + void listAuditLog_returnsLogs() throws Exception { + var log = new AuditLogResponse(UUID.randomUUID(), UUID.randomUUID(), "admin@example.com", + UUID.randomUUID(), "jane@example.com", "create_account", Map.of(), Instant.now()); + when(adminService.listAuditLog(isNull(), eq(50), eq(0))) + .thenReturn(List.of(log)); + + mockMvc.perform(get("/v1/admin/audit-log") + .principal(() -> adminEmail)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].action").value("create_account")); + } +} diff --git a/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java b/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java new file mode 100644 index 0000000..0c4f587 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java @@ -0,0 +1,170 @@ +package com.recipeapp.admin; + +import com.recipeapp.admin.dto.*; +import com.recipeapp.admin.entity.AdminAuditLog; +import com.recipeapp.auth.UserAccountRepository; +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.common.ConflictException; +import com.recipeapp.common.ResourceNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.Instant; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @Mock private UserAccountRepository userAccountRepository; + @Mock private AdminAuditLogRepository auditLogRepository; + @Mock private AdminUserQueryRepository adminUserQueryRepository; + @Mock private PasswordEncoder passwordEncoder; + + private AdminServiceImpl adminService; + + private final String adminEmail = "admin@example.com"; + private UserAccount adminUser; + private UserAccount targetUser; + + @BeforeEach + void setUp() { + adminService = new AdminServiceImpl(userAccountRepository, auditLogRepository, adminUserQueryRepository, passwordEncoder); + adminUser = new UserAccount("admin@example.com", "Admin", "hashed"); + setId(adminUser, UserAccount.class, UUID.randomUUID()); + targetUser = new UserAccount("jane@example.com", "Jane", "hashed"); + setId(targetUser, UserAccount.class, UUID.randomUUID()); + } + + private void setId(T entity, Class clazz, UUID id) { + try { + var field = clazz.getDeclaredField("id"); + field.setAccessible(true); + field.set(entity, id); + } catch (Exception e) { throw new RuntimeException(e); } + } + + @Test + void listUsers_returnsPaginatedResults() { + when(adminUserQueryRepository.findUsersFiltered(isNull(), isNull(), any(Pageable.class))) + .thenReturn(List.of(targetUser)); + when(adminUserQueryRepository.countUsersFiltered(isNull(), isNull())) + .thenReturn(1L); + + var result = adminService.listUsers(null, null, 50, 0); + + assertEquals(1, result.users().size()); + assertEquals(1L, result.total()); + assertEquals("jane@example.com", result.users().getFirst().email()); + } + + @Test + void listUsers_withSearchFilter() { + when(adminUserQueryRepository.findUsersFiltered(eq("jane"), isNull(), any(Pageable.class))) + .thenReturn(List.of(targetUser)); + when(adminUserQueryRepository.countUsersFiltered(eq("jane"), isNull())) + .thenReturn(1L); + + var result = adminService.listUsers("jane", null, 50, 0); + + assertEquals(1, result.users().size()); + } + + @Test + void createUser_success() { + when(userAccountRepository.existsByEmailIgnoreCase("new@example.com")).thenReturn(false); + when(userAccountRepository.findByEmailIgnoreCase(adminEmail)).thenReturn(Optional.of(adminUser)); + when(passwordEncoder.encode("TempPass1!")).thenReturn("encoded"); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(inv -> { + var u = inv.getArgument(0, UserAccount.class); + setId(u, UserAccount.class, UUID.randomUUID()); + return u; + }); + when(auditLogRepository.save(any(AdminAuditLog.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = adminService.createUser( + new CreateUserRequest("new@example.com", "New User", "TempPass1!", "user"), adminEmail); + + assertEquals("new@example.com", result.email()); + assertEquals("New User", result.displayName()); + verify(auditLogRepository).save(argThat(log -> "create_account".equals(log.getAction()))); + } + + @Test + void createUser_duplicateEmail_throwsConflict() { + when(userAccountRepository.existsByEmailIgnoreCase("jane@example.com")).thenReturn(true); + + assertThrows(ConflictException.class, () -> + adminService.createUser( + new CreateUserRequest("jane@example.com", "Jane", "TempPass1!", "user"), adminEmail)); + } + + @Test + void updateUser_success() { + when(userAccountRepository.findByEmailIgnoreCase(adminEmail)).thenReturn(Optional.of(adminUser)); + when(userAccountRepository.findById(targetUser.getId())).thenReturn(Optional.of(targetUser)); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(inv -> inv.getArgument(0)); + when(auditLogRepository.save(any(AdminAuditLog.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = adminService.updateUser(targetUser.getId(), + new UpdateUserRequest("Updated Jane", null, null, null), adminEmail); + + assertEquals("Updated Jane", result.displayName()); + verify(auditLogRepository).save(argThat(log -> "update_account".equals(log.getAction()))); + } + + @Test + void updateUser_deactivate() { + when(userAccountRepository.findByEmailIgnoreCase(adminEmail)).thenReturn(Optional.of(adminUser)); + when(userAccountRepository.findById(targetUser.getId())).thenReturn(Optional.of(targetUser)); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(inv -> inv.getArgument(0)); + when(auditLogRepository.save(any(AdminAuditLog.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = adminService.updateUser(targetUser.getId(), + new UpdateUserRequest(null, null, null, false), adminEmail); + + assertFalse(result.isActive()); + verify(auditLogRepository).save(argThat(log -> "deactivate_account".equals(log.getAction()))); + } + + @Test + void resetPassword_success() { + when(userAccountRepository.findByEmailIgnoreCase(adminEmail)).thenReturn(Optional.of(adminUser)); + when(userAccountRepository.findById(targetUser.getId())).thenReturn(Optional.of(targetUser)); + when(passwordEncoder.encode("NewTemp123!")).thenReturn("encoded"); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(inv -> inv.getArgument(0)); + when(auditLogRepository.save(any(AdminAuditLog.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = adminService.resetPassword(targetUser.getId(), + new ResetPasswordRequest("NewTemp123!", "Forgot password"), adminEmail); + + assertEquals("Password reset successfully", result.message()); + assertTrue(result.mustChangePassword()); + verify(auditLogRepository).save(argThat(log -> "reset_password".equals(log.getAction()))); + } + + @Test + void listAuditLog_returnsLogs() { + var log = new AdminAuditLog(adminUser.getId(), targetUser.getId(), "create_account", Map.of(), null); + setId(log, AdminAuditLog.class, UUID.randomUUID()); + when(auditLogRepository.findAllByOrderByPerformedAtDesc(any(Pageable.class))) + .thenReturn(List.of(log)); + when(userAccountRepository.findById(adminUser.getId())).thenReturn(Optional.of(adminUser)); + when(userAccountRepository.findById(targetUser.getId())).thenReturn(Optional.of(targetUser)); + + var result = adminService.listAuditLog(null, 50, 0); + + assertEquals(1, result.size()); + assertEquals("create_account", result.getFirst().action()); + assertEquals("admin@example.com", result.getFirst().adminEmail()); + } +} diff --git a/backend/src/test/java/com/recipeapp/pantry/PantryControllerTest.java b/backend/src/test/java/com/recipeapp/pantry/PantryControllerTest.java new file mode 100644 index 0000000..03ab609 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/pantry/PantryControllerTest.java @@ -0,0 +1,116 @@ +package com.recipeapp.pantry; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.pantry.dto.*; +import com.recipeapp.recipe.HouseholdResolver; +import com.recipeapp.recipe.dto.RecipeDetailResponse.CategoryRef; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class PantryControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Mock private PantryService pantryService; + @Mock private HouseholdResolver householdResolver; + + @InjectMocks private PantryController pantryController; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(pantryController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void listItemsShouldReturn200() throws Exception { + var categoryRef = new CategoryRef(UUID.randomUUID(), "Dairy"); + var item = new PantryItemResponse(UUID.randomUUID(), UUID.randomUUID(), "Milk", categoryRef, + new BigDecimal("2.00"), "liters", LocalDate.of(2026, 4, 10), null); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(pantryService.listItems(HOUSEHOLD_ID)).thenReturn(List.of(item)); + + mockMvc.perform(get("/v1/pantry-items") + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Milk")) + .andExpect(jsonPath("$[0].category.name").value("Dairy")); + } + + @Test + void createItemShouldReturn201() throws Exception { + var request = new CreatePantryItemRequest(UUID.randomUUID(), null, + new BigDecimal("1.50"), "kg", LocalDate.of(2026, 4, 15), null); + var response = new PantryItemResponse(UUID.randomUUID(), request.ingredientId(), "Chicken", null, + request.quantity(), request.unit(), request.bestBefore(), null); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(pantryService.createItem(eq(HOUSEHOLD_ID), any(CreatePantryItemRequest.class))) + .thenReturn(response); + + mockMvc.perform(post("/v1/pantry-items") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Chicken")); + } + + @Test + void updateItemShouldReturn200() throws Exception { + var itemId = UUID.randomUUID(); + var request = new UpdatePantryItemRequest(new BigDecimal("0.50"), null, null, null); + var response = new PantryItemResponse(itemId, UUID.randomUUID(), "Milk", null, + new BigDecimal("0.50"), "liters", LocalDate.of(2026, 4, 10), null); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(pantryService.updateItem(eq(HOUSEHOLD_ID), eq(itemId), any(UpdatePantryItemRequest.class))) + .thenReturn(response); + + mockMvc.perform(patch("/v1/pantry-items/{id}", itemId) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.quantity").value(0.50)); + } + + @Test + void deleteItemShouldReturn204() throws Exception { + var itemId = UUID.randomUUID(); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + + mockMvc.perform(delete("/v1/pantry-items/{id}", itemId) + .principal(() -> "sarah@example.com")) + .andExpect(status().isNoContent()); + + verify(pantryService).deleteItem(HOUSEHOLD_ID, itemId); + } +} diff --git a/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java b/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java new file mode 100644 index 0000000..5bc3b80 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java @@ -0,0 +1,181 @@ +package com.recipeapp.pantry; + +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ValidationException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.pantry.dto.*; +import com.recipeapp.pantry.entity.PantryItem; +import com.recipeapp.recipe.IngredientRepository; +import com.recipeapp.recipe.entity.Ingredient; +import com.recipeapp.recipe.entity.IngredientCategory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PantryServiceTest { + + @Mock private PantryItemRepository pantryItemRepository; + @Mock private HouseholdRepository householdRepository; + @Mock private IngredientRepository ingredientRepository; + + @InjectMocks private PantryServiceImpl pantryService; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + + private Household testHousehold() { + var h = new Household("Test family", null); + setId(h, Household.class, HOUSEHOLD_ID); + return h; + } + + private Ingredient testIngredient(Household household, String name) { + var i = new Ingredient(household, name, false); + setId(i, Ingredient.class, UUID.randomUUID()); + var cat = new IngredientCategory(household, "Dairy", (short) 1); + setId(cat, IngredientCategory.class, UUID.randomUUID()); + i.setCategory(cat); + return i; + } + + private PantryItem testPantryItem(Household household, Ingredient ingredient) { + var item = new PantryItem(household, ingredient, null, + new BigDecimal("2.00"), "liters", LocalDate.of(2026, 4, 10), null); + setId(item, PantryItem.class, UUID.randomUUID()); + return item; + } + + private void setId(T entity, Class clazz, UUID id) { + try { + var field = clazz.getDeclaredField("id"); + field.setAccessible(true); + field.set(entity, id); + } catch (Exception e) { throw new RuntimeException(e); } + } + + @Test + void listItemsShouldReturnItemsSortedByBestBefore() { + var household = testHousehold(); + var ingredient = testIngredient(household, "Milk"); + var item = testPantryItem(household, ingredient); + + when(pantryItemRepository.findByHouseholdIdOrderByBestBeforeAscNullsLast(HOUSEHOLD_ID)) + .thenReturn(List.of(item)); + + List result = pantryService.listItems(HOUSEHOLD_ID); + + assertThat(result).hasSize(1); + assertThat(result.get(0).name()).isEqualTo("Milk"); + assertThat(result.get(0).category()).isNotNull(); + assertThat(result.get(0).category().name()).isEqualTo("Dairy"); + } + + @Test + void createItemWithIngredientShouldResolveIngredient() { + var household = testHousehold(); + var ingredient = testIngredient(household, "Milk"); + + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient)); + when(pantryItemRepository.save(any(PantryItem.class))).thenAnswer(invocation -> { + PantryItem saved = invocation.getArgument(0); + setId(saved, PantryItem.class, UUID.randomUUID()); + return saved; + }); + + var request = new CreatePantryItemRequest(ingredient.getId(), null, + new BigDecimal("1.00"), "liters", LocalDate.of(2026, 4, 15), null); + PantryItemResponse result = pantryService.createItem(HOUSEHOLD_ID, request); + + assertThat(result.name()).isEqualTo("Milk"); + assertThat(result.ingredientId()).isEqualTo(ingredient.getId()); + } + + @Test + void createItemWithCustomNameShouldUseCustomName() { + var household = testHousehold(); + + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(pantryItemRepository.save(any(PantryItem.class))).thenAnswer(invocation -> { + PantryItem saved = invocation.getArgument(0); + setId(saved, PantryItem.class, UUID.randomUUID()); + return saved; + }); + + var request = new CreatePantryItemRequest(null, "Homemade sauce", + new BigDecimal("1.00"), "jar", null, null); + PantryItemResponse result = pantryService.createItem(HOUSEHOLD_ID, request); + + assertThat(result.name()).isEqualTo("Homemade sauce"); + assertThat(result.ingredientId()).isNull(); + assertThat(result.category()).isNull(); + } + + @Test + void createItemWithoutIngredientOrCustomNameShouldThrowValidation() { + var household = testHousehold(); + + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + + var request = new CreatePantryItemRequest(null, null, + new BigDecimal("1.00"), "kg", null, null); + + assertThatThrownBy(() -> pantryService.createItem(HOUSEHOLD_ID, request)) + .isInstanceOf(ValidationException.class); + } + + @Test + void updateItemShouldUpdateFields() { + var household = testHousehold(); + var ingredient = testIngredient(household, "Milk"); + var item = testPantryItem(household, ingredient); + + when(pantryItemRepository.findByIdAndHouseholdId(item.getId(), HOUSEHOLD_ID)) + .thenReturn(Optional.of(item)); + when(pantryItemRepository.save(any(PantryItem.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + var request = new UpdatePantryItemRequest(new BigDecimal("0.50"), null, null, LocalDate.of(2026, 4, 1)); + PantryItemResponse result = pantryService.updateItem(HOUSEHOLD_ID, item.getId(), request); + + assertThat(result.quantity()).isEqualByComparingTo(new BigDecimal("0.50")); + assertThat(result.openedOn()).isEqualTo(LocalDate.of(2026, 4, 1)); + } + + @Test + void deleteItemShouldRemoveItem() { + var household = testHousehold(); + var ingredient = testIngredient(household, "Milk"); + var item = testPantryItem(household, ingredient); + + when(pantryItemRepository.findByIdAndHouseholdId(item.getId(), HOUSEHOLD_ID)) + .thenReturn(Optional.of(item)); + + pantryService.deleteItem(HOUSEHOLD_ID, item.getId()); + + verify(pantryItemRepository).delete(item); + } + + @Test + void deleteItemNotFoundShouldThrow() { + var itemId = UUID.randomUUID(); + + when(pantryItemRepository.findByIdAndHouseholdId(itemId, HOUSEHOLD_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> pantryService.deleteItem(HOUSEHOLD_ID, itemId)) + .isInstanceOf(ResourceNotFoundException.class); + } +} diff --git a/backend/src/test/java/com/recipeapp/planning/CookingLogControllerTest.java b/backend/src/test/java/com/recipeapp/planning/CookingLogControllerTest.java new file mode 100644 index 0000000..e5379b6 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/planning/CookingLogControllerTest.java @@ -0,0 +1,81 @@ +package com.recipeapp.planning; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.planning.dto.*; +import com.recipeapp.recipe.HouseholdResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class CookingLogControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Mock private PlanningService planningService; + @Mock private HouseholdResolver householdResolver; + + @InjectMocks private CookingLogController cookingLogController; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(cookingLogController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void createCookingLogShouldReturn201() throws Exception { + var recipeId = UUID.randomUUID(); + var logResponse = new CookingLogResponse(UUID.randomUUID(), recipeId, + "Spaghetti Bolognese", LocalDate.of(2026, 4, 7), UUID.randomUUID()); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.createCookingLog(eq(HOUSEHOLD_ID), any(), any(CreateCookingLogRequest.class))) + .thenReturn(logResponse); + + mockMvc.perform(post("/v1/cooking-logs") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new CreateCookingLogRequest(recipeId, LocalDate.of(2026, 4, 7))))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.recipeName").value("Spaghetti Bolognese")); + } + + @Test + void listCookingLogsShouldReturn200() throws Exception { + var log = new CookingLogResponse(UUID.randomUUID(), UUID.randomUUID(), + "Spaghetti Bolognese", LocalDate.of(2026, 4, 7), UUID.randomUUID()); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.listCookingLogs(HOUSEHOLD_ID, 30, 0)).thenReturn(List.of(log)); + + mockMvc.perform(get("/v1/cooking-logs") + .principal(() -> "sarah@example.com") + .param("limit", "30") + .param("offset", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].recipeName").value("Spaghetti Bolognese")); + } +} diff --git a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java new file mode 100644 index 0000000..00eb257 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java @@ -0,0 +1,280 @@ +package com.recipeapp.planning; + +import com.recipeapp.auth.UserAccountRepository; +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.common.ConflictException; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ValidationException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.planning.dto.*; +import com.recipeapp.planning.entity.*; +import com.recipeapp.recipe.RecipeRepository; +import com.recipeapp.recipe.entity.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PlanningServiceTest { + + @Mock private WeekPlanRepository weekPlanRepository; + @Mock private WeekPlanSlotRepository weekPlanSlotRepository; + @Mock private CookingLogRepository cookingLogRepository; + @Mock private RecipeRepository recipeRepository; + @Mock private HouseholdRepository householdRepository; + @Mock private UserAccountRepository userAccountRepository; + + @InjectMocks private PlanningServiceImpl planningService; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + private static final LocalDate WEEK_START = LocalDate.of(2026, 4, 6); // Monday + + private Household testHousehold() { + var h = new Household("Test family", null); + setId(h, Household.class, HOUSEHOLD_ID); + return h; + } + + private WeekPlan testWeekPlan(Household household) { + var wp = new WeekPlan(household, WEEK_START); + setId(wp, WeekPlan.class, UUID.randomUUID()); + return wp; + } + + private Recipe testRecipe(Household household, String name) { + var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true); + setId(r, Recipe.class, UUID.randomUUID()); + return r; + } + + private void setId(T entity, Class clazz, UUID id) { + try { + var field = clazz.getDeclaredField("id"); + field.setAccessible(true); + field.set(entity, id); + } catch (Exception e) { throw new RuntimeException(e); } + } + + // ── Week Plan CRUD ── + + @Test + void getWeekPlanShouldReturnPlanWithSlots() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var recipe = testRecipe(household, "Spaghetti"); + var slot = new WeekPlanSlot(plan, recipe, WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + when(weekPlanRepository.findByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.of(plan)); + + WeekPlanResponse result = planningService.getWeekPlan(HOUSEHOLD_ID, WEEK_START); + + assertThat(result.weekStart()).isEqualTo(WEEK_START); + assertThat(result.slots()).hasSize(1); + assertThat(result.slots().getFirst().recipe().name()).isEqualTo("Spaghetti"); + } + + @Test + void getWeekPlanShouldThrowWhenNotFound() { + when(weekPlanRepository.findByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.getWeekPlan(HOUSEHOLD_ID, WEEK_START)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createWeekPlanShouldPersist() { + var household = testHousehold(); + + when(weekPlanRepository.existsByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)).thenReturn(false); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(weekPlanRepository.save(any(WeekPlan.class))).thenAnswer(i -> { + WeekPlan wp = i.getArgument(0); + setId(wp, WeekPlan.class, UUID.randomUUID()); + return wp; + }); + + WeekPlanResponse result = planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START); + + assertThat(result.weekStart()).isEqualTo(WEEK_START); + assertThat(result.status()).isEqualTo("draft"); + } + + @Test + void createWeekPlanShouldThrowConflictWhenExists() { + when(weekPlanRepository.existsByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)).thenReturn(true); + + assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START)) + .isInstanceOf(ConflictException.class); + } + + @Test + void createWeekPlanShouldThrowWhenNotMonday() { + var tuesday = LocalDate.of(2026, 4, 7); + + assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, tuesday)) + .isInstanceOf(ValidationException.class); + } + + // ── Slots ── + + @Test + void addSlotShouldCreateSlot() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var recipe = testRecipe(household, "Spaghetti"); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipe.getId(), HOUSEHOLD_ID)) + .thenReturn(Optional.of(recipe)); + when(weekPlanSlotRepository.save(any(WeekPlanSlot.class))).thenAnswer(i -> { + WeekPlanSlot s = i.getArgument(0); + setId(s, WeekPlanSlot.class, UUID.randomUUID()); + return s; + }); + + SlotResponse result = planningService.addSlot(HOUSEHOLD_ID, plan.getId(), + new CreateSlotRequest(WEEK_START.plusDays(1), recipe.getId())); + + assertThat(result.recipe().name()).isEqualTo("Spaghetti"); + } + + @Test + void updateSlotShouldSwapRecipe() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var oldRecipe = testRecipe(household, "Spaghetti"); + var newRecipe = testRecipe(household, "Stir Fry"); + var slot = new WeekPlanSlot(plan, oldRecipe, WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(weekPlanSlotRepository.findById(slot.getId())).thenReturn(Optional.of(slot)); + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(newRecipe.getId(), HOUSEHOLD_ID)) + .thenReturn(Optional.of(newRecipe)); + + SlotResponse result = planningService.updateSlot(HOUSEHOLD_ID, plan.getId(), slot.getId(), + new UpdateSlotRequest(newRecipe.getId())); + + assertThat(result.recipe().name()).isEqualTo("Stir Fry"); + } + + @Test + void deleteSlotShouldRemoveSlot() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var slot = new WeekPlanSlot(plan, testRecipe(household, "X"), WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(weekPlanSlotRepository.findById(slot.getId())).thenReturn(Optional.of(slot)); + + planningService.deleteSlot(HOUSEHOLD_ID, plan.getId(), slot.getId()); + + verify(weekPlanSlotRepository).delete(slot); + } + + // ── Confirm ── + + @Test + void confirmPlanShouldSetStatusAndTimestamp() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var slot = new WeekPlanSlot(plan, testRecipe(household, "X"), WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + + WeekPlanResponse result = planningService.confirmPlan(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.status()).isEqualTo("confirmed"); + assertThat(result.confirmedAt()).isNotNull(); + } + + @Test + void confirmPlanShouldThrowWhenNoSlots() { + var household = testHousehold(); + var plan = testWeekPlan(household); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + + assertThatThrownBy(() -> planningService.confirmPlan(HOUSEHOLD_ID, plan.getId())) + .isInstanceOf(ValidationException.class); + } + + @Test + void confirmPlanShouldThrowWhenAlreadyConfirmed() { + var household = testHousehold(); + var plan = testWeekPlan(household); + plan.setStatus("confirmed"); + var slot = new WeekPlanSlot(plan, testRecipe(household, "X"), WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + + assertThatThrownBy(() -> planningService.confirmPlan(HOUSEHOLD_ID, plan.getId())) + .isInstanceOf(ValidationException.class); + } + + // ── Cooking Logs ── + + @Test + void createCookingLogShouldPersist() { + var household = testHousehold(); + var recipe = testRecipe(household, "Spaghetti"); + var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); + setId(user, UserAccount.class, UUID.randomUUID()); + + when(recipeRepository.findById(recipe.getId())).thenReturn(Optional.of(recipe)); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(userAccountRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(cookingLogRepository.save(any(CookingLog.class))).thenAnswer(i -> { + CookingLog cl = i.getArgument(0); + setId(cl, CookingLog.class, UUID.randomUUID()); + return cl; + }); + + CookingLogResponse result = planningService.createCookingLog(HOUSEHOLD_ID, user.getId(), + new CreateCookingLogRequest(recipe.getId(), LocalDate.of(2026, 4, 7))); + + assertThat(result.recipeName()).isEqualTo("Spaghetti"); + assertThat(result.cookedOn()).isEqualTo(LocalDate.of(2026, 4, 7)); + } + + @Test + void listCookingLogsShouldReturnRecent() { + var household = testHousehold(); + var recipe = testRecipe(household, "Spaghetti"); + var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); + setId(user, UserAccount.class, UUID.randomUUID()); + var log = new CookingLog(recipe, household, LocalDate.of(2026, 4, 7), user); + setId(log, CookingLog.class, UUID.randomUUID()); + + when(cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc(eq(HOUSEHOLD_ID), any())) + .thenReturn(List.of(log)); + + List result = planningService.listCookingLogs(HOUSEHOLD_ID, 30, 0); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().recipeName()).isEqualTo("Spaghetti"); + } +} diff --git a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java new file mode 100644 index 0000000..a9ffa9b --- /dev/null +++ b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java @@ -0,0 +1,186 @@ +package com.recipeapp.planning; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.common.ValidationException; +import com.recipeapp.planning.dto.*; +import com.recipeapp.recipe.HouseholdResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class WeekPlanControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Mock private PlanningService planningService; + @Mock private HouseholdResolver householdResolver; + + @InjectMocks private WeekPlanController weekPlanController; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + private static final UUID PLAN_ID = UUID.randomUUID(); + private static final UUID SLOT_ID = UUID.randomUUID(); + private static final LocalDate WEEK_START = LocalDate.of(2026, 4, 6); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(weekPlanController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void getWeekPlanShouldReturn200() throws Exception { + var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of()); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.getWeekPlan(HOUSEHOLD_ID, WEEK_START)).thenReturn(plan); + + mockMvc.perform(get("/v1/week-plans") + .principal(() -> "sarah@example.com") + .param("weekStart", "2026-04-06")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weekStart").value("2026-04-06")) + .andExpect(jsonPath("$.status").value("draft")); + } + + @Test + void createWeekPlanShouldReturn201() throws Exception { + var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of()); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START)).thenReturn(plan); + + mockMvc.perform(post("/v1/week-plans") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new CreateWeekPlanRequest(WEEK_START)))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.weekStart").value("2026-04-06")); + } + + @Test + void addSlotShouldReturn201() throws Exception { + var recipeId = UUID.randomUUID(); + var slotRecipe = new SlotResponse.SlotRecipe(recipeId, "Spaghetti", "medium", (short) 45, null); + var slot = new SlotResponse(SLOT_ID, WEEK_START.plusDays(1), slotRecipe); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.addSlot(eq(HOUSEHOLD_ID), eq(PLAN_ID), any(CreateSlotRequest.class))) + .thenReturn(slot); + + mockMvc.perform(post("/v1/week-plans/{id}/slots", PLAN_ID) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new CreateSlotRequest(WEEK_START.plusDays(1), recipeId)))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.recipe.name").value("Spaghetti")); + } + + @Test + void updateSlotShouldReturn200() throws Exception { + var recipeId = UUID.randomUUID(); + var slotRecipe = new SlotResponse.SlotRecipe(recipeId, "Stir Fry", "easy", (short) 15, null); + var slot = new SlotResponse(SLOT_ID, WEEK_START.plusDays(1), slotRecipe); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.updateSlot(eq(HOUSEHOLD_ID), eq(PLAN_ID), eq(SLOT_ID), + any(UpdateSlotRequest.class))).thenReturn(slot); + + mockMvc.perform(patch("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UpdateSlotRequest(recipeId)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.recipe.name").value("Stir Fry")); + } + + @Test + void deleteSlotShouldReturn204() throws Exception { + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + doNothing().when(planningService).deleteSlot(HOUSEHOLD_ID, PLAN_ID, SLOT_ID); + + mockMvc.perform(delete("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isNoContent()); + } + + @Test + void confirmPlanShouldReturn200() throws Exception { + var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "confirmed", Instant.now(), List.of()); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.confirmPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(plan); + + mockMvc.perform(post("/v1/week-plans/{id}/confirm", PLAN_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("confirmed")); + } + + @Test + void confirmPlanShouldReturn422WhenNoSlots() throws Exception { + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.confirmPlan(HOUSEHOLD_ID, PLAN_ID)) + .thenThrow(new ValidationException("Plan has no slots")); + + mockMvc.perform(post("/v1/week-plans/{id}/confirm", PLAN_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void getSuggestionsShouldReturn200() throws Exception { + var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null); + var item = new SuggestionResponse.SuggestionItem(recipe, + List.of("not_cooked_recently"), List.of()); + var response = new SuggestionResponse(List.of(item)); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.getSuggestions(HOUSEHOLD_ID, PLAN_ID, WEEK_START.plusDays(2))) + .thenReturn(response); + + mockMvc.perform(get("/v1/week-plans/{id}/suggestions", PLAN_ID) + .principal(() -> "sarah@example.com") + .param("slotDate", "2026-04-08")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry")) + .andExpect(jsonPath("$.suggestions[0].fitReasons[0]").value("not_cooked_recently")); + } + + @Test + void getVarietyScoreShouldReturn200() throws Exception { + var response = new VarietyScoreResponse(7.5, List.of(), List.of(), + Map.of("easy", 2, "medium", 3, "hard", 2)); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.getVarietyScore(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response); + + mockMvc.perform(get("/v1/week-plans/{id}/variety-score", PLAN_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.score").value(7.5)); + } +} diff --git a/backend/src/test/java/com/recipeapp/recipe/IngredientCategoryControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/IngredientCategoryControllerTest.java new file mode 100644 index 0000000..9f8b976 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/recipe/IngredientCategoryControllerTest.java @@ -0,0 +1,74 @@ +package com.recipeapp.recipe; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.recipe.dto.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class IngredientCategoryControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock private RecipeService recipeService; + @Mock private HouseholdResolver householdResolver; + + @InjectMocks private IngredientCategoryController ingredientCategoryController; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(ingredientCategoryController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void listCategoriesShouldReturn200() throws Exception { + var cat = new IngredientCategoryResponse(UUID.randomUUID(), "Produce"); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.listCategories(HOUSEHOLD_ID)).thenReturn(List.of(cat)); + + mockMvc.perform(get("/v1/ingredient-categories") + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Produce")); + } + + @Test + void createCategoryShouldReturn201() throws Exception { + var request = new IngredientCategoryCreateRequest("Frozen"); + var response = new IngredientCategoryResponse(UUID.randomUUID(), "Frozen"); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.createCategory(eq(HOUSEHOLD_ID), any(IngredientCategoryCreateRequest.class))) + .thenReturn(response); + + mockMvc.perform(post("/v1/ingredient-categories") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Frozen")); + } +} diff --git a/backend/src/test/java/com/recipeapp/recipe/IngredientControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/IngredientControllerTest.java new file mode 100644 index 0000000..ef17631 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/recipe/IngredientControllerTest.java @@ -0,0 +1,79 @@ +package com.recipeapp.recipe; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.recipe.dto.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class IngredientControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock private RecipeService recipeService; + @Mock private HouseholdResolver householdResolver; + + @InjectMocks private IngredientController ingredientController; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(ingredientController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void searchIngredientsShouldReturn200() throws Exception { + var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "meat"); + var ingredient = new IngredientResponse(UUID.randomUUID(), "chicken breast", catRef, false); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.searchIngredients(HOUSEHOLD_ID, "chick", null)) + .thenReturn(List.of(ingredient)); + + mockMvc.perform(get("/v1/ingredients") + .principal(() -> "sarah@example.com") + .param("search", "chick")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("chicken breast")) + .andExpect(jsonPath("$[0].category.name").value("meat")); + } + + @Test + void patchIngredientShouldReturn200() throws Exception { + var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "oil"); + var ingredientId = UUID.randomUUID(); + var response = new IngredientResponse(ingredientId, "olive oil", catRef, true); + var request = new IngredientPatchRequest(null, true, null); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.patchIngredient(eq(HOUSEHOLD_ID), eq(ingredientId), any(IngredientPatchRequest.class))) + .thenReturn(response); + + mockMvc.perform(patch("/v1/ingredients/{id}", ingredientId) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isStaple").value(true)); + } +} diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java new file mode 100644 index 0000000..7d8d9d5 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java @@ -0,0 +1,184 @@ +package com.recipeapp.recipe; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.recipe.dto.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class RecipeControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock private RecipeService recipeService; + @Mock private HouseholdResolver householdResolver; + + @InjectMocks private RecipeController recipeController; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + private static final UUID RECIPE_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(recipeController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void listRecipesShouldReturn200WithPagination() throws Exception { + var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese", + (short) 4, (short) 45, "medium", true, null); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(20), eq(0))) + .thenReturn(List.of(summary)); + when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull())) + .thenReturn(1L); + + mockMvc.perform(get("/v1/recipes") + .principal(() -> "sarah@example.com") + .param("limit", "20") + .param("offset", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].name").value("Spaghetti Bolognese")) + .andExpect(jsonPath("$.meta.pagination.total").value(1)) + .andExpect(jsonPath("$.meta.pagination.hasMore").value(false)); + } + + @Test + void listRecipesWithFiltersShouldPassParams() throws Exception { + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), + eq(30), eq("-cookTimeMin"), eq(10), eq(5))) + .thenReturn(List.of()); + when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), eq(30))) + .thenReturn(0L); + + mockMvc.perform(get("/v1/recipes") + .principal(() -> "sarah@example.com") + .param("search", "pasta") + .param("effort", "easy") + .param("isChildFriendly", "true") + .param("cookTimeMin.lte", "30") + .param("sort", "-cookTimeMin") + .param("limit", "10") + .param("offset", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + } + + @Test + void getRecipeShouldReturn200WithDetail() throws Exception { + var detail = sampleDetail(); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.getRecipe(HOUSEHOLD_ID, RECIPE_ID)).thenReturn(detail); + + mockMvc.perform(get("/v1/recipes/{id}", RECIPE_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Spaghetti Bolognese")) + .andExpect(jsonPath("$.ingredients[0].name").value("spaghetti")) + .andExpect(jsonPath("$.steps[0].instruction").value("Boil water.")) + .andExpect(jsonPath("$.tags[0].name").value("beef")); + } + + @Test + void getRecipeShouldReturn404WhenNotFound() throws Exception { + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.getRecipe(HOUSEHOLD_ID, RECIPE_ID)) + .thenThrow(new ResourceNotFoundException("Recipe not found")); + + mockMvc.perform(get("/v1/recipes/{id}", RECIPE_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isNotFound()); + } + + @Test + void createRecipeShouldReturn201() throws Exception { + var request = sampleCreateRequest(); + var detail = sampleDetail(); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.createRecipe(eq(HOUSEHOLD_ID), any(RecipeCreateRequest.class))) + .thenReturn(detail); + + mockMvc.perform(post("/v1/recipes") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Spaghetti Bolognese")) + .andExpect(header().string("Location", "/v1/recipes/" + RECIPE_ID)); + } + + @Test + void updateRecipeShouldReturn200() throws Exception { + var request = sampleCreateRequest(); + var detail = sampleDetail(); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.updateRecipe(eq(HOUSEHOLD_ID), eq(RECIPE_ID), any(RecipeCreateRequest.class))) + .thenReturn(detail); + + mockMvc.perform(put("/v1/recipes/{id}", RECIPE_ID) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Spaghetti Bolognese")); + } + + @Test + void deleteRecipeShouldReturn204() throws Exception { + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + doNothing().when(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID); + + mockMvc.perform(delete("/v1/recipes/{id}", RECIPE_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isNoContent()); + + verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID); + } + + private RecipeCreateRequest sampleCreateRequest() { + var ingredientId = UUID.randomUUID(); + return new RecipeCreateRequest( + "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null, + List.of(new RecipeCreateRequest.IngredientEntry( + ingredientId, null, new BigDecimal("400"), "g", (short) 1)), + List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")), + List.of(UUID.randomUUID(), UUID.randomUUID())); + } + + private RecipeDetailResponse sampleDetail() { + var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta"); + return new RecipeDetailResponse( + RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null, + List.of(new RecipeDetailResponse.IngredientItem( + UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)), + List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")), + List.of(new RecipeDetailResponse.TagItem(UUID.randomUUID(), "beef", "protein"))); + } +} diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java new file mode 100644 index 0000000..772a6ca --- /dev/null +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java @@ -0,0 +1,347 @@ +package com.recipeapp.recipe; + +import com.recipeapp.common.ConflictException; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.recipe.dto.*; +import com.recipeapp.recipe.entity.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.*; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RecipeServiceTest { + + @Mock private RecipeRepository recipeRepository; + @Mock private IngredientRepository ingredientRepository; + @Mock private TagRepository tagRepository; + @Mock private IngredientCategoryRepository ingredientCategoryRepository; + @Mock private HouseholdRepository householdRepository; + + @InjectMocks private RecipeServiceImpl recipeService; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + + private Household testHousehold() { + var h = new Household("Test family", null); + try { + var field = Household.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(h, HOUSEHOLD_ID); + } catch (Exception e) { throw new RuntimeException(e); } + return h; + } + + private Recipe testRecipe(Household household) { + var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true); + try { + var field = Recipe.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(r, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + return r; + } + + private Ingredient testIngredient(Household household, String name) { + var ing = new Ingredient(household, name, false); + try { + var field = Ingredient.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(ing, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + return ing; + } + + private Tag testTag(Household household, String name, String type) { + var tag = new Tag(household, name, type); + try { + var field = Tag.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(tag, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + return tag; + } + + // ── Recipe CRUD ── + + @Test + void getRecipeShouldReturnDetail() { + var household = testHousehold(); + var recipe = testRecipe(household); + var ingredient = testIngredient(household, "spaghetti"); + recipe.getIngredients().add(new RecipeIngredient(recipe, ingredient, new BigDecimal("400"), "g", (short) 1)); + recipe.getSteps().add(new RecipeStep(recipe, (short) 1, "Boil water.")); + var tag = testTag(household, "beef", "protein"); + recipe.getTags().add(tag); + + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipe.getId(), HOUSEHOLD_ID)) + .thenReturn(Optional.of(recipe)); + + RecipeDetailResponse result = recipeService.getRecipe(HOUSEHOLD_ID, recipe.getId()); + + assertThat(result.name()).isEqualTo("Spaghetti Bolognese"); + assertThat(result.ingredients()).hasSize(1); + assertThat(result.ingredients().getFirst().name()).isEqualTo("spaghetti"); + assertThat(result.steps()).hasSize(1); + assertThat(result.tags()).hasSize(1); + } + + @Test + void getRecipeShouldThrowWhenNotFound() { + var id = UUID.randomUUID(); + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(id, HOUSEHOLD_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recipeService.getRecipe(HOUSEHOLD_ID, id)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createRecipeShouldPersistWithIngredientsStepsTags() { + var household = testHousehold(); + var ingredient = testIngredient(household, "spaghetti"); + var tag = testTag(household, "beef", "protein"); + + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient)); + when(tagRepository.findAllById(List.of(tag.getId()))).thenReturn(List.of(tag)); + 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( + "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null, + List.of(new RecipeCreateRequest.IngredientEntry( + ingredient.getId(), null, new BigDecimal("400"), "g", (short) 1)), + List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")), + List.of(tag.getId())); + + RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request); + + assertThat(result.name()).isEqualTo("Spaghetti Bolognese"); + assertThat(result.id()).isNotNull(); + verify(recipeRepository).save(any(Recipe.class)); + } + + @Test + void createRecipeShouldCreateNewIngredientWhenNameProvided() { + var household = testHousehold(); + var tag = testTag(household, "beef", "protein"); + + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(ingredientRepository.save(any(Ingredient.class))).thenAnswer(i -> { + Ingredient ing = i.getArgument(0); + try { + var field = Ingredient.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(ing, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + return ing; + }); + when(tagRepository.findAllById(List.of(tag.getId()))).thenReturn(List.of(tag)); + 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( + "Carbonara", (short) 2, (short) 30, "medium", false, null, + List.of(new RecipeCreateRequest.IngredientEntry( + null, "pancetta", new BigDecimal("100"), "g", (short) 1)), + List.of(), + List.of(tag.getId())); + + RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request); + + assertThat(result.name()).isEqualTo("Carbonara"); + verify(ingredientRepository).save(any(Ingredient.class)); + } + + @Test + void updateRecipeShouldReplaceChildren() { + var household = testHousehold(); + var recipe = testRecipe(household); + var ingredient = testIngredient(household, "rice"); + var tag = testTag(household, "chicken", "protein"); + + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipe.getId(), HOUSEHOLD_ID)) + .thenReturn(Optional.of(recipe)); + when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient)); + when(tagRepository.findAllById(List.of(tag.getId()))).thenReturn(List.of(tag)); + when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0)); + + var request = new RecipeCreateRequest( + "Chicken Rice", (short) 3, (short) 25, "easy", true, null, + List.of(new RecipeCreateRequest.IngredientEntry( + ingredient.getId(), null, new BigDecimal("300"), "g", (short) 1)), + List.of(new RecipeCreateRequest.StepEntry((short) 1, "Cook rice.")), + List.of(tag.getId())); + + RecipeDetailResponse result = recipeService.updateRecipe(HOUSEHOLD_ID, recipe.getId(), request); + + assertThat(result.name()).isEqualTo("Chicken Rice"); + assertThat(result.serves()).isEqualTo((short) 3); + } + + @Test + void deleteRecipeShouldSoftDelete() { + var household = testHousehold(); + var recipe = testRecipe(household); + + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipe.getId(), HOUSEHOLD_ID)) + .thenReturn(Optional.of(recipe)); + + recipeService.deleteRecipe(HOUSEHOLD_ID, recipe.getId()); + + assertThat(recipe.getDeletedAt()).isNotNull(); + } + + // ── Ingredients ── + + @Test + void searchIngredientsShouldReturnMatches() { + var household = testHousehold(); + var ingredient = testIngredient(household, "chicken breast"); + + when(ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(HOUSEHOLD_ID, "chick")) + .thenReturn(List.of(ingredient)); + + List result = recipeService.searchIngredients(HOUSEHOLD_ID, "chick", null); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().name()).isEqualTo("chicken breast"); + } + + @Test + void patchIngredientShouldUpdateFields() { + var household = testHousehold(); + var ingredient = testIngredient(household, "olive oil"); + + when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient)); + + var request = new IngredientPatchRequest("extra virgin olive oil", true, null); + IngredientResponse result = recipeService.patchIngredient(HOUSEHOLD_ID, ingredient.getId(), request); + + assertThat(result.name()).isEqualTo("extra virgin olive oil"); + assertThat(result.isStaple()).isTrue(); + } + + // ── Tags ── + + @Test + void listTagsShouldReturnAll() { + var household = testHousehold(); + var tag = testTag(household, "chicken", "protein"); + + when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of(tag)); + + List result = recipeService.listTags(HOUSEHOLD_ID); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().name()).isEqualTo("chicken"); + } + + @Test + void createTagShouldPersist() { + var household = testHousehold(); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(tagRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "Thai")).thenReturn(false); + when(tagRepository.save(any(Tag.class))).thenAnswer(i -> { + Tag t = i.getArgument(0); + try { + var field = Tag.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(t, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + return t; + }); + + TagResponse result = recipeService.createTag(HOUSEHOLD_ID, new TagCreateRequest("Thai", "cuisine")); + + assertThat(result.name()).isEqualTo("Thai"); + assertThat(result.tagType()).isEqualTo("cuisine"); + } + + @Test + void createTagShouldThrowConflictWhenNameExists() { + when(tagRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "Chicken")).thenReturn(true); + + assertThatThrownBy(() -> recipeService.createTag(HOUSEHOLD_ID, new TagCreateRequest("Chicken", "protein"))) + .isInstanceOf(ConflictException.class); + } + + // ── Ingredient Categories ── + + @Test + void listCategoriesShouldReturnAllSorted() { + var household = testHousehold(); + var cat = new IngredientCategory(household, "Produce", (short) 1); + try { + var field = IngredientCategory.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(cat, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + + when(ingredientCategoryRepository.findByHouseholdIdOrderBySortOrder(HOUSEHOLD_ID)) + .thenReturn(List.of(cat)); + + List result = recipeService.listCategories(HOUSEHOLD_ID); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().name()).isEqualTo("Produce"); + } + + @Test + void createCategoryShouldPersist() { + var household = testHousehold(); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "Frozen")) + .thenReturn(false); + when(ingredientCategoryRepository.countByHouseholdId(HOUSEHOLD_ID)).thenReturn(8L); + when(ingredientCategoryRepository.save(any(IngredientCategory.class))).thenAnswer(i -> { + IngredientCategory c = i.getArgument(0); + try { + var field = IngredientCategory.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(c, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + return c; + }); + + IngredientCategoryResponse result = recipeService.createCategory( + HOUSEHOLD_ID, new IngredientCategoryCreateRequest("Frozen")); + + assertThat(result.name()).isEqualTo("Frozen"); + } + + @Test + void createCategoryShouldThrowConflictWhenNameExists() { + when(ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "Produce")) + .thenReturn(true); + + assertThatThrownBy(() -> recipeService.createCategory( + HOUSEHOLD_ID, new IngredientCategoryCreateRequest("Produce"))) + .isInstanceOf(ConflictException.class); + } +} diff --git a/backend/src/test/java/com/recipeapp/recipe/TagControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/TagControllerTest.java new file mode 100644 index 0000000..7022293 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/recipe/TagControllerTest.java @@ -0,0 +1,76 @@ +package com.recipeapp.recipe; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.recipe.dto.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class TagControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock private RecipeService recipeService; + @Mock private HouseholdResolver householdResolver; + + @InjectMocks private TagController tagController; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(tagController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void listTagsShouldReturn200() throws Exception { + var tag = new TagResponse(UUID.randomUUID(), "chicken", "protein"); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.listTags(HOUSEHOLD_ID)).thenReturn(List.of(tag)); + + mockMvc.perform(get("/v1/tags") + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("chicken")) + .andExpect(jsonPath("$[0].tagType").value("protein")); + } + + @Test + void createTagShouldReturn201() throws Exception { + var request = new TagCreateRequest("Thai", "cuisine"); + var response = new TagResponse(UUID.randomUUID(), "Thai", "cuisine"); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(recipeService.createTag(eq(HOUSEHOLD_ID), any(TagCreateRequest.class))) + .thenReturn(response); + + mockMvc.perform(post("/v1/tags") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Thai")) + .andExpect(jsonPath("$.tagType").value("cuisine")); + } +} diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java new file mode 100644 index 0000000..6f6d181 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingListControllerTest.java @@ -0,0 +1,148 @@ +package com.recipeapp.shopping; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.common.ValidationException; +import com.recipeapp.recipe.HouseholdResolver; +import com.recipeapp.shopping.dto.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class ShoppingListControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Mock private ShoppingService shoppingService; + @Mock private HouseholdResolver householdResolver; + + @InjectMocks private ShoppingListController shoppingListController; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + private static final UUID USER_ID = UUID.randomUUID(); + private static final UUID LIST_ID = UUID.randomUUID(); + private static final UUID ITEM_ID = UUID.randomUUID(); + private static final UUID PLAN_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(shoppingListController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void generateFromPlanShouldReturn201() throws Exception { + var item = new ShoppingListItemResponse( + ITEM_ID, UUID.randomUUID(), "Tomatoes", + new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"), + new BigDecimal("4.00"), "pcs", false, null, List.of(UUID.randomUUID())); + var response = new ShoppingListResponse(LIST_ID, PLAN_ID, "draft", null, List.of(item)); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response); + + mockMvc.perform(post("/v1/week-plans/{id}/shopping-list", PLAN_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(LIST_ID.toString())) + .andExpect(jsonPath("$.status").value("draft")) + .andExpect(jsonPath("$.items[0].name").value("Tomatoes")); + } + + @Test + void getShoppingListShouldReturn200() throws Exception { + var response = new ShoppingListResponse(LIST_ID, PLAN_ID, "draft", null, List.of()); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response); + + mockMvc.perform(get("/v1/shopping-lists/{id}", LIST_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(LIST_ID.toString())) + .andExpect(jsonPath("$.weekPlanId").value(PLAN_ID.toString())); + } + + @Test + void publishShouldReturn200() throws Exception { + var now = Instant.now(); + var response = new PublishResponse(LIST_ID, "published", now); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(shoppingService.publish(HOUSEHOLD_ID, LIST_ID)).thenReturn(response); + + mockMvc.perform(post("/v1/shopping-lists/{id}/publish", LIST_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("published")); + } + + @Test + void checkItemShouldReturn200() throws Exception { + var response = new ShoppingListItemResponse( + ITEM_ID, UUID.randomUUID(), "Tomatoes", null, + new BigDecimal("4.00"), "pcs", true, USER_ID, List.of()); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID); + when(shoppingService.checkItem(eq(HOUSEHOLD_ID), eq(LIST_ID), eq(ITEM_ID), + any(CheckItemRequest.class), eq(USER_ID))).thenReturn(response); + + mockMvc.perform(patch("/v1/shopping-lists/{listId}/items/{itemId}", LIST_ID, ITEM_ID) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new CheckItemRequest(true)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isChecked").value(true)) + .andExpect(jsonPath("$.checkedBy").value(USER_ID.toString())); + } + + @Test + void addItemShouldReturn201() throws Exception { + var response = new ShoppingListItemResponse( + ITEM_ID, null, "Paper towels", null, + new BigDecimal("1"), "", false, null, List.of()); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class))) + .thenReturn(response); + + mockMvc.perform(post("/v1/shopping-lists/{id}/items", LIST_ID) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new AddItemRequest(null, "Paper towels", new BigDecimal("1"), "")))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Paper towels")); + } + + @Test + void deleteItemShouldReturn204() throws Exception { + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + doNothing().when(shoppingService).deleteItem(HOUSEHOLD_ID, LIST_ID, ITEM_ID); + + mockMvc.perform(delete("/v1/shopping-lists/{listId}/items/{itemId}", LIST_ID, ITEM_ID) + .principal(() -> "sarah@example.com")) + .andExpect(status().isNoContent()); + } +} diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java new file mode 100644 index 0000000..1da56eb --- /dev/null +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java @@ -0,0 +1,285 @@ +package com.recipeapp.shopping; + +import com.recipeapp.auth.UserAccountRepository; +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ValidationException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.planning.WeekPlanRepository; +import com.recipeapp.planning.entity.WeekPlan; +import com.recipeapp.planning.entity.WeekPlanSlot; +import com.recipeapp.recipe.IngredientRepository; +import com.recipeapp.recipe.entity.Ingredient; +import com.recipeapp.recipe.entity.IngredientCategory; +import com.recipeapp.recipe.entity.Recipe; +import com.recipeapp.recipe.entity.RecipeIngredient; +import com.recipeapp.shopping.dto.*; +import com.recipeapp.shopping.entity.ShoppingList; +import com.recipeapp.shopping.entity.ShoppingListItem; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ShoppingServiceTest { + + @Mock private ShoppingListRepository shoppingListRepository; + @Mock private ShoppingListItemRepository shoppingListItemRepository; + @Mock private WeekPlanRepository weekPlanRepository; + @Mock private HouseholdRepository householdRepository; + @Mock private IngredientRepository ingredientRepository; + @Mock private UserAccountRepository userAccountRepository; + + @InjectMocks private ShoppingServiceImpl shoppingService; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + private static final LocalDate WEEK_START = LocalDate.of(2026, 4, 6); + + private Household testHousehold() { + var h = new Household("Test family", null); + setId(h, Household.class, HOUSEHOLD_ID); + return h; + } + + private WeekPlan testWeekPlan(Household household) { + var wp = new WeekPlan(household, WEEK_START); + setId(wp, WeekPlan.class, UUID.randomUUID()); + return wp; + } + + private Recipe testRecipe(Household household, String name) { + var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true); + setId(r, Recipe.class, UUID.randomUUID()); + return r; + } + + private Ingredient testIngredient(Household household, String name, boolean staple) { + var i = new Ingredient(household, name, staple); + setId(i, Ingredient.class, UUID.randomUUID()); + return i; + } + + private ShoppingList testShoppingList(Household household, WeekPlan weekPlan) { + var sl = new ShoppingList(household, weekPlan); + setId(sl, ShoppingList.class, UUID.randomUUID()); + return sl; + } + + private ShoppingListItem testItem(ShoppingList list, Ingredient ingredient, + BigDecimal quantity, String unit) { + var item = new ShoppingListItem(list, ingredient, null, quantity, unit, new UUID[0]); + setId(item, ShoppingListItem.class, UUID.randomUUID()); + return item; + } + + private void setId(T entity, Class clazz, UUID id) { + try { + var field = clazz.getDeclaredField("id"); + field.setAccessible(true); + field.set(entity, id); + } catch (Exception e) { throw new RuntimeException(e); } + } + + // ── Generate ── + + @Test + void generateFromPlanShouldMergeIngredientsAndFilterStaples() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var recipe1 = testRecipe(household, "Spaghetti"); + var recipe2 = testRecipe(household, "Pizza"); + + var tomato = testIngredient(household, "Tomatoes", false); + var salt = testIngredient(household, "Salt", true); + var cheese = testIngredient(household, "Cheese", false); + + // Recipe 1: 2 tomatoes + salt + recipe1.getIngredients().add(new RecipeIngredient(recipe1, tomato, new BigDecimal("2.00"), "pcs", (short) 1)); + recipe1.getIngredients().add(new RecipeIngredient(recipe1, salt, new BigDecimal("1.00"), "tsp", (short) 2)); + + // Recipe 2: 3 tomatoes + cheese + recipe2.getIngredients().add(new RecipeIngredient(recipe2, tomato, new BigDecimal("3.00"), "pcs", (short) 1)); + recipe2.getIngredients().add(new RecipeIngredient(recipe2, cheese, new BigDecimal("200.00"), "g", (short) 2)); + + var slot1 = new WeekPlanSlot(plan, recipe1, WEEK_START); + setId(slot1, WeekPlanSlot.class, UUID.randomUUID()); + var slot2 = new WeekPlanSlot(plan, recipe2, WEEK_START.plusDays(1)); + setId(slot2, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot1); + plan.getSlots().add(slot2); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> { + ShoppingList sl = i.getArgument(0); + setId(sl, ShoppingList.class, UUID.randomUUID()); + return sl; + }); + + ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.status()).isEqualTo("draft"); + assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered) + + var tomatoItem = result.items().stream() + .filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow(); + assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3 + assertThat(tomatoItem.sourceRecipes()).hasSize(2); + + var cheeseItem = result.items().stream() + .filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow(); + assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00")); + } + + @Test + void generateFromPlanShouldThrowWhenPlanNotFound() { + var planId = UUID.randomUUID(); + when(weekPlanRepository.findById(planId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> shoppingService.generateFromPlan(HOUSEHOLD_ID, planId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── Get ── + + @Test + void getShoppingListShouldReturnListWithItems() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + var ingredient = testIngredient(household, "Tomatoes", false); + var item = testItem(list, ingredient, new BigDecimal("5.00"), "pcs"); + list.getItems().add(item); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + + ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId()); + + assertThat(result.id()).isEqualTo(list.getId()); + assertThat(result.items()).hasSize(1); + assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes"); + } + + // ── Publish ── + + @Test + void publishShouldSetStatusAndTimestamp() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0)); + + PublishResponse result = shoppingService.publish(HOUSEHOLD_ID, list.getId()); + + assertThat(result.status()).isEqualTo("published"); + assertThat(result.publishedAt()).isNotNull(); + } + + @Test + void publishShouldThrowWhenAlreadyPublished() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + list.setStatus("published"); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + + assertThatThrownBy(() -> shoppingService.publish(HOUSEHOLD_ID, list.getId())) + .isInstanceOf(ValidationException.class); + } + + // ── Check Item ── + + @Test + void checkItemShouldSetCheckedAndUser() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + var ingredient = testIngredient(household, "Tomatoes", false); + var item = testItem(list, ingredient, new BigDecimal("5.00"), "pcs"); + list.getItems().add(item); + + var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); + setId(user, UserAccount.class, UUID.randomUUID()); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + when(userAccountRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(shoppingListItemRepository.save(any(ShoppingListItem.class))).thenAnswer(i -> i.getArgument(0)); + + ShoppingListItemResponse result = shoppingService.checkItem( + HOUSEHOLD_ID, list.getId(), item.getId(), new CheckItemRequest(true), user.getId()); + + assertThat(result.isChecked()).isTrue(); + assertThat(result.checkedBy()).isEqualTo(user.getId()); + } + + // ── Add Item ── + + @Test + void addItemShouldCreateCustomItem() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + when(shoppingListItemRepository.save(any(ShoppingListItem.class))).thenAnswer(i -> { + ShoppingListItem si = i.getArgument(0); + setId(si, ShoppingListItem.class, UUID.randomUUID()); + return si; + }); + + ShoppingListItemResponse result = shoppingService.addItem( + HOUSEHOLD_ID, list.getId(), + new AddItemRequest(null, "Paper towels", new BigDecimal("1"), "")); + + assertThat(result.name()).isEqualTo("Paper towels"); + assertThat(result.ingredientId()).isNull(); + } + + // ── Delete Item ── + + @Test + void deleteItemShouldRemoveItem() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + var ingredient = testIngredient(household, "Tomatoes", false); + var item = testItem(list, ingredient, new BigDecimal("5.00"), "pcs"); + list.getItems().add(item); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + + shoppingService.deleteItem(HOUSEHOLD_ID, list.getId(), item.getId()); + + verify(shoppingListItemRepository).delete(item); + assertThat(list.getItems()).isEmpty(); + } + + @Test + void deleteItemShouldThrowWhenListIsPublished() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + list.setStatus("published"); + var ingredient = testIngredient(household, "Tomatoes", false); + var item = testItem(list, ingredient, new BigDecimal("5.00"), "pcs"); + list.getItems().add(item); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + + assertThatThrownBy(() -> shoppingService.deleteItem(HOUSEHOLD_ID, list.getId(), item.getId())) + .isInstanceOf(ValidationException.class); + } +}