diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java b/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java index 728e56b..723a863 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java @@ -9,6 +9,8 @@ import com.recipeapp.household.dto.*; import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.HouseholdInvite; import com.recipeapp.household.entity.HouseholdMember; +import com.recipeapp.planning.VarietyScoreConfigRepository; +import com.recipeapp.planning.entity.VarietyScoreConfig; import com.recipeapp.recipe.IngredientCategoryRepository; import com.recipeapp.recipe.IngredientRepository; import com.recipeapp.recipe.TagRepository; @@ -32,6 +34,7 @@ public class HouseholdServiceImpl implements HouseholdService { private final IngredientRepository ingredientRepository; private final IngredientCategoryRepository ingredientCategoryRepository; private final TagRepository tagRepository; + private final VarietyScoreConfigRepository varietyScoreConfigRepository; private static final SecureRandom RANDOM = new SecureRandom(); private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; @@ -42,7 +45,8 @@ public class HouseholdServiceImpl implements HouseholdService { HouseholdInviteRepository householdInviteRepository, IngredientRepository ingredientRepository, IngredientCategoryRepository ingredientCategoryRepository, - TagRepository tagRepository) { + TagRepository tagRepository, + VarietyScoreConfigRepository varietyScoreConfigRepository) { this.userAccountRepository = userAccountRepository; this.householdRepository = householdRepository; this.householdMemberRepository = householdMemberRepository; @@ -50,6 +54,7 @@ public class HouseholdServiceImpl implements HouseholdService { this.ingredientRepository = ingredientRepository; this.ingredientCategoryRepository = ingredientCategoryRepository; this.tagRepository = tagRepository; + this.varietyScoreConfigRepository = varietyScoreConfigRepository; } @Override @@ -168,6 +173,8 @@ public class HouseholdServiceImpl implements HouseholdService { new Ingredient(household, "Flour", true), new Ingredient(household, "Rice", true), new Ingredient(household, "Pasta", true))); + + varietyScoreConfigRepository.save(VarietyScoreConfig.defaults(household)); } private String generateInviteCode() { diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index f2ac5f5..719188d 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -19,7 +19,8 @@ public interface PlanningService { WeekPlanResponse confirmPlan(UUID householdId, UUID planId); - SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate); + SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate, + List tagFilters, Integer topN); VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId); diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java b/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java index 40df2a8..6fe724f 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java @@ -32,19 +32,22 @@ public class PlanningServiceImpl implements PlanningService { private final RecipeRepository recipeRepository; private final HouseholdRepository householdRepository; private final UserAccountRepository userAccountRepository; + private final VarietyScoreConfigRepository varietyScoreConfigRepository; public PlanningServiceImpl(WeekPlanRepository weekPlanRepository, WeekPlanSlotRepository weekPlanSlotRepository, CookingLogRepository cookingLogRepository, RecipeRepository recipeRepository, HouseholdRepository householdRepository, - UserAccountRepository userAccountRepository) { + UserAccountRepository userAccountRepository, + VarietyScoreConfigRepository varietyScoreConfigRepository) { this.weekPlanRepository = weekPlanRepository; this.weekPlanSlotRepository = weekPlanSlotRepository; this.cookingLogRepository = cookingLogRepository; this.recipeRepository = recipeRepository; this.householdRepository = householdRepository; this.userAccountRepository = userAccountRepository; + this.varietyScoreConfigRepository = varietyScoreConfigRepository; } @Override @@ -117,89 +120,125 @@ public class PlanningServiceImpl implements PlanningService { @Override @Transactional(readOnly = true) - public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate) { + public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate, + List tagFilters, Integer topN) { WeekPlan plan = findPlan(planId, householdId); + int limit = (topN != null) ? topN : 5; + if (limit <= 0) { + return new SuggestionResponse(List.of()); + } + + VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId) + .orElse(VarietyScoreConfig.defaults(plan.getHousehold())); - // Collect recipes already in this plan Set usedRecipeIds = plan.getSlots().stream() .map(s -> s.getRecipe().getId()) .collect(Collectors.toSet()); - // Collect proteins used in adjacent days - Set adjacentProteins = new HashSet<>(); - for (WeekPlanSlot slot : plan.getSlots()) { - if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) { - for (Tag tag : slot.getRecipe().getTags()) { - if ("protein".equals(tag.getTagType())) { - adjacentProteins.add(tag.getName().toLowerCase()); - } - } - } - } - - // Collect ingredients used in adjacent days - Set adjacentIngredientIds = new HashSet<>(); - for (WeekPlanSlot slot : plan.getSlots()) { - if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) { - for (RecipeIngredient ri : slot.getRecipe().getIngredients()) { - adjacentIngredientIds.add(ri.getIngredient().getId()); - } - } - } - - // Recent cooking logs (last 14 days) - List recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter( - householdId, slotDate.minusDays(14)); - Set recentlyCookedIds = recentLogs.stream() + Set recentlyCookedIds = cookingLogRepository + .findByHouseholdIdAndCookedOnAfter(householdId, + plan.getWeekStart().minusDays(config.getHistoryDays())) + .stream() .map(cl -> cl.getRecipe().getId()) .collect(Collectors.toSet()); - // Count effort levels in plan - Map effortCounts = plan.getSlots().stream() - .collect(Collectors.groupingBy(s -> s.getRecipe().getEffort(), Collectors.counting())); - - // Get all household recipes, score and pick top 5 List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); + Set lowerTagFilters = tagFilters.stream() + .map(String::toLowerCase) + .collect(Collectors.toSet()); + List suggestions = allRecipes.stream() .filter(r -> !usedRecipeIds.contains(r.getId())) - .map(recipe -> { - List fitReasons = new ArrayList<>(); - List warnings = new ArrayList<>(); - - if (!recentlyCookedIds.contains(recipe.getId())) { - fitReasons.add("not_cooked_recently"); - } - - boolean hasProteinRepeat = recipe.getTags().stream() - .filter(t -> "protein".equals(t.getTagType())) - .anyMatch(t -> adjacentProteins.contains(t.getName().toLowerCase())); - if (!hasProteinRepeat) { - fitReasons.add("no_protein_repeat"); - } - - String effort = recipe.getEffort(); - long currentCount = effortCounts.getOrDefault(effort, 0L); - if (currentCount < 3) { - fitReasons.add("effort_balance"); - } - - boolean sharesIngredient = recipe.getIngredients().stream() - .anyMatch(ri -> adjacentIngredientIds.contains(ri.getIngredient().getId())); - if (sharesIngredient) { - warnings.add("shares_ingredient_with_adjacent_day"); - } - - return new SuggestionResponse.SuggestionItem( - toSlotRecipe(recipe), fitReasons, warnings); + .filter(r -> matchesAllTags(r, lowerTagFilters)) + .map(candidate -> { + double score = simulateVarietyScore( + plan, candidate, slotDate, config, recentlyCookedIds); + return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score); }) - .sorted((a, b) -> b.fitReasons().size() - a.fitReasons().size()) - .limit(5) + .sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore())) + .limit(limit) .toList(); return new SuggestionResponse(suggestions); } + private boolean matchesAllTags(Recipe recipe, Set lowerTagFilters) { + if (lowerTagFilters.isEmpty()) return true; + Set recipeTags = recipe.getTags().stream() + .map(t -> t.getName().toLowerCase()) + .collect(Collectors.toSet()); + return recipeTags.containsAll(lowerTagFilters); + } + + private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate, + VarietyScoreConfig config, Set recentlyCookedIds) { + // Build a simulated slot list: existing slots + candidate on slotDate + List simulatedSlots = new ArrayList<>(); + for (WeekPlanSlot slot : plan.getSlots()) { + simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate())); + } + simulatedSlots.add(new SimulatedSlot(candidate, slotDate)); + + List checkedTagTypes = config.getRepeatTagTypes(); + double wTagRepeat = config.getWTagRepeat().doubleValue(); + double wIngredientOverlap = config.getWIngredientOverlap().doubleValue(); + double wRecentRepeat = config.getWRecentRepeat().doubleValue(); + double wPlanDuplicate = config.getWPlanDuplicate().doubleValue(); + + // 1. Tag-type repeats on consecutive days + Map> tagDays = new LinkedHashMap<>(); + for (SimulatedSlot slot : simulatedSlots) { + for (Tag tag : slot.recipe.getTags()) { + if (checkedTagTypes.contains(tag.getTagType())) { + tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()) + .add(slot.date); + } + } + } + long tagRepeatCount = tagDays.values().stream() + .filter(this::hasConsecutiveDays) + .count(); + + // 2. Non-staple ingredient overlaps on consecutive days + Map> ingredientDays = new LinkedHashMap<>(); + for (SimulatedSlot slot : simulatedSlots) { + for (RecipeIngredient ri : slot.recipe.getIngredients()) { + if (!ri.getIngredient().isStaple()) { + ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>()) + .add(slot.date); + } + } + } + long ingredientOverlapCount = ingredientDays.values().stream() + .filter(this::hasConsecutiveDays) + .count(); + + // 3. Recent repeats from cooking log + long recentRepeatCount = simulatedSlots.stream() + .map(s -> s.recipe.getId()) + .distinct() + .filter(recentlyCookedIds::contains) + .count(); + + // 4. Duplicate recipes within the simulated plan + Map recipeCounts = simulatedSlots.stream() + .collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting())); + long duplicatePenaltyCount = recipeCounts.values().stream() + .filter(c -> c > 1) + .mapToLong(c -> c - 1) + .sum(); + + double score = 10.0; + score -= tagRepeatCount * wTagRepeat; + score -= ingredientOverlapCount * wIngredientOverlap; + score -= recentRepeatCount * wRecentRepeat; + score -= duplicatePenaltyCount * wPlanDuplicate; + return Math.max(0, Math.min(10, score)); + } + + private record SimulatedSlot(Recipe recipe, LocalDate date) {} + @Override @Transactional(readOnly = true) public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) { @@ -207,21 +246,45 @@ public class PlanningServiceImpl implements PlanningService { List slots = plan.getSlots(); if (slots.isEmpty()) { - return new VarietyScoreResponse(0, List.of(), List.of(), Map.of()); + return new VarietyScoreResponse(0, List.of(), List.of(), List.of(), List.of()); } - // Effort balance - Map effortBalance = new LinkedHashMap<>(); + // Load config (or use defaults) + VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId) + .orElse(VarietyScoreConfig.defaults(plan.getHousehold())); + + List checkedTagTypes = config.getRepeatTagTypes(); + double wTagRepeat = config.getWTagRepeat().doubleValue(); + double wIngredientOverlap = config.getWIngredientOverlap().doubleValue(); + double wRecentRepeat = config.getWRecentRepeat().doubleValue(); + double wPlanDuplicate = config.getWPlanDuplicate().doubleValue(); + int historyDays = config.getHistoryDays(); + + // 1. Tag-type repeats on consecutive days + Map tagDays = new LinkedHashMap<>(); for (WeekPlanSlot slot : slots) { - effortBalance.merge(slot.getRecipe().getEffort(), 1, Integer::sum); + for (Tag tag : slot.getRecipe().getTags()) { + if (checkedTagTypes.contains(tag.getTagType())) { + tagDays.computeIfAbsent(tag.getName(), + k -> new TagAccumulator(tag.getTagType())) + .addDay(slot.getSlotDate()); + } + } } + List tagRepeats = tagDays.entrySet().stream() + .filter(e -> hasConsecutiveDays(e.getValue().days)) + .map(e -> new VarietyScoreResponse.TagRepeat( + e.getKey(), e.getValue().tagType, e.getValue().days)) + .toList(); - // Ingredient overlaps (same ingredient on consecutive days) + // 2. Non-staple ingredient overlaps on consecutive days Map> ingredientDays = new LinkedHashMap<>(); for (WeekPlanSlot slot : slots) { for (RecipeIngredient ri : slot.getRecipe().getIngredients()) { - ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>()) - .add(slot.getSlotDate()); + if (!ri.getIngredient().isStaple()) { + ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>()) + .add(slot.getSlotDate()); + } } } List overlaps = ingredientDays.entrySet().stream() @@ -229,34 +292,57 @@ public class PlanningServiceImpl implements PlanningService { .map(e -> new VarietyScoreResponse.IngredientOverlap(e.getKey(), e.getValue())) .toList(); - // Protein repeats (same protein on consecutive days) - Map> proteinDays = new LinkedHashMap<>(); - for (WeekPlanSlot slot : slots) { - for (Tag tag : slot.getRecipe().getTags()) { - if ("protein".equals(tag.getTagType())) { - proteinDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()) - .add(slot.getSlotDate()); - } - } - } - List proteinRepeats = proteinDays.entrySet().stream() - .filter(e -> hasConsecutiveDays(e.getValue())) - .map(Map.Entry::getKey) + // 3. Recent repeats from cooking log + LocalDate referenceDate = plan.getWeekStart(); + List recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter( + householdId, referenceDate.minusDays(historyDays)); + Set recentlyCookedIds = recentLogs.stream() + .map(cl -> cl.getRecipe().getId()) + .collect(Collectors.toSet()); + + List recentRepeats = slots.stream() + .map(s -> s.getRecipe()) + .filter(r -> recentlyCookedIds.contains(r.getId())) + .map(Recipe::getName) + .distinct() .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; + // 4. Duplicate recipes within the plan + Map recipeCounts = slots.stream() + .collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting())); + + List duplicatesInPlan = new ArrayList<>(); + long duplicatePenaltyCount = 0; + for (var entry : recipeCounts.entrySet()) { + if (entry.getValue() > 1) { + String recipeName = slots.stream() + .filter(s -> s.getRecipe().getId().equals(entry.getKey())) + .findFirst() + .map(s -> s.getRecipe().getName()) + .orElse("Unknown"); + duplicatesInPlan.add(recipeName); + duplicatePenaltyCount += entry.getValue() - 1; + } } + + // Calculate score + double score = 10.0; + score -= tagRepeats.size() * wTagRepeat; + score -= overlaps.size() * wIngredientOverlap; + score -= recentRepeats.size() * wRecentRepeat; + score -= duplicatePenaltyCount * wPlanDuplicate; score = Math.max(0, Math.min(10, score)); - return new VarietyScoreResponse(score, overlaps, proteinRepeats, effortBalance); + return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan); + } + + private static class TagAccumulator { + final String tagType; + final List days = new ArrayList<>(); + + TagAccumulator(String tagType) { this.tagType = tagType; } + + void addDay(LocalDate day) { days.add(day); } } @Override diff --git a/backend/src/main/java/com/recipeapp/planning/VarietyScoreConfigRepository.java b/backend/src/main/java/com/recipeapp/planning/VarietyScoreConfigRepository.java new file mode 100644 index 0000000..eeca2a2 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/VarietyScoreConfigRepository.java @@ -0,0 +1,11 @@ +package com.recipeapp.planning; + +import com.recipeapp.planning.entity.VarietyScoreConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface VarietyScoreConfigRepository extends JpaRepository { + Optional findByHouseholdId(UUID householdId); +} diff --git a/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java index 305230b..0306fb0 100644 --- a/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java +++ b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.*; import java.security.Principal; import java.time.LocalDate; +import java.util.List; import java.util.UUID; @RestController @@ -78,9 +79,12 @@ public class WeekPlanController { public SuggestionResponse getSuggestions( Principal principal, @PathVariable UUID id, - @RequestParam LocalDate slotDate) { + @RequestParam LocalDate slotDate, + @RequestParam(required = false) List tags, + @RequestParam(required = false) Integer topN) { UUID householdId = householdResolver.resolve(principal.getName()); - return planningService.getSuggestions(householdId, id, slotDate); + return planningService.getSuggestions(householdId, id, slotDate, + tags != null ? tags : List.of(), topN); } @GetMapping("/{id}/variety-score") diff --git a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java index 660f20a..7c0f4ed 100644 --- a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java +++ b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java @@ -6,7 +6,6 @@ public record SuggestionResponse(List suggestions) { public record SuggestionItem( SlotResponse.SlotRecipe recipe, - List fitReasons, - List warnings + double simulatedScore ) {} } diff --git a/backend/src/main/java/com/recipeapp/planning/dto/VarietyScoreResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/VarietyScoreResponse.java index 569d88f..8ca2051 100644 --- a/backend/src/main/java/com/recipeapp/planning/dto/VarietyScoreResponse.java +++ b/backend/src/main/java/com/recipeapp/planning/dto/VarietyScoreResponse.java @@ -2,13 +2,14 @@ package com.recipeapp.planning.dto; import java.time.LocalDate; import java.util.List; -import java.util.Map; public record VarietyScoreResponse( double score, + List tagRepeats, List ingredientOverlaps, - List proteinRepeats, - Map effortBalance + List recentRepeats, + List duplicatesInPlan ) { + public record TagRepeat(String tagName, String tagType, List days) {} public record IngredientOverlap(String ingredientName, List days) {} } diff --git a/backend/src/main/java/com/recipeapp/planning/entity/VarietyScoreConfig.java b/backend/src/main/java/com/recipeapp/planning/entity/VarietyScoreConfig.java new file mode 100644 index 0000000..275c0ac --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/entity/VarietyScoreConfig.java @@ -0,0 +1,76 @@ +package com.recipeapp.planning.entity; + +import com.recipeapp.household.entity.Household; +import jakarta.persistence.*; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "variety_score_config") +public class VarietyScoreConfig { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "household_id", nullable = false, unique = true) + private Household household; + + @Column(name = "repeat_tag_types", nullable = false, columnDefinition = "text[]") + private String[] repeatTagTypes; + + @Column(name = "w_tag_repeat", nullable = false, precision = 3, scale = 1) + private BigDecimal wTagRepeat; + + @Column(name = "w_ingredient_overlap", nullable = false, precision = 3, scale = 1) + private BigDecimal wIngredientOverlap; + + @Column(name = "w_recent_repeat", nullable = false, precision = 3, scale = 1) + private BigDecimal wRecentRepeat; + + @Column(name = "w_plan_duplicate", nullable = false, precision = 3, scale = 1) + private BigDecimal wPlanDuplicate; + + @Column(name = "history_days", nullable = false) + private int historyDays; + + protected VarietyScoreConfig() {} + + public VarietyScoreConfig(Household household, String[] repeatTagTypes, + BigDecimal wTagRepeat, BigDecimal wIngredientOverlap, + BigDecimal wRecentRepeat, BigDecimal wPlanDuplicate, + int historyDays) { + this.household = household; + this.repeatTagTypes = repeatTagTypes; + this.wTagRepeat = wTagRepeat; + this.wIngredientOverlap = wIngredientOverlap; + this.wRecentRepeat = wRecentRepeat; + this.wPlanDuplicate = wPlanDuplicate; + this.historyDays = historyDays; + } + + public static VarietyScoreConfig defaults(Household household) { + return new VarietyScoreConfig( + household, + new String[]{"protein", "cuisine"}, + new BigDecimal("1.5"), + new BigDecimal("0.3"), + new BigDecimal("1.0"), + new BigDecimal("2.0"), + 14 + ); + } + + public UUID getId() { return id; } + public Household getHousehold() { return household; } + public List getRepeatTagTypes() { return Arrays.asList(repeatTagTypes); } + public BigDecimal getWTagRepeat() { return wTagRepeat; } + public BigDecimal getWIngredientOverlap() { return wIngredientOverlap; } + public BigDecimal getWRecentRepeat() { return wRecentRepeat; } + public BigDecimal getWPlanDuplicate() { return wPlanDuplicate; } + public int getHistoryDays() { return historyDays; } +} diff --git a/backend/src/main/resources/db/migration/V020__create_variety_score_config.sql b/backend/src/main/resources/db/migration/V020__create_variety_score_config.sql new file mode 100644 index 0000000..2204b84 --- /dev/null +++ b/backend/src/main/resources/db/migration/V020__create_variety_score_config.sql @@ -0,0 +1,11 @@ +CREATE TABLE variety_score_config ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + household_id UUID NOT NULL REFERENCES household(id) ON DELETE CASCADE, + repeat_tag_types TEXT[] NOT NULL DEFAULT '{protein,cuisine}', + w_tag_repeat NUMERIC(3,1) NOT NULL DEFAULT 1.5, + w_ingredient_overlap NUMERIC(3,1) NOT NULL DEFAULT 0.3, + w_recent_repeat NUMERIC(3,1) NOT NULL DEFAULT 1.0, + w_plan_duplicate NUMERIC(3,1) NOT NULL DEFAULT 2.0, + history_days INT NOT NULL DEFAULT 14, + UNIQUE(household_id) +); diff --git a/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java b/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java index 0c4f587..f94a3d0 100644 --- a/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java +++ b/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java @@ -167,4 +167,136 @@ class AdminServiceTest { assertEquals("create_account", result.getFirst().action()); assertEquals("admin@example.com", result.getFirst().adminEmail()); } + + @Test + void listAuditLog_withTargetUserIdFilter() { + var log = new AdminAuditLog(adminUser.getId(), targetUser.getId(), "update_account", Map.of(), null); + setId(log, AdminAuditLog.class, UUID.randomUUID()); + when(auditLogRepository.findByTargetUserIdOrderByPerformedAtDesc(eq(targetUser.getId()), 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(targetUser.getId(), 50, 0); + + assertEquals(1, result.size()); + assertEquals("update_account", result.getFirst().action()); + } + + @Test + void updateUser_changeEmail_success() { + when(userAccountRepository.findByEmailIgnoreCase(adminEmail)).thenReturn(Optional.of(adminUser)); + when(userAccountRepository.findById(targetUser.getId())).thenReturn(Optional.of(targetUser)); + when(userAccountRepository.existsByEmailIgnoreCase("newemail@example.com")).thenReturn(false); + 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, "newemail@example.com", null, null), adminEmail); + + assertEquals("newemail@example.com", result.email()); + } + + @Test + void updateUser_changeEmail_conflictWhenEmailExists() { + when(userAccountRepository.findByEmailIgnoreCase(adminEmail)).thenReturn(Optional.of(adminUser)); + when(userAccountRepository.findById(targetUser.getId())).thenReturn(Optional.of(targetUser)); + when(userAccountRepository.existsByEmailIgnoreCase("taken@example.com")).thenReturn(true); + + assertThrows(ConflictException.class, () -> + adminService.updateUser(targetUser.getId(), + new UpdateUserRequest(null, "taken@example.com", null, null), adminEmail)); + } + + @Test + void updateUser_changeSystemRole() { + 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, "admin", null), adminEmail); + + assertEquals("admin", result.systemRole()); + verify(auditLogRepository).save(argThat(log -> "change_system_role".equals(log.getAction()))); + } + + @Test + void updateUser_reactivateAccount() { + targetUser.setActive(false); + + 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, true), adminEmail); + + assertTrue(result.isActive()); + verify(auditLogRepository).save(argThat(log -> "reactivate_account".equals(log.getAction()))); + } + + @Test + void updateUser_notFound() { + var userId = UUID.randomUUID(); + when(userAccountRepository.findByEmailIgnoreCase(adminEmail)).thenReturn(Optional.of(adminUser)); + when(userAccountRepository.findById(userId)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> + adminService.updateUser(userId, + new UpdateUserRequest("Test", null, null, null), adminEmail)); + } + + @Test + void resetPassword_userNotFound() { + var userId = UUID.randomUUID(); + when(userAccountRepository.findByEmailIgnoreCase(adminEmail)).thenReturn(Optional.of(adminUser)); + when(userAccountRepository.findById(userId)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> + adminService.resetPassword(userId, + new ResetPasswordRequest("NewPass1!", null), adminEmail)); + } + + @Test + void resetPassword_withoutReason() { + 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!", null), adminEmail); + + assertTrue(result.mustChangePassword()); + } + + @Test + void createUser_adminNotFound() { + when(userAccountRepository.existsByEmailIgnoreCase("new@example.com")).thenReturn(false); + when(userAccountRepository.findByEmailIgnoreCase("ghost@example.com")).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> + adminService.createUser( + new CreateUserRequest("new@example.com", "New", "TempPass1!", "user"), + "ghost@example.com")); + } + + @Test + void updateUser_sameEmail_noConflictCheck() { + 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)); + + // Setting the same email (case-insensitive) should not trigger conflict check + var result = adminService.updateUser(targetUser.getId(), + new UpdateUserRequest(null, "jane@example.com", null, null), adminEmail); + + assertEquals("jane@example.com", result.email()); + verify(userAccountRepository, never()).existsByEmailIgnoreCase(anyString()); + } } diff --git a/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java b/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java index c6d3426..c8581c1 100644 --- a/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java +++ b/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java @@ -180,4 +180,70 @@ class AuthServiceTest { assertThatThrownBy(() -> authService.updateProfile("sarah@example.com", request)) .isInstanceOf(ValidationException.class); } + + @Test + void getCurrentUserShouldThrowWhenUserNotFound() { + when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.getCurrentUser("unknown@example.com")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getCurrentUserShouldReturnUserWithoutHousehold() { + var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); + + when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user)); + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.empty()); + + UserResponse result = authService.getCurrentUser("sarah@example.com"); + + assertThat(result.email()).isEqualTo("sarah@example.com"); + assertThat(result.householdId()).isNull(); + assertThat(result.householdName()).isNull(); + assertThat(result.householdRole()).isNull(); + } + + @Test + void updateProfileShouldThrowWhenUserNotFound() { + var request = new UpdateProfileRequest("New Name", null, null); + + when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.updateProfile("unknown@example.com", request)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void loginShouldReturnUserWithHouseholdInfo() { + var request = new LoginRequest("sarah@example.com", "s3cure!Pass"); + var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + + when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("s3cure!Pass", "hashed")).thenReturn(true); + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); + + UserResponse result = authService.login(request); + + assertThat(result.householdName()).isEqualTo("Smith family"); + assertThat(result.householdRole()).isEqualTo("planner"); + } + + @Test + void updateProfileShouldUpdateBothDisplayNameAndPassword() { + var request = new UpdateProfileRequest("Sarah S.", "oldpass", "newpassword"); + var user = new UserAccount("sarah@example.com", "Sarah", "hashed_old"); + + when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("oldpass", "hashed_old")).thenReturn(true); + when(passwordEncoder.encode("newpassword")).thenReturn("hashed_new"); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0)); + + UserResponse result = authService.updateProfile("sarah@example.com", request); + + assertThat(result.displayName()).isEqualTo("Sarah S."); + verify(passwordEncoder).encode("newpassword"); + } } diff --git a/backend/src/test/java/com/recipeapp/auth/CustomUserDetailsServiceTest.java b/backend/src/test/java/com/recipeapp/auth/CustomUserDetailsServiceTest.java new file mode 100644 index 0000000..4337998 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/auth/CustomUserDetailsServiceTest.java @@ -0,0 +1,73 @@ +package com.recipeapp.auth; + +import com.recipeapp.auth.entity.UserAccount; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomUserDetailsServiceTest { + + @Mock + private UserAccountRepository userAccountRepository; + + @InjectMocks + private CustomUserDetailsService userDetailsService; + + @Test + void shouldReturnUserDetailsForActiveUser() { + var user = new UserAccount("sarah@example.com", "Sarah", "hashed_pw"); + + when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user)); + + UserDetails details = userDetailsService.loadUserByUsername("sarah@example.com"); + + assertThat(details.getUsername()).isEqualTo("sarah@example.com"); + assertThat(details.getPassword()).isEqualTo("hashed_pw"); + assertThat(details.getAuthorities()).extracting("authority") + .containsExactly("ROLE_USER"); + } + + @Test + void shouldReturnAdminRoleForAdminUser() { + var user = new UserAccount("admin@example.com", "Admin", "hashed_pw"); + user.setSystemRole("admin"); + + when(userAccountRepository.findByEmailIgnoreCase("admin@example.com")).thenReturn(Optional.of(user)); + + UserDetails details = userDetailsService.loadUserByUsername("admin@example.com"); + + assertThat(details.getAuthorities()).extracting("authority") + .containsExactly("ROLE_ADMIN"); + } + + @Test + void shouldThrowWhenUserNotFound() { + when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userDetailsService.loadUserByUsername("unknown@example.com")) + .isInstanceOf(UsernameNotFoundException.class) + .hasMessageContaining("User not found"); + } + + @Test + void shouldThrowWhenAccountIsInactive() { + var user = new UserAccount("sarah@example.com", "Sarah", "hashed_pw"); + user.setActive(false); + + when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> userDetailsService.loadUserByUsername("sarah@example.com")) + .isInstanceOf(UsernameNotFoundException.class) + .hasMessageContaining("deactivated"); + } +} diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index 8baaf11..0f65c7f 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -9,6 +9,7 @@ import com.recipeapp.household.dto.*; import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.HouseholdInvite; import com.recipeapp.household.entity.HouseholdMember; +import com.recipeapp.planning.VarietyScoreConfigRepository; import com.recipeapp.recipe.IngredientCategoryRepository; import com.recipeapp.recipe.IngredientRepository; import com.recipeapp.recipe.TagRepository; @@ -36,6 +37,7 @@ class HouseholdServiceTest { @Mock private IngredientRepository ingredientRepository; @Mock private IngredientCategoryRepository ingredientCategoryRepository; @Mock private TagRepository tagRepository; + @Mock private VarietyScoreConfigRepository varietyScoreConfigRepository; @InjectMocks private HouseholdServiceImpl householdService; @@ -191,4 +193,67 @@ class HouseholdServiceTest { assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123")) .isInstanceOf(ConflictException.class); } + + @Test + void acceptInviteShouldThrowWhenInviteNotFound() { + var user = new UserAccount("tom@example.com", "Tom", "hashed"); + + when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); + when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); + when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "INVALID")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void acceptInviteShouldThrowWhenUserNotFound() { + when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.acceptInvite("unknown@example.com", "ABC12XYZ")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createHouseholdShouldThrowWhenUserNotFound() { + when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.createHousehold( + "unknown@example.com", new CreateHouseholdRequest("New"))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getMembersShouldReturnAllMembers() { + var user1 = testUser(); + var user2 = new UserAccount("tom@example.com", "Tom", "hashed"); + var household = new Household("Smith family", user1); + var member1 = new HouseholdMember(household, user1, "planner"); + var member2 = new HouseholdMember(household, user2, "member"); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member1)); + when(householdMemberRepository.findByHouseholdId(any())).thenReturn(List.of(member1, member2)); + + List result = householdService.getMembers("sarah@example.com"); + + assertThat(result).hasSize(2); + assertThat(result.get(0).displayName()).isEqualTo("Sarah"); + assertThat(result.get(1).displayName()).isEqualTo("Tom"); + } + + @Test + void getMembersShouldThrowWhenUserNotInHousehold() { + when(householdMemberRepository.findByUserEmailIgnoreCase("orphan@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.getMembers("orphan@example.com")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createInviteShouldThrowWhenUserNotInHousehold() { + when(householdMemberRepository.findByUserEmailIgnoreCase("orphan@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.createInvite("orphan@example.com")) + .isInstanceOf(ResourceNotFoundException.class); + } } diff --git a/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java b/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java index 5bc3b80..7563a14 100644 --- a/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java +++ b/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java @@ -178,4 +178,102 @@ class PantryServiceTest { assertThatThrownBy(() -> pantryService.deleteItem(HOUSEHOLD_ID, itemId)) .isInstanceOf(ResourceNotFoundException.class); } + + @Test + void createItemShouldThrowWhenHouseholdNotFound() { + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + + var request = new CreatePantryItemRequest(null, "Something", + new BigDecimal("1.00"), "pcs", null, null); + + assertThatThrownBy(() -> pantryService.createItem(HOUSEHOLD_ID, request)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createItemShouldThrowWhenIngredientNotFound() { + var household = testHousehold(); + var ingredientId = UUID.randomUUID(); + + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(ingredientRepository.findById(ingredientId)).thenReturn(Optional.empty()); + + var request = new CreatePantryItemRequest(ingredientId, null, + new BigDecimal("1.00"), "pcs", null, null); + + assertThatThrownBy(() -> pantryService.createItem(HOUSEHOLD_ID, request)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void updateItemShouldThrowWhenNotFound() { + var itemId = UUID.randomUUID(); + + when(pantryItemRepository.findByIdAndHouseholdId(itemId, HOUSEHOLD_ID)) + .thenReturn(Optional.empty()); + + var request = new UpdatePantryItemRequest(new BigDecimal("1.00"), null, null, null); + + assertThatThrownBy(() -> pantryService.updateItem(HOUSEHOLD_ID, itemId, request)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createItemWithBlankCustomNameShouldThrowValidation() { + var household = testHousehold(); + + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + + var request = new CreatePantryItemRequest(null, " ", + new BigDecimal("1.00"), "pcs", null, null); + + assertThatThrownBy(() -> pantryService.createItem(HOUSEHOLD_ID, request)) + .isInstanceOf(ValidationException.class); + } + + @Test + void updateItemShouldOnlyUpdateProvidedFields() { + 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(i -> i.getArgument(0)); + + var request = new UpdatePantryItemRequest(null, "bottles", null, null); + PantryItemResponse result = pantryService.updateItem(HOUSEHOLD_ID, item.getId(), request); + + assertThat(result.unit()).isEqualTo("bottles"); + assertThat(result.quantity()).isEqualByComparingTo(new BigDecimal("2.00")); // unchanged + } + + @Test + void listItemsShouldReturnEmptyListWhenNoneExist() { + when(pantryItemRepository.findByHouseholdIdOrderByBestBeforeAscNullsLast(HOUSEHOLD_ID)) + .thenReturn(List.of()); + + List result = pantryService.listItems(HOUSEHOLD_ID); + + assertThat(result).isEmpty(); + } + + @Test + void listItemsShouldHandleItemWithoutCategory() { + var household = testHousehold(); + var ingredient = new Ingredient(household, "Custom item", false); + setId(ingredient, Ingredient.class, UUID.randomUUID()); + // no category set + var item = new PantryItem(household, ingredient, null, + new BigDecimal("1.00"), "pcs", null, null); + setId(item, PantryItem.class, UUID.randomUUID()); + + when(pantryItemRepository.findByHouseholdIdOrderByBestBeforeAscNullsLast(HOUSEHOLD_ID)) + .thenReturn(List.of(item)); + + List result = pantryService.listItems(HOUSEHOLD_ID); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().category()).isNull(); + } } diff --git a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java index 00eb257..2a5a86d 100644 --- a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java +++ b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java @@ -35,6 +35,7 @@ class PlanningServiceTest { @Mock private RecipeRepository recipeRepository; @Mock private HouseholdRepository householdRepository; @Mock private UserAccountRepository userAccountRepository; + @Mock private VarietyScoreConfigRepository varietyScoreConfigRepository; @InjectMocks private PlanningServiceImpl planningService; @@ -277,4 +278,169 @@ class PlanningServiceTest { assertThat(result).hasSize(1); assertThat(result.getFirst().recipeName()).isEqualTo("Spaghetti"); } + + // ── Plan not found / household mismatch ── + + @Test + void getWeekPlanShouldThrowWhenPlanBelongsToDifferentHousehold() { + var otherHousehold = new Household("Other family", null); + setId(otherHousehold, Household.class, UUID.randomUUID()); + var plan = new WeekPlan(otherHousehold, WEEK_START); + setId(plan, WeekPlan.class, UUID.randomUUID()); + + // getWeekPlan uses findByHouseholdIdAndWeekStart so it won't find it for wrong household + when(weekPlanRepository.findByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.getWeekPlan(HOUSEHOLD_ID, WEEK_START)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void addSlotShouldThrowWhenPlanNotFound() { + var planId = UUID.randomUUID(); + when(weekPlanRepository.findById(planId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.addSlot(HOUSEHOLD_ID, planId, + new CreateSlotRequest(WEEK_START, UUID.randomUUID()))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void addSlotShouldThrowWhenPlanHouseholdMismatch() { + var otherHousehold = new Household("Other family", null); + setId(otherHousehold, Household.class, UUID.randomUUID()); + var plan = new WeekPlan(otherHousehold, WEEK_START); + setId(plan, WeekPlan.class, UUID.randomUUID()); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + + assertThatThrownBy(() -> planningService.addSlot(HOUSEHOLD_ID, plan.getId(), + new CreateSlotRequest(WEEK_START, UUID.randomUUID()))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void addSlotShouldThrowWhenRecipeNotFound() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var recipeId = UUID.randomUUID(); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, HOUSEHOLD_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.addSlot(HOUSEHOLD_ID, plan.getId(), + new CreateSlotRequest(WEEK_START, recipeId))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void updateSlotShouldThrowWhenSlotNotFound() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var slotId = UUID.randomUUID(); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(weekPlanSlotRepository.findById(slotId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.updateSlot(HOUSEHOLD_ID, plan.getId(), slotId, + new UpdateSlotRequest(UUID.randomUUID()))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void deleteSlotShouldThrowWhenSlotNotFound() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var slotId = UUID.randomUUID(); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(weekPlanSlotRepository.findById(slotId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.deleteSlot(HOUSEHOLD_ID, plan.getId(), slotId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void confirmPlanShouldThrowWhenPlanNotFound() { + var planId = UUID.randomUUID(); + when(weekPlanRepository.findById(planId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.confirmPlan(HOUSEHOLD_ID, planId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── Cooking log edge cases ── + + @Test + void createCookingLogShouldDefaultToTodayWhenCookedOnNull() { + 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(), null)); + + assertThat(result.cookedOn()).isEqualTo(LocalDate.now()); + } + + @Test + void createCookingLogShouldThrowWhenRecipeNotFound() { + var recipeId = UUID.randomUUID(); + when(recipeRepository.findById(recipeId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.createCookingLog(HOUSEHOLD_ID, UUID.randomUUID(), + new CreateCookingLogRequest(recipeId, LocalDate.now()))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createCookingLogShouldThrowWhenHouseholdNotFound() { + var household = testHousehold(); + var recipe = testRecipe(household, "Spaghetti"); + + when(recipeRepository.findById(recipe.getId())).thenReturn(Optional.of(recipe)); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.createCookingLog(HOUSEHOLD_ID, UUID.randomUUID(), + new CreateCookingLogRequest(recipe.getId(), LocalDate.now()))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createCookingLogShouldThrowWhenUserNotFound() { + var household = testHousehold(); + var recipe = testRecipe(household, "Spaghetti"); + var userId = UUID.randomUUID(); + + when(recipeRepository.findById(recipe.getId())).thenReturn(Optional.of(recipe)); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(userAccountRepository.findById(userId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.createCookingLog(HOUSEHOLD_ID, userId, + new CreateCookingLogRequest(recipe.getId(), LocalDate.now()))) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── Create week plan edge cases ── + + @Test + void createWeekPlanShouldThrowWhenHouseholdNotFound() { + when(weekPlanRepository.existsByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)).thenReturn(false); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START)) + .isInstanceOf(ResourceNotFoundException.class); + } } diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java new file mode 100644 index 0000000..ec5c2fd --- /dev/null +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -0,0 +1,865 @@ +package com.recipeapp.planning; + +import com.recipeapp.auth.UserAccountRepository; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.planning.dto.SuggestionResponse; +import com.recipeapp.planning.entity.*; +import com.recipeapp.recipe.RecipeRepository; +import com.recipeapp.recipe.entity.Ingredient; +import com.recipeapp.recipe.entity.Recipe; +import com.recipeapp.recipe.entity.RecipeIngredient; +import com.recipeapp.recipe.entity.Tag; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SuggestionsTest { + + @Mock private WeekPlanRepository weekPlanRepository; + @Mock private WeekPlanSlotRepository weekPlanSlotRepository; + @Mock private CookingLogRepository cookingLogRepository; + @Mock private RecipeRepository recipeRepository; + @Mock private HouseholdRepository householdRepository; + @Mock private UserAccountRepository userAccountRepository; + @Mock private VarietyScoreConfigRepository varietyScoreConfigRepository; + + private PlanningServiceImpl planningService; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + private static final LocalDate MONDAY = LocalDate.of(2026, 4, 6); + + private Household household; + + @BeforeEach + void setUp() { + planningService = new PlanningServiceImpl( + weekPlanRepository, weekPlanSlotRepository, cookingLogRepository, + recipeRepository, householdRepository, userAccountRepository, + varietyScoreConfigRepository); + household = createHousehold(); + } + + // ── Factory helpers ── + + private Household createHousehold() { + var h = new Household("Test family", null); + setId(h, Household.class, HOUSEHOLD_ID); + return h; + } + + private WeekPlan createPlan() { + var wp = new WeekPlan(household, MONDAY); + setId(wp, WeekPlan.class, UUID.randomUUID()); + return wp; + } + + private Recipe createRecipe(String name) { + var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true); + setId(r, Recipe.class, UUID.randomUUID()); + return r; + } + + private Tag createTag(String name, String tagType) { + var t = new Tag(household, name, tagType); + setId(t, Tag.class, UUID.randomUUID()); + return t; + } + + private Ingredient createIngredient(String name, boolean staple) { + var i = new Ingredient(household, name, staple); + setId(i, Ingredient.class, UUID.randomUUID()); + return i; + } + + private WeekPlanSlot addSlot(WeekPlan plan, Recipe recipe, LocalDate date) { + var slot = new WeekPlanSlot(plan, recipe, date); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + return slot; + } + + private void addIngredient(Recipe recipe, Ingredient ingredient) { + recipe.getIngredients().add(new RecipeIngredient( + recipe, ingredient, new BigDecimal("100"), "g", (short) 1)); + } + + private void addTag(Recipe recipe, Tag tag) { + recipe.getTags().add(tag); + } + + private void stubPlan(WeekPlan plan) { + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + } + + private void stubDefaultConfig() { + when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + } + + private void stubRecipes(Recipe... recipes) { + when(recipeRepository.findByHouseholdIdAndDeletedAtIsNull(HOUSEHOLD_ID)) + .thenReturn(List.of(recipes)); + } + + private void stubNoCookingLogs() { + when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any())) + .thenReturn(List.of()); + } + + private void stubCookingLogs(CookingLog... logs) { + when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any())) + .thenReturn(List.of(logs)); + } + + private CookingLog createCookingLog(Recipe recipe, LocalDate cookedOn) { + var log = new CookingLog(recipe, household, cookedOn, null); + setId(log, CookingLog.class, UUID.randomUUID()); + return log; + } + + private void stubConfig(VarietyScoreConfig config) { + when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.of(config)); + } + + private void setId(T entity, Class clazz, UUID id) { + try { + var field = clazz.getDeclaredField("id"); + field.setAccessible(true); + field.set(entity, id); + } catch (Exception e) { throw new RuntimeException(e); } + } + + // ═══════════════════════════════════════════════════════════ + // Category 1: Base Cases + // ═══════════════════════════════════════════════════════════ + + @Nested + class BaseCases { + + @Test + void emptyPlanNoRecipesShouldReturnEmptyList() { + var plan = createPlan(); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).isEmpty(); + } + + @Test + void emptyPlanWithRecipesShouldReturnAllWithPerfectScore() { + var plan = createPlan(); + var r1 = createRecipe("Pasta"); + var r2 = createRecipe("Salad"); + var r3 = createRecipe("Soup"); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(r1, r2, r3); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(3); + assertThat(result.suggestions()).allSatisfy(s -> + assertThat(s.simulatedScore()).isEqualTo(10.0)); + } + + @Test + void planNotFoundShouldThrow() { + UUID badPlanId = UUID.randomUUID(); + when(weekPlanRepository.findById(badPlanId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.getSuggestions( + HOUSEHOLD_ID, badPlanId, MONDAY, List.of(), 5)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void householdMismatchShouldThrow() { + UUID otherHouseholdId = UUID.randomUUID(); + var plan = createPlan(); + stubPlan(plan); + + assertThatThrownBy(() -> planningService.getSuggestions( + otherHouseholdId, plan.getId(), MONDAY, List.of(), 5)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void singleCandidateShouldReturnOne() { + var plan = createPlan(); + var recipe = createRecipe("Lasagna"); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(recipe); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + assertThat(result.suggestions().getFirst().recipe().name()).isEqualTo("Lasagna"); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 2: Exclusion of In-Plan Recipes + // ═══════════════════════════════════════════════════════════ + + @Nested + class ExclusionOfInPlanRecipes { + + @Test + void recipeAlreadyInPlanShouldBeExcluded() { + var plan = createPlan(); + var inPlan = createRecipe("Already Used"); + var candidate = createRecipe("Fresh Recipe"); + addSlot(plan, inPlan, MONDAY); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(inPlan, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + assertThat(result.suggestions().getFirst().recipe().name()).isEqualTo("Fresh Recipe"); + } + + @Test + void allRecipesInPlanShouldReturnEmptyList() { + var plan = createPlan(); + var r1 = createRecipe("Monday Meal"); + var r2 = createRecipe("Tuesday Meal"); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(r1, r2); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(2), List.of(), 5); + + assertThat(result.suggestions()).isEmpty(); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 3: Tag Filtering (AND logic) + // ═══════════════════════════════════════════════════════════ + + @Nested + class TagFiltering { + + @Test + void noTagFilterShouldReturnAllCandidates() { + var plan = createPlan(); + var r1 = createRecipe("A"); + var r2 = createRecipe("B"); + var r3 = createRecipe("C"); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(r1, r2, r3); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(3); + } + + @Test + void singleTagFilterShouldOnlyReturnMatches() { + var plan = createPlan(); + var quickTag = createTag("Quick meal", "other"); + var r1 = createRecipe("Quick Stir Fry"); + addTag(r1, quickTag); + var r2 = createRecipe("Slow Roast"); + var r3 = createRecipe("Quick Salad"); + addTag(r3, quickTag); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(r1, r2, r3); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of("Quick meal"), 5); + + assertThat(result.suggestions()).hasSize(2); + assertThat(result.suggestions()).extracting(s -> s.recipe().name()) + .containsExactlyInAnyOrder("Quick Stir Fry", "Quick Salad"); + } + + @Test + void multipleTagFiltersShouldUseAndLogic() { + var plan = createPlan(); + var quickTag = createTag("Quick meal", "other"); + var kidTag = createTag("Child-friendly", "other"); + var r1 = createRecipe("Quick Kid Pasta"); + addTag(r1, quickTag); + addTag(r1, kidTag); + var r2 = createRecipe("Quick Adult Curry"); + addTag(r2, quickTag); + var r3 = createRecipe("Slow Kid Stew"); + addTag(r3, kidTag); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(r1, r2, r3); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, + List.of("Quick meal", "Child-friendly"), 5); + + assertThat(result.suggestions()).hasSize(1); + assertThat(result.suggestions().getFirst().recipe().name()).isEqualTo("Quick Kid Pasta"); + } + + @Test + void noRecipesMatchFilterShouldReturnEmptyList() { + var plan = createPlan(); + var r1 = createRecipe("Regular Meal"); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(r1); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of("Vegan"), 5); + + assertThat(result.suggestions()).isEmpty(); + } + + @Test + void tagFilterShouldBeCaseInsensitive() { + var plan = createPlan(); + var quickTag = createTag("Quick meal", "other"); + var r1 = createRecipe("Quick Pasta"); + addTag(r1, quickTag); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(r1); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of("quick meal"), 5); + + assertThat(result.suggestions()).hasSize(1); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 4: Variety Score Simulation — Tag Repeats + // ═══════════════════════════════════════════════════════════ + + @Nested + class SimulationTagRepeats { + + @Test + void candidateAvoidingTagRepeatShouldRankHigher() { + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var existingRecipe = createRecipe("Monday Pasta"); + addTag(existingRecipe, pastaTag); + addSlot(plan, existingRecipe, MONDAY); + + // Candidate A: also has Pasta tag → will cause consecutive tag repeat + var candidateA = createRecipe("More Pasta"); + addTag(candidateA, pastaTag); + + // Candidate B: no cuisine tag → no repeat + var candidateB = createRecipe("Plain Rice"); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidateA, candidateB); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(2); + // B should rank higher (no tag penalty) + assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice"); + assertThat(result.suggestions().get(0).simulatedScore()) + .isGreaterThan(result.suggestions().get(1).simulatedScore()); + } + + @Test + void bothCandidatesCauseTagRepeatShouldRankEqually() { + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var existingRecipe = createRecipe("Monday Pasta"); + addTag(existingRecipe, pastaTag); + addSlot(plan, existingRecipe, MONDAY); + + var candidateA = createRecipe("Spaghetti"); + addTag(candidateA, pastaTag); + var candidateB = createRecipe("Penne"); + addTag(candidateB, pastaTag); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidateA, candidateB); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(2); + assertThat(result.suggestions().get(0).simulatedScore()) + .isEqualTo(result.suggestions().get(1).simulatedScore()); + } + + @Test + void tagTypeNotInConfigShouldNotPenalize() { + var plan = createPlan(); + var dietaryTag = createTag("Vegetarian", "dietary"); + var existingRecipe = createRecipe("Veggie Monday"); + addTag(existingRecipe, dietaryTag); + addSlot(plan, existingRecipe, MONDAY); + + // Candidate also has "Vegetarian/dietary" — but "dietary" is not in repeat_tag_types + var candidate = createRecipe("Veggie Tuesday"); + addTag(candidate, dietaryTag); + + stubPlan(plan); + stubDefaultConfig(); // default: ["protein", "cuisine"] + stubRecipes(existingRecipe, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + // No penalty — dietary not tracked + assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 5: Variety Score Simulation — Ingredient Overlaps + // ═══════════════════════════════════════════════════════════ + + @Nested + class SimulationIngredientOverlaps { + + @Test + void candidateSharingNonStapleIngredientShouldRankLower() { + var plan = createPlan(); + var tomato = createIngredient("Tomatoes", false); + var existingRecipe = createRecipe("Tomato Soup"); + addIngredient(existingRecipe, tomato); + addSlot(plan, existingRecipe, MONDAY); + + // Candidate A: also uses tomatoes → overlap on consecutive day + var candidateA = createRecipe("Tomato Pasta"); + addIngredient(candidateA, tomato); + + // Candidate B: different ingredients + var candidateB = createRecipe("Mushroom Risotto"); + var mushroom = createIngredient("Mushrooms", false); + addIngredient(candidateB, mushroom); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidateA, candidateB); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(2); + assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Mushroom Risotto"); + assertThat(result.suggestions().get(0).simulatedScore()) + .isGreaterThan(result.suggestions().get(1).simulatedScore()); + } + + @Test + void stapleIngredientsShouldBeIgnored() { + var plan = createPlan(); + var salt = createIngredient("Salt", true); + var oil = createIngredient("Olive oil", true); + var existingRecipe = createRecipe("Salted Something"); + addIngredient(existingRecipe, salt); + addIngredient(existingRecipe, oil); + addSlot(plan, existingRecipe, MONDAY); + + var candidate = createRecipe("Also Salted"); + addIngredient(candidate, salt); + addIngredient(candidate, oil); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 6: Variety Score Simulation — Cooking Log + // ═══════════════════════════════════════════════════════════ + + @Nested + class SimulationCookingLog { + + @Test + void recentlyCookedCandidateShouldRankLower() { + var plan = createPlan(); + var candidateA = createRecipe("Lasagna"); + var candidateB = createRecipe("Stir Fry"); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(candidateA, candidateB); + // Lasagna cooked 5 days ago + stubCookingLogs(createCookingLog(candidateA, MONDAY.minusDays(5))); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(2); + assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Stir Fry"); + assertThat(result.suggestions().get(0).simulatedScore()) + .isGreaterThan(result.suggestions().get(1).simulatedScore()); + } + + @Test + void candidateCookedOutsideWindowShouldNotBePenalized() { + var plan = createPlan(); + var candidate = createRecipe("Old Favorite"); + + stubPlan(plan); + stubDefaultConfig(); // history_days = 14 + stubRecipes(candidate); + // Cooked 20 days ago — outside 14-day window, so the DB query wouldn't return it + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 7: Ranking and Top N + // ═══════════════════════════════════════════════════════════ + + @Nested + class RankingAndTopN { + + @Test + void shouldLimitResultsToTopN() { + var plan = createPlan(); + var recipes = new Recipe[10]; + for (int i = 0; i < 10; i++) { + recipes[i] = createRecipe("Recipe " + i); + } + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(recipes); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 3); + + assertThat(result.suggestions()).hasSize(3); + } + + @Test + void defaultTopNShouldBeFive() { + var plan = createPlan(); + var recipes = new Recipe[10]; + for (int i = 0; i < 10; i++) { + recipes[i] = createRecipe("Recipe " + i); + } + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(recipes); + stubNoCookingLogs(); + + // Call without topN (uses default) + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), null); + + assertThat(result.suggestions()).hasSize(5); + } + + @Test + void fewerCandidatesThanNShouldReturnAll() { + var plan = createPlan(); + var r1 = createRecipe("A"); + var r2 = createRecipe("B"); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(r1, r2); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(2); + } + + @Test + void rankingOrderShouldBeBySimulatedScoreDescending() { + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var tomato = createIngredient("Tomatoes", false); + + var existingRecipe = createRecipe("Monday Meal"); + addTag(existingRecipe, pastaTag); + addIngredient(existingRecipe, tomato); + addSlot(plan, existingRecipe, MONDAY); + + // Worst: tag repeat + ingredient overlap + var worst = createRecipe("Tomato Pasta"); + addTag(worst, pastaTag); + addIngredient(worst, tomato); + + // Middle: tag repeat only + var middle = createRecipe("Dry Pasta"); + addTag(middle, pastaTag); + + // Best: no penalties + var best = createRecipe("Fresh Salad"); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, worst, middle, best); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(3); + assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Fresh Salad"); + assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Dry Pasta"); + assertThat(result.suggestions().get(2).recipe().name()).isEqualTo("Tomato Pasta"); + + // Verify scores are strictly descending + assertThat(result.suggestions().get(0).simulatedScore()) + .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(1).simulatedScore()) + .isGreaterThan(result.suggestions().get(2).simulatedScore()); + } + + @Test + void tiedCandidatesShouldBothBeReturned() { + var plan = createPlan(); + var r1 = createRecipe("Alpha"); + var r2 = createRecipe("Beta"); + // Both identical: no tags, no ingredients → same score + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(r1, r2); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(2); + assertThat(result.suggestions().get(0).simulatedScore()) + .isEqualTo(result.suggestions().get(1).simulatedScore()); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 8: Combined / Realistic + // ═══════════════════════════════════════════════════════════ + + @Nested + class CombinedRealistic { + + @Test + void realisticWeekWithMixedSignals() { + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var chickenTag = createTag("Chicken", "protein"); + var tomato = createIngredient("Tomatoes", false); + var cheese = createIngredient("Cheese", false); + var salt = createIngredient("Salt", true); + + // Plan: Mon=Chicken Pasta with tomatoes, Tue=Cheese thing, Wed=open + var monRecipe = createRecipe("Chicken Pasta"); + addTag(monRecipe, pastaTag); + addTag(monRecipe, chickenTag); + addIngredient(monRecipe, tomato); + addIngredient(monRecipe, salt); + addSlot(plan, monRecipe, MONDAY); + + var tueRecipe = createRecipe("Mac and Cheese"); + addIngredient(tueRecipe, cheese); + addSlot(plan, tueRecipe, MONDAY.plusDays(1)); + + // Candidate 1: Pasta + tomato + recently cooked → tag repeat + ingredient overlap + recent + var c1 = createRecipe("Tomato Spaghetti"); + addTag(c1, pastaTag); + addIngredient(c1, tomato); + + // Candidate 2: Chicken only → protein repeat with Mon + var c2 = createRecipe("Chicken Salad"); + addTag(c2, chickenTag); + + // Candidate 3: Cheese → ingredient overlap with Tue + var c3 = createRecipe("Cheese Omelette"); + addIngredient(c3, cheese); + + // Candidate 4: Clean — no overlaps + var c4 = createRecipe("Mushroom Risotto"); + var mushroom = createIngredient("Mushrooms", false); + addIngredient(c4, mushroom); + + // Candidate 5: Also clean + var c5 = createRecipe("Lentil Soup"); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(monRecipe, tueRecipe, c1, c2, c3, c4, c5); + // c1 was cooked recently + stubCookingLogs(createCookingLog(c1, MONDAY.minusDays(3))); + + // Slot date = Wednesday (adjacent to Tuesday) + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(2), List.of(), 5); + + assertThat(result.suggestions()).hasSize(5); + + // c2, c4, c5 all score 10.0 (no penalties — Chicken Mon→Wed not consecutive) + var topThree = result.suggestions().subList(0, 3); + assertThat(topThree).extracting(s -> s.recipe().name()) + .containsExactlyInAnyOrder("Chicken Salad", "Mushroom Risotto", "Lentil Soup"); + assertThat(topThree).allSatisfy(s -> assertThat(s.simulatedScore()).isEqualTo(10.0)); + + // c3 (Cheese Omelette) has ingredient overlap Tue→Wed: -0.3 + assertThat(result.suggestions().get(3).recipe().name()).isEqualTo("Cheese Omelette"); + assertThat(result.suggestions().get(3).simulatedScore()).isCloseTo(9.7, within(0.001)); + + // c1 (Tomato Spaghetti) has recent repeat: -1.0 + assertThat(result.suggestions().get(4).recipe().name()).isEqualTo("Tomato Spaghetti"); + assertThat(result.suggestions().get(4).simulatedScore()).isEqualTo(9.0); + } + + @Test + void tagFilterCombinedWithVarietyRanking() { + var plan = createPlan(); + var quickTag = createTag("Quick meal", "other"); + var pastaTag = createTag("Pasta", "cuisine"); + + var existingRecipe = createRecipe("Monday Pasta"); + addTag(existingRecipe, pastaTag); + addSlot(plan, existingRecipe, MONDAY); + + // Quick + pasta tag → will be penalized for cuisine repeat + var c1 = createRecipe("Quick Pasta"); + addTag(c1, quickTag); + addTag(c1, pastaTag); + + // Quick + no cuisine tag → no repeat penalty + var c2 = createRecipe("Quick Salad"); + addTag(c2, quickTag); + + // Not quick → filtered out + var c3 = createRecipe("Slow Roast"); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, c1, c2, c3); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), + List.of("Quick meal"), 5); + + // Only quick recipes, ranked by variety + assertThat(result.suggestions()).hasSize(2); + assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Quick Salad"); + assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Quick Pasta"); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 9: Edge Cases + // ═══════════════════════════════════════════════════════════ + + @Nested + class EdgeCases { + + @Test + void recipeWithNoTagsOrIngredientsShouldGetPerfectScore() { + var plan = createPlan(); + var existingRecipe = createRecipe("Existing"); + addSlot(plan, existingRecipe, MONDAY); + + var candidate = createRecipe("Bare Recipe"); + // No tags, no ingredients + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + } + + @Test + void slotDateNotInPlanWeekShouldStillWork() { + var plan = createPlan(); // week starts MONDAY (Apr 6) + var candidate = createRecipe("Future Meal"); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(candidate); + stubNoCookingLogs(); + + // Next week's Monday + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(14), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + } + + @Test + void topNZeroShouldReturnEmptyList() { + var plan = createPlan(); + stubPlan(plan); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 0); + + assertThat(result.suggestions()).isEmpty(); + } + } +} diff --git a/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java b/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java new file mode 100644 index 0000000..65e8029 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java @@ -0,0 +1,986 @@ +package com.recipeapp.planning; + +import com.recipeapp.auth.UserAccountRepository; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.household.HouseholdRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.planning.dto.VarietyScoreResponse; +import com.recipeapp.planning.entity.*; +import com.recipeapp.recipe.RecipeRepository; +import com.recipeapp.recipe.entity.Ingredient; +import com.recipeapp.recipe.entity.Recipe; +import com.recipeapp.recipe.entity.RecipeIngredient; +import com.recipeapp.recipe.entity.Tag; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VarietyScoreTest { + + @Mock private WeekPlanRepository weekPlanRepository; + @Mock private WeekPlanSlotRepository weekPlanSlotRepository; + @Mock private CookingLogRepository cookingLogRepository; + @Mock private RecipeRepository recipeRepository; + @Mock private HouseholdRepository householdRepository; + @Mock private UserAccountRepository userAccountRepository; + @Mock private VarietyScoreConfigRepository varietyScoreConfigRepository; + + private PlanningServiceImpl planningService; + + private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); + private static final LocalDate MONDAY = LocalDate.of(2026, 4, 6); + + private Household household; + + @BeforeEach + void setUp() { + planningService = new PlanningServiceImpl( + weekPlanRepository, weekPlanSlotRepository, cookingLogRepository, + recipeRepository, householdRepository, userAccountRepository, + varietyScoreConfigRepository); + household = createHousehold(); + } + + // ── Factory helpers ── + + private Household createHousehold() { + var h = new Household("Test family", null); + setId(h, Household.class, HOUSEHOLD_ID); + return h; + } + + private WeekPlan createPlan() { + var wp = new WeekPlan(household, MONDAY); + setId(wp, WeekPlan.class, UUID.randomUUID()); + return wp; + } + + private Recipe createRecipe(String name) { + var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true); + setId(r, Recipe.class, UUID.randomUUID()); + return r; + } + + private Tag createTag(String name, String tagType) { + var t = new Tag(household, name, tagType); + setId(t, Tag.class, UUID.randomUUID()); + return t; + } + + private Ingredient createIngredient(String name, boolean staple) { + var i = new Ingredient(household, name, staple); + setId(i, Ingredient.class, UUID.randomUUID()); + return i; + } + + private WeekPlanSlot addSlot(WeekPlan plan, Recipe recipe, LocalDate date) { + var slot = new WeekPlanSlot(plan, recipe, date); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + return slot; + } + + private void addIngredient(Recipe recipe, Ingredient ingredient) { + recipe.getIngredients().add(new RecipeIngredient( + recipe, ingredient, new BigDecimal("100"), "g", (short) 1)); + } + + private void addTag(Recipe recipe, Tag tag) { + recipe.getTags().add(tag); + } + + private void stubPlan(WeekPlan plan) { + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + } + + private void stubDefaultConfig() { + when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + } + + private void stubConfig(VarietyScoreConfig config) { + when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.of(config)); + } + + private void stubNoCookingLogs() { + when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any())) + .thenReturn(List.of()); + } + + private void stubCookingLogs(CookingLog... logs) { + when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any())) + .thenReturn(List.of(logs)); + } + + private CookingLog createCookingLog(Recipe recipe, LocalDate cookedOn) { + var log = new CookingLog(recipe, household, cookedOn, null); + setId(log, CookingLog.class, UUID.randomUUID()); + return log; + } + + private void setId(T entity, Class clazz, UUID id) { + try { + var field = clazz.getDeclaredField("id"); + field.setAccessible(true); + field.set(entity, id); + } catch (Exception e) { throw new RuntimeException(e); } + } + + // ═══════════════════════════════════════════════════════════ + // Category 1: Base Cases + // ═══════════════════════════════════════════════════════════ + + @Nested + class BaseCases { + + @Test + void emptyPlanShouldReturnZeroScore() { + var plan = createPlan(); + stubPlan(plan); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(0); + assertThat(result.tagRepeats()).isEmpty(); + assertThat(result.ingredientOverlaps()).isEmpty(); + assertThat(result.recentRepeats()).isEmpty(); + assertThat(result.duplicatesInPlan()).isEmpty(); + } + + @Test + void singleSlotShouldReturnPerfectScore() { + var plan = createPlan(); + addSlot(plan, createRecipe("Spaghetti"), MONDAY); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + } + + @Test + void planNotFoundShouldThrow() { + var planId = UUID.randomUUID(); + when(weekPlanRepository.findById(planId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.getVarietyScore(HOUSEHOLD_ID, planId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void householdMismatchShouldThrow() { + var otherHousehold = new Household("Other", null); + setId(otherHousehold, Household.class, UUID.randomUUID()); + var plan = new WeekPlan(otherHousehold, MONDAY); + setId(plan, WeekPlan.class, UUID.randomUUID()); + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + + assertThatThrownBy(() -> planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId())) + .isInstanceOf(ResourceNotFoundException.class); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 2: Tag-Type Repeats on Consecutive Days + // ═══════════════════════════════════════════════════════════ + + @Nested + class TagTypeRepeats { + + @Test + void noRepeatWhenGapDay() { + var plan = createPlan(); + var tag = createTag("Tofu", "base"); + var r1 = createRecipe("Tofu Monday"); + addTag(r1, tag); + var r2 = createRecipe("Tofu Wednesday"); + addTag(r2, tag); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(2)); // Wednesday — gap + stubPlan(plan); + stubConfig(new VarietyScoreConfig(household, + new String[]{"base"}, bd("1.5"), bd("0.3"), bd("1.0"), bd("2.0"), 14)); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.tagRepeats()).isEmpty(); + } + + @Test + void singleTagRepeatOnConsecutiveDays() { + var plan = createPlan(); + var tag = createTag("Chicken", "protein"); + var r1 = createRecipe("Chicken Monday"); + addTag(r1, tag); + var r2 = createRecipe("Chicken Tuesday"); + addTag(r2, tag); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(8.5); + assertThat(result.tagRepeats()).hasSize(1); + assertThat(result.tagRepeats().getFirst().tagName()).isEqualTo("Chicken"); + assertThat(result.tagRepeats().getFirst().tagType()).isEqualTo("protein"); + assertThat(result.tagRepeats().getFirst().days()).containsExactly(MONDAY, MONDAY.plusDays(1)); + } + + @Test + void multipleDifferentTagRepeats() { + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var tofuTag = createTag("Tofu", "base"); + var r1 = createRecipe("Pasta Tofu Mon"); + addTag(r1, pastaTag); + addTag(r1, tofuTag); + var r2 = createRecipe("Pasta Tofu Tue"); + addTag(r2, pastaTag); + addTag(r2, tofuTag); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubConfig(new VarietyScoreConfig(household, + new String[]{"cuisine", "base"}, bd("1.5"), bd("0.3"), bd("1.0"), bd("2.0"), 14)); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(7.0); + assertThat(result.tagRepeats()).hasSize(2); + } + + @Test + void sameTagOnNonConsecutiveDays() { + var plan = createPlan(); + var tag = createTag("Chicken", "protein"); + var r1 = createRecipe("Chicken Monday"); + addTag(r1, tag); + var r2 = createRecipe("Chicken Wednesday"); + addTag(r2, tag); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(2)); // Wed + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.tagRepeats()).isEmpty(); + } + + @Test + void tagTypeNotInConfigShouldNotPenalize() { + var plan = createPlan(); + var tag = createTag("Vegetarian", "dietary"); + var r1 = createRecipe("Veggie Mon"); + addTag(r1, tag); + var r2 = createRecipe("Veggie Tue"); + addTag(r2, tag); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + // Config only checks "protein" and "cuisine", not "dietary" + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.tagRepeats()).isEmpty(); + } + + @Test + void threeConsecutiveDaysSameTagPenalizesOnce() { + var plan = createPlan(); + var tag = createTag("Pasta", "cuisine"); + for (int i = 0; i < 3; i++) { + var r = createRecipe("Pasta day " + i); + addTag(r, tag); + addSlot(plan, r, MONDAY.plusDays(i)); + } + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(8.5); // -1.5 once + assertThat(result.tagRepeats()).hasSize(1); + assertThat(result.tagRepeats().getFirst().days()).hasSize(3); + } + + @Test + void differentTagsOfSameTypeNoPenalty() { + var plan = createPlan(); + var tofuTag = createTag("Tofu", "base"); + var lentilTag = createTag("Lentils", "base"); + var r1 = createRecipe("Tofu stir fry"); + addTag(r1, tofuTag); + var r2 = createRecipe("Lentil soup"); + addTag(r2, lentilTag); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubConfig(new VarietyScoreConfig(household, + new String[]{"base"}, bd("1.5"), bd("0.3"), bd("1.0"), bd("2.0"), 14)); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.tagRepeats()).isEmpty(); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 3: Ingredient Overlap on Consecutive Days + // ═══════════════════════════════════════════════════════════ + + @Nested + class IngredientOverlaps { + + @Test + void noOverlapWhenGapDay() { + var plan = createPlan(); + var tomato = createIngredient("Tomatoes", false); + var r1 = createRecipe("Salad Mon"); + addIngredient(r1, tomato); + var r2 = createRecipe("Salad Wed"); + addIngredient(r2, tomato); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(2)); // gap + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.ingredientOverlaps()).isEmpty(); + } + + @Test + void singleIngredientOverlap() { + var plan = createPlan(); + var tomato = createIngredient("Tomatoes", false); + var r1 = createRecipe("Recipe Mon"); + addIngredient(r1, tomato); + var r2 = createRecipe("Recipe Tue"); + addIngredient(r2, tomato); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(9.7); + assertThat(result.ingredientOverlaps()).hasSize(1); + assertThat(result.ingredientOverlaps().getFirst().ingredientName()).isEqualTo("Tomatoes"); + } + + @Test + void stapleIngredientsShouldBeFiltered() { + var plan = createPlan(); + var salt = createIngredient("Salt", true); + var oil = createIngredient("Olive oil", true); + var r1 = createRecipe("Recipe Mon"); + addIngredient(r1, salt); + addIngredient(r1, oil); + var r2 = createRecipe("Recipe Tue"); + addIngredient(r2, salt); + addIngredient(r2, oil); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.ingredientOverlaps()).isEmpty(); + } + + @Test + void multipleNonStapleOverlaps() { + var plan = createPlan(); + var tomato = createIngredient("Tomatoes", false); + var cheese = createIngredient("Cheese", false); + var r1 = createRecipe("Pizza Mon"); + addIngredient(r1, tomato); + addIngredient(r1, cheese); + var r2 = createRecipe("Pizza Tue"); + addIngredient(r2, tomato); + addIngredient(r2, cheese); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(9.4); + assertThat(result.ingredientOverlaps()).hasSize(2); + } + + @Test + void threeConsecutiveDaysSameIngredientPenalizesOnce() { + var plan = createPlan(); + var rice = createIngredient("Rice", false); + for (int i = 0; i < 3; i++) { + var r = createRecipe("Rice dish " + i); + addIngredient(r, rice); + addSlot(plan, r, MONDAY.plusDays(i)); + } + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(9.7); // -0.3 once + assertThat(result.ingredientOverlaps()).hasSize(1); + } + + @Test + void mixedStapleAndNonStaple() { + var plan = createPlan(); + var salt = createIngredient("Salt", true); + var tomato = createIngredient("Tomatoes", false); + var r1 = createRecipe("Recipe Mon"); + addIngredient(r1, salt); + addIngredient(r1, tomato); + var r2 = createRecipe("Recipe Tue"); + addIngredient(r2, salt); + addIngredient(r2, tomato); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(9.7); // only tomatoes + assertThat(result.ingredientOverlaps()).hasSize(1); + assertThat(result.ingredientOverlaps().getFirst().ingredientName()).isEqualTo("Tomatoes"); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 4: Recent Repeats from Cooking Log + // ═══════════════════════════════════════════════════════════ + + @Nested + class RecentRepeats { + + @Test + void noRecentHistoryShouldNotPenalize() { + var plan = createPlan(); + addSlot(plan, createRecipe("Lasagna"), MONDAY); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.recentRepeats()).isEmpty(); + } + + @Test + void recipeCookedWithinHistoryWindowShouldPenalize() { + var plan = createPlan(); + var lasagna = createRecipe("Lasagna"); + addSlot(plan, lasagna, MONDAY); + stubPlan(plan); + stubDefaultConfig(); + stubCookingLogs(createCookingLog(lasagna, MONDAY.minusDays(5))); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(9.0); + assertThat(result.recentRepeats()).containsExactly("Lasagna"); + } + + @Test + void recipeCookedOutsideHistoryWindowShouldNotPenalize() { + var plan = createPlan(); + var lasagna = createRecipe("Lasagna"); + addSlot(plan, lasagna, MONDAY); + stubPlan(plan); + stubDefaultConfig(); + // Cooking log 20 days ago — outside the default 14-day window + // The query uses weekStart - historyDays, so we just return no results + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.recentRepeats()).isEmpty(); + } + + @Test + void multipleRecentRepeatsShouldPenalizeEach() { + var plan = createPlan(); + var lasagna = createRecipe("Lasagna"); + var stirFry = createRecipe("Stir Fry"); + addSlot(plan, lasagna, MONDAY); + addSlot(plan, stirFry, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubCookingLogs( + createCookingLog(lasagna, MONDAY.minusDays(3)), + createCookingLog(stirFry, MONDAY.minusDays(7))); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(8.0); + assertThat(result.recentRepeats()).containsExactlyInAnyOrder("Lasagna", "Stir Fry"); + } + + @Test + void cookingLogFromDifferentHouseholdShouldNotPenalize() { + var plan = createPlan(); + var lasagna = createRecipe("Lasagna"); + addSlot(plan, lasagna, MONDAY); + stubPlan(plan); + stubDefaultConfig(); + // Repository query is scoped by householdId — returns empty + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 5: Duplicate Recipes Within Plan + // ═══════════════════════════════════════════════════════════ + + @Nested + class DuplicatesInPlan { + + @Test + void noDuplicatesShouldNotPenalize() { + var plan = createPlan(); + for (int i = 0; i < 5; i++) { + addSlot(plan, createRecipe("Recipe " + i), MONDAY.plusDays(i)); + } + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.duplicatesInPlan()).isEmpty(); + } + + @Test + void oneRecipeUsedTwice() { + var plan = createPlan(); + var lasagna = createRecipe("Lasagna"); + addSlot(plan, lasagna, MONDAY); + addSlot(plan, lasagna, MONDAY.plusDays(3)); + addSlot(plan, createRecipe("Other"), MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(8.0); // -2.0 + assertThat(result.duplicatesInPlan()).containsExactly("Lasagna"); + } + + @Test + void oneRecipeUsedThreeTimes() { + var plan = createPlan(); + var lasagna = createRecipe("Lasagna"); + addSlot(plan, lasagna, MONDAY); + addSlot(plan, lasagna, MONDAY.plusDays(2)); + addSlot(plan, lasagna, MONDAY.plusDays(4)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(6.0); // -2.0 x 2 extra + assertThat(result.duplicatesInPlan()).containsExactly("Lasagna"); + } + + @Test + void twoDifferentRecipesEachDuplicated() { + var plan = createPlan(); + var lasagna = createRecipe("Lasagna"); + var pizza = createRecipe("Pizza"); + addSlot(plan, lasagna, MONDAY); + addSlot(plan, lasagna, MONDAY.plusDays(1)); + addSlot(plan, pizza, MONDAY.plusDays(2)); + addSlot(plan, pizza, MONDAY.plusDays(3)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(6.0); // -2.0 -2.0 + assertThat(result.duplicatesInPlan()).containsExactlyInAnyOrder("Lasagna", "Pizza"); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 6: Combined Penalties + // ═══════════════════════════════════════════════════════════ + + @Nested + class CombinedPenalties { + + @Test + void perfectWeekShouldScoreTen() { + var plan = createPlan(); + for (int i = 0; i < 5; i++) { + var r = createRecipe("Unique " + i); + addTag(r, createTag("Tag" + i, "protein")); + addIngredient(r, createIngredient("Ingredient" + i, false)); + addSlot(plan, r, MONDAY.plusDays(i)); + } + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + } + + @Test + void worstCaseShouldFloorAtZero() { + var plan = createPlan(); + var tag = createTag("Pasta", "cuisine"); + var ingredient = createIngredient("Tomatoes", false); + var recipe = createRecipe("Same Pasta"); + addTag(recipe, tag); + addIngredient(recipe, ingredient); + + // Same recipe 7 days in a row + for (int i = 0; i < 7; i++) { + addSlot(plan, recipe, MONDAY.plusDays(i)); + } + stubPlan(plan); + stubDefaultConfig(); + // Also recently cooked + stubCookingLogs(createCookingLog(recipe, MONDAY.minusDays(2))); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + // tag repeat: -1.5, ingredient overlap: -0.3, recent repeat: -1.0, + // duplicates: 6 extra x -2.0 = -12.0 → total > 10, clamped to 0 + assertThat(result.score()).isEqualTo(0.0); + } + + @Test + void realisticMediocreWeek() { + var plan = createPlan(); + var chickenTag = createTag("Chicken", "protein"); + var tomato = createIngredient("Tomatoes", false); + var cheese = createIngredient("Cheese", false); + + // Mon+Tue: same protein tag (chicken) → -1.5 + var r1 = createRecipe("Chicken Stir Fry"); + addTag(r1, chickenTag); + addIngredient(r1, tomato); + var r2 = createRecipe("Chicken Curry"); + addTag(r2, chickenTag); + addIngredient(r2, tomato); + addIngredient(r2, cheese); + + // Wed: different recipe with tomato+cheese overlap from Tue → -0.3 -0.3 + var r3 = createRecipe("Pizza"); + addIngredient(r3, tomato); + addIngredient(r3, cheese); + + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + addSlot(plan, r3, MONDAY.plusDays(2)); + stubPlan(plan); + stubDefaultConfig(); + // r1 was also recently cooked → -1.0 + stubCookingLogs(createCookingLog(r1, MONDAY.minusDays(5))); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + // 10.0 - 1.5 (chicken) - 0.3 (tomato) - 0.3 (cheese) - 1.0 (recent) = 6.9 + assertThat(result.score()).isEqualTo(6.9); + assertThat(result.tagRepeats()).hasSize(1); + assertThat(result.ingredientOverlaps()).hasSize(2); + assertThat(result.recentRepeats()).hasSize(1); + assertThat(result.duplicatesInPlan()).isEmpty(); + } + + @Test + void allPenaltyTypesActive() { + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var tomato = createIngredient("Tomatoes", false); + var lasagna = createRecipe("Lasagna"); + addTag(lasagna, pastaTag); + addIngredient(lasagna, tomato); + + var pastaRecipe = createRecipe("Spaghetti"); + addTag(pastaRecipe, pastaTag); + + // Lasagna Mon + Tue (duplicate + tag repeat + ingredient overlap) + addSlot(plan, lasagna, MONDAY); + addSlot(plan, lasagna, MONDAY.plusDays(1)); + // Spaghetti Wed (consecutive pasta tag from Tue) + addSlot(plan, pastaRecipe, MONDAY.plusDays(2)); + + stubPlan(plan); + stubDefaultConfig(); + stubCookingLogs(createCookingLog(lasagna, MONDAY.minusDays(3))); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + // tag: Pasta Mon+Tue+Wed → -1.5 + // ingredient: Tomatoes Mon+Tue → -0.3 + // recent: Lasagna → -1.0 + // duplicate: Lasagna x2 → -2.0 + // Total: 10 - 1.5 - 0.3 - 1.0 - 2.0 = 5.2 + assertThat(result.score()).isCloseTo(5.2, within(0.001)); + assertThat(result.tagRepeats()).hasSize(1); + assertThat(result.ingredientOverlaps()).hasSize(1); + assertThat(result.recentRepeats()).hasSize(1); + assertThat(result.duplicatesInPlan()).hasSize(1); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 7: Configuration + // ═══════════════════════════════════════════════════════════ + + @Nested + class Configuration { + + @Test + void customWeightsShouldChangeScore() { + var plan = createPlan(); + var tag = createTag("Chicken", "protein"); + var r1 = createRecipe("Chicken Mon"); + addTag(r1, tag); + var r2 = createRecipe("Chicken Tue"); + addTag(r2, tag); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + // w_tag_repeat = 2.0 instead of default 1.5 + stubConfig(new VarietyScoreConfig(household, + new String[]{"protein", "cuisine"}, bd("2.0"), bd("0.3"), bd("1.0"), bd("2.0"), 14)); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(8.0); // -2.0 instead of -1.5 + } + + @Test + void customRepeatTagTypesShouldScopeChecks() { + var plan = createPlan(); + var cuisineTag = createTag("Pasta", "cuisine"); + var r1 = createRecipe("Pasta Mon"); + addTag(r1, cuisineTag); + var r2 = createRecipe("Pasta Tue"); + addTag(r2, cuisineTag); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + // Only check "base", NOT "cuisine" + stubConfig(new VarietyScoreConfig(household, + new String[]{"base"}, bd("1.5"), bd("0.3"), bd("1.0"), bd("2.0"), 14)); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); // cuisine not checked + } + + @Test + void customHistoryDaysShouldScopeWindow() { + var plan = createPlan(); + var lasagna = createRecipe("Lasagna"); + addSlot(plan, lasagna, MONDAY); + stubPlan(plan); + // history_days = 7 + stubConfig(new VarietyScoreConfig(household, + new String[]{"protein", "cuisine"}, bd("1.5"), bd("0.3"), bd("1.0"), bd("2.0"), 7)); + // Recipe cooked 10 days ago — outside 7-day window → repo returns nothing + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + } + + @Test + void noConfigShouldUseDefaults() { + var plan = createPlan(); + var tag = createTag("Chicken", "protein"); + var r1 = createRecipe("Chicken Mon"); + addTag(r1, tag); + var r2 = createRecipe("Chicken Tue"); + addTag(r2, tag); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + // No config in DB + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + // Should use default w_tag_repeat of 1.5 + assertThat(result.score()).isEqualTo(8.5); + } + } + + // ═══════════════════════════════════════════════════════════ + // Category 8: Edge Cases + // ═══════════════════════════════════════════════════════════ + + @Nested + class EdgeCases { + + @Test + void recipesWithNoTagsShouldNotPenalize() { + var plan = createPlan(); + var r1 = createRecipe("Untagged Mon"); + var r2 = createRecipe("Untagged Tue"); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.tagRepeats()).isEmpty(); + } + + @Test + void recipesWithNoIngredientsShouldNotPenalize() { + var plan = createPlan(); + var r1 = createRecipe("No-ingredient Mon"); + var r2 = createRecipe("No-ingredient Tue"); + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.ingredientOverlaps()).isEmpty(); + } + + @Test + void multipleSlotsOnSameDayShouldNotCountAsConsecutive() { + var plan = createPlan(); + var tag = createTag("Pasta", "cuisine"); + var r1 = createRecipe("Pasta Lunch"); + addTag(r1, tag); + var r2 = createRecipe("Pasta Dinner"); + addTag(r2, tag); + // Both on Monday + addSlot(plan, r1, MONDAY); + addSlot(plan, r2, MONDAY); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + // Same day, not consecutive — no tag repeat penalty + // But it IS a duplicate recipe situation? No — different recipes. + assertThat(result.tagRepeats()).isEmpty(); + } + + @Test + void slotsNotInOrderShouldStillDetectConsecutive() { + var plan = createPlan(); + var tag = createTag("Pasta", "cuisine"); + var rWed = createRecipe("Pasta Wed"); + addTag(rWed, tag); + var rMon = createRecipe("Pasta Mon"); + addTag(rMon, tag); + var rTue = createRecipe("Pasta Tue"); + addTag(rTue, tag); + // Add in wrong order + addSlot(plan, rWed, MONDAY.plusDays(2)); + addSlot(plan, rMon, MONDAY); + addSlot(plan, rTue, MONDAY.plusDays(1)); + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(8.5); // Pasta on 3 consecutive days + assertThat(result.tagRepeats()).hasSize(1); + } + + @Test + void fullWeekAllDifferentShouldScorePerfect() { + var plan = createPlan(); + for (int i = 0; i < 7; i++) { + var r = createRecipe("Day " + i + " recipe"); + addTag(r, createTag("Tag" + i, "protein")); + addIngredient(r, createIngredient("Ingredient" + i, false)); + addSlot(plan, r, MONDAY.plusDays(i)); + } + stubPlan(plan); + stubDefaultConfig(); + stubNoCookingLogs(); + + VarietyScoreResponse result = planningService.getVarietyScore(HOUSEHOLD_ID, plan.getId()); + + assertThat(result.score()).isEqualTo(10.0); + assertThat(result.tagRepeats()).isEmpty(); + assertThat(result.ingredientOverlaps()).isEmpty(); + assertThat(result.recentRepeats()).isEmpty(); + assertThat(result.duplicatesInPlan()).isEmpty(); + } + } + + private static BigDecimal bd(String val) { + return new BigDecimal(val); + } +} diff --git a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java index a9ffa9b..888e824 100644 --- a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java +++ b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java @@ -19,7 +19,6 @@ 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.*; @@ -154,12 +153,12 @@ class WeekPlanControllerTest { @Test void getSuggestionsShouldReturn200() throws Exception { var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null); - var item = new SuggestionResponse.SuggestionItem(recipe, - List.of("not_cooked_recently"), List.of()); + var item = new SuggestionResponse.SuggestionItem(recipe, 9.5); 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))) + when(planningService.getSuggestions(HOUSEHOLD_ID, PLAN_ID, WEEK_START.plusDays(2), + List.of(), null)) .thenReturn(response); mockMvc.perform(get("/v1/week-plans/{id}/suggestions", PLAN_ID) @@ -167,13 +166,13 @@ class WeekPlanControllerTest { .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")); + .andExpect(jsonPath("$.suggestions[0].simulatedScore").value(9.5)); } @Test void getVarietyScoreShouldReturn200() throws Exception { var response = new VarietyScoreResponse(7.5, List.of(), List.of(), - Map.of("easy", 2, "medium", 3, "hard", 2)); + List.of(), List.of()); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(planningService.getVarietyScore(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response); diff --git a/backend/src/test/java/com/recipeapp/recipe/HouseholdResolverTest.java b/backend/src/test/java/com/recipeapp/recipe/HouseholdResolverTest.java new file mode 100644 index 0000000..dbe333b --- /dev/null +++ b/backend/src/test/java/com/recipeapp/recipe/HouseholdResolverTest.java @@ -0,0 +1,74 @@ +package com.recipeapp.recipe; + +import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.household.HouseholdMemberRepository; +import com.recipeapp.household.entity.Household; +import com.recipeapp.household.entity.HouseholdMember; +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.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HouseholdResolverTest { + + @Mock + private HouseholdMemberRepository householdMemberRepository; + + @InjectMocks + private HouseholdResolver householdResolver; + + @Test + void resolveShouldReturnHouseholdId() { + var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")) + .thenReturn(Optional.of(member)); + + UUID result = householdResolver.resolve("sarah@example.com"); + + assertThat(result).isEqualTo(household.getId()); + } + + @Test + void resolveUserIdShouldReturnUserId() { + var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")) + .thenReturn(Optional.of(member)); + + UUID result = householdResolver.resolveUserId("sarah@example.com"); + + assertThat(result).isEqualTo(user.getId()); + } + + @Test + void resolveShouldThrowWhenUserNotInHousehold() { + when(householdMemberRepository.findByUserEmailIgnoreCase("orphan@example.com")) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdResolver.resolve("orphan@example.com")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void resolveUserIdShouldThrowWhenUserNotInHousehold() { + when(householdMemberRepository.findByUserEmailIgnoreCase("orphan@example.com")) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdResolver.resolveUserId("orphan@example.com")) + .isInstanceOf(ResourceNotFoundException.class); + } +} diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java index 772a6ca..dbf793a 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java @@ -344,4 +344,215 @@ class RecipeServiceTest { HOUSEHOLD_ID, new IngredientCategoryCreateRequest("Produce"))) .isInstanceOf(ConflictException.class); } + + // ── Additional search filter combinations ── + + @Test + void searchIngredientsShouldFilterByIsStapleOnly() { + var household = testHousehold(); + var ingredient = testIngredient(household, "Salt"); + + when(ingredientRepository.findByHouseholdIdAndIsStaple(HOUSEHOLD_ID, true)) + .thenReturn(List.of(ingredient)); + + List result = recipeService.searchIngredients(HOUSEHOLD_ID, null, true); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().name()).isEqualTo("Salt"); + } + + @Test + void searchIngredientsShouldFilterBySearchAndIsStaple() { + var household = testHousehold(); + var ingredient = testIngredient(household, "Olive oil"); + + when(ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple( + HOUSEHOLD_ID, "olive", true)).thenReturn(List.of(ingredient)); + + List result = recipeService.searchIngredients(HOUSEHOLD_ID, "olive", true); + + assertThat(result).hasSize(1); + } + + @Test + void searchIngredientsShouldReturnAllWhenNoFilters() { + var household = testHousehold(); + var ingredient = testIngredient(household, "Tomato"); + + when(ingredientRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of(ingredient)); + + List result = recipeService.searchIngredients(HOUSEHOLD_ID, null, null); + + assertThat(result).hasSize(1); + } + + @Test + void searchIngredientsShouldReturnEmptyListWhenNoMatches() { + when(ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(HOUSEHOLD_ID, "xyz")) + .thenReturn(List.of()); + + List result = recipeService.searchIngredients(HOUSEHOLD_ID, "xyz", null); + + assertThat(result).isEmpty(); + } + + // ── Patch ingredient edge cases ── + + @Test + void patchIngredientShouldThrowWhenNotFound() { + var id = UUID.randomUUID(); + when(ingredientRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recipeService.patchIngredient(HOUSEHOLD_ID, id, + new IngredientPatchRequest("new name", null, null))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void patchIngredientShouldSetCategory() { + var household = testHousehold(); + var ingredient = testIngredient(household, "Chicken breast"); + var category = new IngredientCategory(household, "Fish & Meat", (short) 2); + try { + var field = IngredientCategory.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(category, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + + when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient)); + when(ingredientCategoryRepository.findById(category.getId())).thenReturn(Optional.of(category)); + + var request = new IngredientPatchRequest(null, null, category.getId()); + IngredientResponse result = recipeService.patchIngredient(HOUSEHOLD_ID, ingredient.getId(), request); + + assertThat(result.category()).isNotNull(); + assertThat(result.category().name()).isEqualTo("Fish & Meat"); + } + + @Test + void patchIngredientShouldThrowWhenCategoryNotFound() { + var household = testHousehold(); + var ingredient = testIngredient(household, "Chicken breast"); + var categoryId = UUID.randomUUID(); + + when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient)); + when(ingredientCategoryRepository.findById(categoryId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recipeService.patchIngredient(HOUSEHOLD_ID, ingredient.getId(), + new IngredientPatchRequest(null, null, categoryId))) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── Create recipe edge cases ── + + @Test + void createRecipeShouldThrowWhenHouseholdNotFound() { + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + + var request = new RecipeCreateRequest( + "Test", (short) 2, (short) 15, "easy", false, null, + List.of(), List.of(), List.of()); + + assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createRecipeShouldThrowWhenIngredientNotFound() { + var household = testHousehold(); + var ingredientId = UUID.randomUUID(); + + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(ingredientRepository.findById(ingredientId)).thenReturn(Optional.empty()); + + var request = new RecipeCreateRequest( + "Test", (short) 2, (short) 15, "easy", false, null, + List.of(new RecipeCreateRequest.IngredientEntry( + ingredientId, null, new BigDecimal("100"), "g", (short) 1)), + List.of(), List.of()); + + assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createRecipeShouldHandleNullIngredientsAndSteps() { + var household = testHousehold(); + + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> { + Recipe r = i.getArgument(0); + try { + var field = Recipe.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(r, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + return r; + }); + + var request = new RecipeCreateRequest( + "Simple", (short) 1, (short) 5, "easy", false, null, + null, null, null); + + RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request); + + assertThat(result.name()).isEqualTo("Simple"); + assertThat(result.ingredients()).isEmpty(); + assertThat(result.steps()).isEmpty(); + } + + @Test + void deleteRecipeShouldThrowWhenNotFound() { + var id = UUID.randomUUID(); + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(id, HOUSEHOLD_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recipeService.deleteRecipe(HOUSEHOLD_ID, id)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void updateRecipeShouldThrowWhenNotFound() { + var id = UUID.randomUUID(); + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(id, HOUSEHOLD_ID)) + .thenReturn(Optional.empty()); + + var request = new RecipeCreateRequest( + "Updated", (short) 2, (short) 20, "easy", false, null, + List.of(), List.of(), List.of()); + + assertThatThrownBy(() -> recipeService.updateRecipe(HOUSEHOLD_ID, id, request)) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── Tag/Category edge cases ── + + @Test + void createTagShouldThrowWhenHouseholdNotFound() { + when(tagRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "New")).thenReturn(false); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recipeService.createTag(HOUSEHOLD_ID, new TagCreateRequest("New", "other"))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createCategoryShouldThrowWhenHouseholdNotFound() { + when(ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "New")) + .thenReturn(false); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recipeService.createCategory( + HOUSEHOLD_ID, new IngredientCategoryCreateRequest("New"))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void listTagsShouldReturnEmptyList() { + when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of()); + + List result = recipeService.listTags(HOUSEHOLD_ID); + + assertThat(result).isEmpty(); + } } diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java index 1da56eb..9fb6cce 100644 --- a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java @@ -282,4 +282,188 @@ class ShoppingServiceTest { assertThatThrownBy(() -> shoppingService.deleteItem(HOUSEHOLD_ID, list.getId(), item.getId())) .isInstanceOf(ValidationException.class); } + + // ── Household mismatch ── + + @Test + void generateFromPlanShouldThrowWhenHouseholdMismatch() { + var otherHousehold = new Household("Other family", null); + setId(otherHousehold, Household.class, UUID.randomUUID()); + var plan = new WeekPlan(otherHousehold, WEEK_START); + setId(plan, WeekPlan.class, UUID.randomUUID()); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + + assertThatThrownBy(() -> shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId())) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getShoppingListShouldThrowWhenNotFound() { + var listId = UUID.randomUUID(); + when(shoppingListRepository.findById(listId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> shoppingService.getShoppingList(HOUSEHOLD_ID, listId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getShoppingListShouldThrowWhenHouseholdMismatch() { + var otherHousehold = new Household("Other family", null); + setId(otherHousehold, Household.class, UUID.randomUUID()); + var plan = new WeekPlan(otherHousehold, WEEK_START); + setId(plan, WeekPlan.class, UUID.randomUUID()); + var list = new ShoppingList(otherHousehold, plan); + setId(list, ShoppingList.class, UUID.randomUUID()); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + + assertThatThrownBy(() -> shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId())) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── Check item edge cases ── + + @Test + void checkItemShouldUncheckAndClearUser() { + 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"); + item.setChecked(true); + var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); + setId(user, UserAccount.class, UUID.randomUUID()); + item.setCheckedBy(user); + list.getItems().add(item); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + when(shoppingListItemRepository.save(any(ShoppingListItem.class))).thenAnswer(i -> i.getArgument(0)); + + ShoppingListItemResponse result = shoppingService.checkItem( + HOUSEHOLD_ID, list.getId(), item.getId(), new CheckItemRequest(false), user.getId()); + + assertThat(result.isChecked()).isFalse(); + assertThat(result.checkedBy()).isNull(); + } + + @Test + void checkItemShouldThrowWhenItemNotFound() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + var fakeItemId = UUID.randomUUID(); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + + assertThatThrownBy(() -> shoppingService.checkItem( + HOUSEHOLD_ID, list.getId(), fakeItemId, new CheckItemRequest(true), UUID.randomUUID())) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── Add item with ingredient ── + + @Test + void addItemShouldResolveIngredient() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + var ingredient = testIngredient(household, "Milk", false); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient)); + 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(ingredient.getId(), null, new BigDecimal("2"), "liters")); + + assertThat(result.name()).isEqualTo("Milk"); + assertThat(result.ingredientId()).isEqualTo(ingredient.getId()); + } + + @Test + void addItemShouldThrowWhenIngredientNotFound() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + var ingredientId = UUID.randomUUID(); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + when(ingredientRepository.findById(ingredientId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> shoppingService.addItem( + HOUSEHOLD_ID, list.getId(), + new AddItemRequest(ingredientId, null, new BigDecimal("1"), "pcs"))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void deleteItemShouldThrowWhenItemNotFound() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + var fakeItemId = UUID.randomUUID(); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + + assertThatThrownBy(() -> shoppingService.deleteItem(HOUSEHOLD_ID, list.getId(), fakeItemId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void publishShouldThrowWhenListNotFound() { + var listId = UUID.randomUUID(); + when(shoppingListRepository.findById(listId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> shoppingService.publish(HOUSEHOLD_ID, listId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── Generate from plan with empty slots ── + + @Test + void generateFromPlanShouldCreateEmptyListWhenNoSlots() { + var household = testHousehold(); + var plan = testWeekPlan(household); + // no slots added + + 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.items()).isEmpty(); + assertThat(result.status()).isEqualTo("draft"); + } + + // ── Item with category ── + + @Test + void getShoppingListShouldIncludeCategoryInItemResponse() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var list = testShoppingList(household, plan); + var ingredient = testIngredient(household, "Milk", false); + var category = new IngredientCategory(household, "Dairy", (short) 3); + setId(category, IngredientCategory.class, UUID.randomUUID()); + ingredient.setCategory(category); + var item = testItem(list, ingredient, new BigDecimal("2.00"), "liters"); + list.getItems().add(item); + + when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list)); + + ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId()); + + assertThat(result.items().getFirst().category()).isNotNull(); + assertThat(result.items().getFirst().category().name()).isEqualTo("Dairy"); + } }