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

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