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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<AdminAuditLog, UUID> {
|
||||
|
||||
List<AdminAuditLog> findByTargetUserIdOrderByPerformedAtDesc(UUID targetUserId, Pageable pageable);
|
||||
|
||||
List<AdminAuditLog> findAllByOrderByPerformedAtDesc(Pageable pageable);
|
||||
|
||||
long countByTargetUserId(UUID targetUserId);
|
||||
}
|
||||
@@ -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<ApiResponse<List<AdminUserResponse>>> 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<ApiResponse<AdminUserResponse>> 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<ApiResponse<AdminUserResponse>> 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<ApiResponse<ResetPasswordResponse>> 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<ApiResponse<List<AuditLogResponse>>> 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));
|
||||
}
|
||||
}
|
||||
21
backend/src/main/java/com/recipeapp/admin/AdminService.java
Normal file
21
backend/src/main/java/com/recipeapp/admin/AdminService.java
Normal file
@@ -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<AuditLogResponse> listAuditLog(UUID targetUserId, int limit, int offset);
|
||||
|
||||
record ListUsersResult(List<AdminUserResponse> users, long total) {}
|
||||
}
|
||||
166
backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java
Normal file
166
backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java
Normal file
@@ -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<String> actions = new ArrayList<>();
|
||||
Map<String, Object> 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<String, Object> 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<AuditLogResponse> listAuditLog(UUID targetUserId, int limit, int offset) {
|
||||
Pageable pageable = PageRequest.of(offset / limit, limit);
|
||||
List<AdminAuditLog> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<UserAccount, UUID> {
|
||||
|
||||
@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<UserAccount> 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);
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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<String, Object> detail,
|
||||
Instant performedAt
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.recipeapp.admin.dto;
|
||||
|
||||
public record ResetPasswordResponse(
|
||||
String message,
|
||||
boolean mustChangePassword
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.recipeapp.admin.dto;
|
||||
|
||||
public record UpdateUserRequest(
|
||||
String displayName,
|
||||
String email,
|
||||
String systemRole,
|
||||
Boolean isActive
|
||||
) {}
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> getDetail() { return detail; }
|
||||
public String getIpAddress() { return ipAddress; }
|
||||
public Instant getPerformedAt() { return performedAt; }
|
||||
}
|
||||
@@ -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<PantryItemResponse> listItems(Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return pantryService.listItems(householdId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<PantryItemResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<PantryItem, UUID> {
|
||||
|
||||
@Query("SELECT p FROM PantryItem p WHERE p.household.id = :householdId ORDER BY p.bestBefore ASC NULLS LAST")
|
||||
List<PantryItem> findByHouseholdIdOrderByBestBeforeAscNullsLast(UUID householdId);
|
||||
|
||||
Optional<PantryItem> findByIdAndHouseholdId(UUID id, UUID householdId);
|
||||
}
|
||||
@@ -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<PantryItemResponse> listItems(UUID householdId);
|
||||
|
||||
PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request);
|
||||
|
||||
PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request);
|
||||
|
||||
void deleteItem(UUID householdId, UUID itemId);
|
||||
}
|
||||
@@ -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<PantryItemResponse> 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<CookingLogResponse> 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<CookingLogResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<CookingLog, UUID> {
|
||||
List<CookingLog> findByHouseholdIdOrderByCookedOnDesc(UUID householdId, Pageable pageable);
|
||||
List<CookingLog> findByHouseholdIdAndCookedOnAfter(UUID householdId, LocalDate after);
|
||||
}
|
||||
@@ -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<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset);
|
||||
}
|
||||
@@ -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<UUID> usedRecipeIds = plan.getSlots().stream()
|
||||
.map(s -> s.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Collect proteins used in adjacent days
|
||||
Set<String> 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<UUID> 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<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
|
||||
householdId, slotDate.minusDays(14));
|
||||
Set<UUID> recentlyCookedIds = recentLogs.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Count effort levels in plan
|
||||
Map<String, Long> effortCounts = plan.getSlots().stream()
|
||||
.collect(Collectors.groupingBy(s -> s.getRecipe().getEffort(), Collectors.counting()));
|
||||
|
||||
// Get all household recipes, score and pick top 5
|
||||
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
|
||||
|
||||
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
|
||||
.filter(r -> !usedRecipeIds.contains(r.getId()))
|
||||
.map(recipe -> {
|
||||
List<String> fitReasons = new ArrayList<>();
|
||||
List<String> 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<WeekPlanSlot> slots = plan.getSlots();
|
||||
|
||||
if (slots.isEmpty()) {
|
||||
return new VarietyScoreResponse(0, List.of(), List.of(), Map.of());
|
||||
}
|
||||
|
||||
// Effort balance
|
||||
Map<String, Integer> effortBalance = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
effortBalance.merge(slot.getRecipe().getEffort(), 1, Integer::sum);
|
||||
}
|
||||
|
||||
// Ingredient overlaps (same ingredient on consecutive days)
|
||||
Map<String, List<LocalDate>> 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<VarietyScoreResponse.IngredientOverlap> 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<String, List<LocalDate>> 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<String> 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<CookingLogResponse> 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<SlotResponse> 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<LocalDate> days) {
|
||||
if (days.size() < 2) return false;
|
||||
List<LocalDate> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<WeekPlanResponse> 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<SlotResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<WeekPlan, UUID> {
|
||||
Optional<WeekPlan> findByHouseholdIdAndWeekStart(UUID householdId, LocalDate weekStart);
|
||||
boolean existsByHouseholdIdAndWeekStart(UUID householdId, LocalDate weekStart);
|
||||
}
|
||||
@@ -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<WeekPlanSlot, UUID> {
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.recipeapp.planning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateWeekPlanRequest(@NotNull LocalDate weekStart) {}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.recipeapp.planning.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SuggestionResponse(List<SuggestionItem> suggestions) {
|
||||
|
||||
public record SuggestionItem(
|
||||
SlotResponse.SlotRecipe recipe,
|
||||
List<String> fitReasons,
|
||||
List<String> warnings
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.recipeapp.planning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.UUID;
|
||||
|
||||
public record UpdateSlotRequest(@NotNull UUID recipeId) {}
|
||||
@@ -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<IngredientOverlap> ingredientOverlaps,
|
||||
List<String> proteinRepeats,
|
||||
Map<String, Integer> effortBalance
|
||||
) {
|
||||
public record IngredientOverlap(String ingredientName, List<LocalDate> days) {}
|
||||
}
|
||||
@@ -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<SlotResponse> slots
|
||||
) {}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<WeekPlanSlot> 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<WeekPlanSlot> getSlots() { return slots; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<IngredientCategoryResponse> listCategories(Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return recipeService.listCategories(householdId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<IngredientCategoryResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,6 @@ import java.util.UUID;
|
||||
|
||||
public interface IngredientCategoryRepository extends JpaRepository<IngredientCategory, UUID> {
|
||||
List<IngredientCategory> findByHouseholdIdOrderBySortOrder(UUID householdId);
|
||||
boolean existsByHouseholdIdAndNameIgnoreCase(UUID householdId, String name);
|
||||
long countByHouseholdId(UUID householdId);
|
||||
}
|
||||
|
||||
@@ -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<IngredientResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,7 @@ import java.util.UUID;
|
||||
|
||||
public interface IngredientRepository extends JpaRepository<Ingredient, UUID> {
|
||||
List<Ingredient> findByHouseholdId(UUID householdId);
|
||||
List<Ingredient> findByHouseholdIdAndNameContainingIgnoreCase(UUID householdId, String name);
|
||||
List<Ingredient> findByHouseholdIdAndIsStaple(UUID householdId, boolean isStaple);
|
||||
List<Ingredient> findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple(UUID householdId, String name, boolean isStaple);
|
||||
}
|
||||
|
||||
@@ -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<ApiResponse<List<RecipeSummaryResponse>>> 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<RecipeSummaryResponse> 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<RecipeDetailResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Recipe, UUID> {
|
||||
|
||||
Optional<Recipe> findByIdAndHouseholdIdAndDeletedAtIsNull(UUID id, UUID householdId);
|
||||
|
||||
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
|
||||
|
||||
@Query("""
|
||||
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
|
||||
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl)
|
||||
FROM Recipe r
|
||||
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<RecipeSummaryResponse> 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);
|
||||
}
|
||||
@@ -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<RecipeSummaryResponse> 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<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple);
|
||||
|
||||
IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request);
|
||||
|
||||
List<TagResponse> listTags(UUID householdId);
|
||||
|
||||
TagResponse createTag(UUID householdId, TagCreateRequest request);
|
||||
|
||||
List<IngredientCategoryResponse> listCategories(UUID householdId);
|
||||
|
||||
IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request);
|
||||
}
|
||||
@@ -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<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin,
|
||||
String sort, int limit, int offset) {
|
||||
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
|
||||
cookTimeMaxMin, sort, limit, offset);
|
||||
}
|
||||
|
||||
@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<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple) {
|
||||
List<Ingredient> 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<TagResponse> 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<IngredientCategoryResponse> 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<RecipeCreateRequest.IngredientEntry> 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<RecipeCreateRequest.StepEntry> 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<UUID> tagIds) {
|
||||
if (tagIds == null || tagIds.isEmpty()) return;
|
||||
List<Tag> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<TagResponse> listTags(Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return recipeService.listTags(householdId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<TagResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,5 @@ import java.util.UUID;
|
||||
|
||||
public interface TagRepository extends JpaRepository<Tag, UUID> {
|
||||
List<Tag> findByHouseholdId(UUID householdId);
|
||||
boolean existsByHouseholdIdAndNameIgnoreCase(UUID householdId, String name);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record IngredientCategoryResponse(UUID id, String name) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record IngredientPatchRequest(
|
||||
String name,
|
||||
Boolean isStaple,
|
||||
UUID categoryId
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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<IngredientEntry> ingredients,
|
||||
@Valid List<StepEntry> steps,
|
||||
@NotEmpty List<UUID> 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
|
||||
) {}
|
||||
}
|
||||
@@ -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<IngredientItem> ingredients,
|
||||
List<StepItem> steps,
|
||||
List<TagItem> 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) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.recipeapp.recipe.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record TagResponse(UUID id, String name, String tagType) {}
|
||||
112
backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java
Normal file
112
backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java
Normal file
@@ -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<RecipeIngredient> ingredients = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("stepNumber")
|
||||
private List<RecipeStep> steps = new ArrayList<>();
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(name = "recipe_tag",
|
||||
joinColumns = @JoinColumn(name = "recipe_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||
private Set<Tag> 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<RecipeIngredient> getIngredients() { return ingredients; }
|
||||
public List<RecipeStep> getSteps() { return steps; }
|
||||
public Set<Tag> getTags() { return tags; }
|
||||
public void setTags(Set<Tag> tags) { this.tags = tags; }
|
||||
|
||||
public boolean isDeleted() { return deletedAt != null; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<ShoppingListItem, UUID> {
|
||||
}
|
||||
@@ -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<ShoppingList, UUID> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String, MergedIngredient> merged = new LinkedHashMap<>();
|
||||
|
||||
for (var slot : weekPlan.getSlots()) {
|
||||
var recipe = slot.getRecipe();
|
||||
for (RecipeIngredient ri : recipe.getIngredients()) {
|
||||
Ingredient ingredient = ri.getIngredient();
|
||||
|
||||
// Filter out staples
|
||||
if (ingredient.isStaple()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = ingredient.getId().toString() + "|" + ri.getUnit();
|
||||
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<ShoppingListItemResponse> 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<UUID> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
public record CheckItemRequest(boolean isChecked) {}
|
||||
@@ -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) {}
|
||||
@@ -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<UUID> sourceRecipes
|
||||
) {
|
||||
public record CategoryRef(UUID id, String name) {}
|
||||
}
|
||||
@@ -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<ShoppingListItemResponse> items
|
||||
) {}
|
||||
@@ -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<ShoppingListItem> 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<ShoppingListItem> getItems() { return items; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
170
backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java
Normal file
170
backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java
Normal file
@@ -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 <T> void setId(T entity, Class<T> 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <T> void setId(T entity, Class<T> 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<PantryItemResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 <T> void setId(T entity, Class<T> 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<CookingLogResponse> result = planningService.listCookingLogs(HOUSEHOLD_ID, 30, 0);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.getFirst().recipeName()).isEqualTo("Spaghetti");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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<IngredientResponse> 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<TagResponse> 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<IngredientCategoryResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 <T> void setId(T entity, Class<T> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user