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:
2026-04-02 10:33:11 +02:00
parent 9ec703abcd
commit 8221a1fd41
21 changed files with 3225 additions and 110 deletions

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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")

View File

@@ -6,7 +6,6 @@ public record SuggestionResponse(List<SuggestionItem> suggestions) {
public record SuggestionItem(
SlotResponse.SlotRecipe recipe,
List<String> fitReasons,
List<String> warnings
double simulatedScore
) {}
}

View File

@@ -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) {}
}

View File

@@ -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; }
}

View File

@@ -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)
);

View File

@@ -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());
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}