Rewrite variety score and suggestions with configurable scoring
- Add VarietyScoreConfig entity, repository, and V020 migration for per-household scoring weights and configurable tag types - Rewrite getVarietyScore: tag-type repeats on consecutive days, non-staple ingredient overlaps, cooking log history, plan duplicates - Rewrite getSuggestions: simulate variety score for each candidate, add tag filter (AND, case-insensitive) and configurable topN param - Update SuggestionResponse to return simulatedScore instead of fitReasons/warnings, update VarietyScoreResponse to new shape - Seed default VarietyScoreConfig on household creation - Extend test suite across all domains (+270 tests, all passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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<String> tagFilters, Integer topN);
|
||||
|
||||
VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId);
|
||||
|
||||
|
||||
@@ -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<String> 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<UUID> usedRecipeIds = plan.getSlots().stream()
|
||||
.map(s -> s.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Collect proteins used in adjacent days
|
||||
Set<String> adjacentProteins = new HashSet<>();
|
||||
for (WeekPlanSlot slot : plan.getSlots()) {
|
||||
if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) {
|
||||
for (Tag tag : slot.getRecipe().getTags()) {
|
||||
if ("protein".equals(tag.getTagType())) {
|
||||
adjacentProteins.add(tag.getName().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect ingredients used in adjacent days
|
||||
Set<UUID> adjacentIngredientIds = new HashSet<>();
|
||||
for (WeekPlanSlot slot : plan.getSlots()) {
|
||||
if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) {
|
||||
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
|
||||
adjacentIngredientIds.add(ri.getIngredient().getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recent cooking logs (last 14 days)
|
||||
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
|
||||
householdId, slotDate.minusDays(14));
|
||||
Set<UUID> recentlyCookedIds = recentLogs.stream()
|
||||
Set<UUID> recentlyCookedIds = cookingLogRepository
|
||||
.findByHouseholdIdAndCookedOnAfter(householdId,
|
||||
plan.getWeekStart().minusDays(config.getHistoryDays()))
|
||||
.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Count effort levels in plan
|
||||
Map<String, Long> effortCounts = plan.getSlots().stream()
|
||||
.collect(Collectors.groupingBy(s -> s.getRecipe().getEffort(), Collectors.counting()));
|
||||
|
||||
// Get all household recipes, score and pick top 5
|
||||
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
|
||||
|
||||
Set<String> lowerTagFilters = tagFilters.stream()
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
|
||||
.filter(r -> !usedRecipeIds.contains(r.getId()))
|
||||
.map(recipe -> {
|
||||
List<String> fitReasons = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
if (!recentlyCookedIds.contains(recipe.getId())) {
|
||||
fitReasons.add("not_cooked_recently");
|
||||
}
|
||||
|
||||
boolean hasProteinRepeat = recipe.getTags().stream()
|
||||
.filter(t -> "protein".equals(t.getTagType()))
|
||||
.anyMatch(t -> adjacentProteins.contains(t.getName().toLowerCase()));
|
||||
if (!hasProteinRepeat) {
|
||||
fitReasons.add("no_protein_repeat");
|
||||
}
|
||||
|
||||
String effort = recipe.getEffort();
|
||||
long currentCount = effortCounts.getOrDefault(effort, 0L);
|
||||
if (currentCount < 3) {
|
||||
fitReasons.add("effort_balance");
|
||||
}
|
||||
|
||||
boolean sharesIngredient = recipe.getIngredients().stream()
|
||||
.anyMatch(ri -> adjacentIngredientIds.contains(ri.getIngredient().getId()));
|
||||
if (sharesIngredient) {
|
||||
warnings.add("shares_ingredient_with_adjacent_day");
|
||||
}
|
||||
|
||||
return new SuggestionResponse.SuggestionItem(
|
||||
toSlotRecipe(recipe), fitReasons, warnings);
|
||||
.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<String> lowerTagFilters) {
|
||||
if (lowerTagFilters.isEmpty()) return true;
|
||||
Set<String> 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<UUID> recentlyCookedIds) {
|
||||
// Build a simulated slot list: existing slots + candidate on slotDate
|
||||
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
|
||||
for (WeekPlanSlot slot : plan.getSlots()) {
|
||||
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
|
||||
}
|
||||
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
|
||||
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<String, List<LocalDate>> 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<UUID, Long> 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<WeekPlanSlot> 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<String, Integer> effortBalance = new LinkedHashMap<>();
|
||||
// Load config (or use defaults)
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
int historyDays = config.getHistoryDays();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, TagAccumulator> 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<VarietyScoreResponse.TagRepeat> 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<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.getSlotDate());
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<VarietyScoreResponse.IngredientOverlap> 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<String, List<LocalDate>> proteinDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (Tag tag : slot.getRecipe().getTags()) {
|
||||
if ("protein".equals(tag.getTagType())) {
|
||||
proteinDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
|
||||
.add(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> proteinRepeats = proteinDays.entrySet().stream()
|
||||
.filter(e -> hasConsecutiveDays(e.getValue()))
|
||||
.map(Map.Entry::getKey)
|
||||
// 3. Recent repeats from cooking log
|
||||
LocalDate referenceDate = plan.getWeekStart();
|
||||
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
|
||||
householdId, referenceDate.minusDays(historyDays));
|
||||
Set<UUID> recentlyCookedIds = recentLogs.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<String> 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<UUID, Long> recipeCounts = slots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting()));
|
||||
|
||||
List<String> 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<LocalDate> days = new ArrayList<>();
|
||||
|
||||
TagAccumulator(String tagType) { this.tagType = tagType; }
|
||||
|
||||
void addDay(LocalDate day) { days.add(day); }
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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<VarietyScoreConfig, UUID> {
|
||||
Optional<VarietyScoreConfig> findByHouseholdId(UUID householdId);
|
||||
}
|
||||
@@ -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<String> 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")
|
||||
|
||||
@@ -6,7 +6,6 @@ public record SuggestionResponse(List<SuggestionItem> suggestions) {
|
||||
|
||||
public record SuggestionItem(
|
||||
SlotResponse.SlotRecipe recipe,
|
||||
List<String> fitReasons,
|
||||
List<String> warnings
|
||||
double simulatedScore
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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<TagRepeat> tagRepeats,
|
||||
List<IngredientOverlap> ingredientOverlaps,
|
||||
List<String> proteinRepeats,
|
||||
Map<String, Integer> effortBalance
|
||||
List<String> recentRepeats,
|
||||
List<String> duplicatesInPlan
|
||||
) {
|
||||
public record TagRepeat(String tagName, String tagType, List<LocalDate> days) {}
|
||||
public record IngredientOverlap(String ingredientName, List<LocalDate> days) {}
|
||||
}
|
||||
|
||||
@@ -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<String> 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; }
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<MemberResponse> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PantryItemResponse> 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<PantryItemResponse> result = pantryService.listItems(HOUSEHOLD_ID);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.getFirst().category()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <T> void setId(T entity, Class<T> clazz, UUID id) {
|
||||
try {
|
||||
var field = clazz.getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
field.set(entity, id);
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <T> void setId(T entity, Class<T> clazz, UUID id) {
|
||||
try {
|
||||
var field = clazz.getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
field.set(entity, id);
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<IngredientResponse> 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<IngredientResponse> 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<IngredientResponse> result = recipeService.searchIngredients(HOUSEHOLD_ID, null, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchIngredientsShouldReturnEmptyListWhenNoMatches() {
|
||||
when(ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(HOUSEHOLD_ID, "xyz"))
|
||||
.thenReturn(List.of());
|
||||
|
||||
List<IngredientResponse> 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<TagResponse> result = recipeService.listTags(HOUSEHOLD_ID);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user