package com.recipeapp.planning; import com.recipeapp.auth.UserAccountRepository; import com.recipeapp.auth.entity.UserAccount; import com.recipeapp.common.ConflictException; import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ValidationException; import com.recipeapp.household.HouseholdRepository; import com.recipeapp.household.entity.Household; import com.recipeapp.planning.dto.*; import com.recipeapp.planning.entity.*; import com.recipeapp.recipe.RecipeRepository; import com.recipeapp.recipe.entity.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageRequest; import java.math.BigDecimal; import java.time.LocalDate; import java.util.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class PlanningServiceTest { @Mock private WeekPlanRepository weekPlanRepository; @Mock private WeekPlanSlotRepository weekPlanSlotRepository; @Mock private CookingLogRepository cookingLogRepository; @Mock private RecipeRepository recipeRepository; @Mock private HouseholdRepository householdRepository; @Mock private UserAccountRepository userAccountRepository; @Mock private VarietyScoreConfigRepository varietyScoreConfigRepository; @InjectMocks private PlanningService planningService; private static final UUID HOUSEHOLD_ID = UUID.randomUUID(); private static final LocalDate WEEK_START = LocalDate.of(2026, 4, 6); // Monday private Household testHousehold() { var h = new Household("Test family", null); setId(h, Household.class, HOUSEHOLD_ID); return h; } private WeekPlan testWeekPlan(Household household) { var wp = new WeekPlan(household, WEEK_START); setId(wp, WeekPlan.class, UUID.randomUUID()); return wp; } private Recipe testRecipe(Household household, String name) { var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true); setId(r, Recipe.class, UUID.randomUUID()); return r; } private void setId(T entity, Class clazz, UUID id) { try { var field = clazz.getDeclaredField("id"); field.setAccessible(true); field.set(entity, id); } catch (Exception e) { throw new RuntimeException(e); } } // ── Week Plan CRUD ── @Test void getWeekPlanShouldReturnPlanWithSlots() { var household = testHousehold(); var plan = testWeekPlan(household); var recipe = testRecipe(household, "Spaghetti"); var slot = new WeekPlanSlot(plan, recipe, WEEK_START); setId(slot, WeekPlanSlot.class, UUID.randomUUID()); plan.getSlots().add(slot); when(weekPlanRepository.findByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)) .thenReturn(Optional.of(plan)); WeekPlanResponse result = planningService.getWeekPlan(HOUSEHOLD_ID, WEEK_START); assertThat(result.weekStart()).isEqualTo(WEEK_START); assertThat(result.slots()).hasSize(1); assertThat(result.slots().getFirst().recipe().name()).isEqualTo("Spaghetti"); } @Test void getWeekPlanShouldThrowWhenNotFound() { when(weekPlanRepository.findByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)) .thenReturn(Optional.empty()); assertThatThrownBy(() -> planningService.getWeekPlan(HOUSEHOLD_ID, WEEK_START)) .isInstanceOf(ResourceNotFoundException.class); } @Test void createWeekPlanShouldPersist() { var household = testHousehold(); when(weekPlanRepository.existsByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)).thenReturn(false); when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); when(weekPlanRepository.save(any(WeekPlan.class))).thenAnswer(i -> { WeekPlan wp = i.getArgument(0); setId(wp, WeekPlan.class, UUID.randomUUID()); return wp; }); WeekPlanResponse result = planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START); assertThat(result.weekStart()).isEqualTo(WEEK_START); assertThat(result.status()).isEqualTo("draft"); } @Test void createWeekPlanShouldThrowConflictWhenExists() { when(weekPlanRepository.existsByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)).thenReturn(true); assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START)) .isInstanceOf(ConflictException.class); } @Test void createWeekPlanShouldThrowWhenNotMonday() { var tuesday = LocalDate.of(2026, 4, 7); assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, tuesday)) .isInstanceOf(ValidationException.class); } // ── Slots ── @Test void addSlotShouldCreateSlot() { var household = testHousehold(); var plan = testWeekPlan(household); var recipe = testRecipe(household, "Spaghetti"); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipe.getId(), HOUSEHOLD_ID)) .thenReturn(Optional.of(recipe)); when(weekPlanSlotRepository.save(any(WeekPlanSlot.class))).thenAnswer(i -> { WeekPlanSlot s = i.getArgument(0); setId(s, WeekPlanSlot.class, UUID.randomUUID()); return s; }); SlotResponse result = planningService.addSlot(HOUSEHOLD_ID, plan.getId(), new CreateSlotRequest(WEEK_START.plusDays(1), recipe.getId())); assertThat(result.recipe().name()).isEqualTo("Spaghetti"); } @Test void updateSlotShouldSwapRecipe() { var household = testHousehold(); var plan = testWeekPlan(household); var oldRecipe = testRecipe(household, "Spaghetti"); var newRecipe = testRecipe(household, "Stir Fry"); var slot = new WeekPlanSlot(plan, oldRecipe, WEEK_START); setId(slot, WeekPlanSlot.class, UUID.randomUUID()); plan.getSlots().add(slot); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); when(weekPlanSlotRepository.findById(slot.getId())).thenReturn(Optional.of(slot)); when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(newRecipe.getId(), HOUSEHOLD_ID)) .thenReturn(Optional.of(newRecipe)); SlotResponse result = planningService.updateSlot(HOUSEHOLD_ID, plan.getId(), slot.getId(), new UpdateSlotRequest(newRecipe.getId())); assertThat(result.recipe().name()).isEqualTo("Stir Fry"); } @Test void deleteSlotShouldRemoveSlot() { var household = testHousehold(); var plan = testWeekPlan(household); var slot = new WeekPlanSlot(plan, testRecipe(household, "X"), WEEK_START); setId(slot, WeekPlanSlot.class, UUID.randomUUID()); plan.getSlots().add(slot); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); when(weekPlanSlotRepository.findById(slot.getId())).thenReturn(Optional.of(slot)); planningService.deleteSlot(HOUSEHOLD_ID, plan.getId(), slot.getId()); verify(weekPlanSlotRepository).delete(slot); } // ── Confirm ── @Test void confirmPlanShouldSetStatusAndTimestamp() { var household = testHousehold(); var plan = testWeekPlan(household); var slot = new WeekPlanSlot(plan, testRecipe(household, "X"), WEEK_START); setId(slot, WeekPlanSlot.class, UUID.randomUUID()); plan.getSlots().add(slot); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); WeekPlanResponse result = planningService.confirmPlan(HOUSEHOLD_ID, plan.getId()); assertThat(result.status()).isEqualTo("confirmed"); assertThat(result.confirmedAt()).isNotNull(); } @Test void confirmPlanShouldThrowWhenNoSlots() { var household = testHousehold(); var plan = testWeekPlan(household); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); assertThatThrownBy(() -> planningService.confirmPlan(HOUSEHOLD_ID, plan.getId())) .isInstanceOf(ValidationException.class); } @Test void confirmPlanShouldThrowWhenAlreadyConfirmed() { var household = testHousehold(); var plan = testWeekPlan(household); plan.setStatus("confirmed"); var slot = new WeekPlanSlot(plan, testRecipe(household, "X"), WEEK_START); setId(slot, WeekPlanSlot.class, UUID.randomUUID()); plan.getSlots().add(slot); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); assertThatThrownBy(() -> planningService.confirmPlan(HOUSEHOLD_ID, plan.getId())) .isInstanceOf(ValidationException.class); } // ── Cooking Logs ── @Test void createCookingLogShouldPersist() { var household = testHousehold(); var recipe = testRecipe(household, "Spaghetti"); var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); setId(user, UserAccount.class, UUID.randomUUID()); when(recipeRepository.findById(recipe.getId())).thenReturn(Optional.of(recipe)); when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); when(userAccountRepository.findById(user.getId())).thenReturn(Optional.of(user)); when(cookingLogRepository.save(any(CookingLog.class))).thenAnswer(i -> { CookingLog cl = i.getArgument(0); setId(cl, CookingLog.class, UUID.randomUUID()); return cl; }); CookingLogResponse result = planningService.createCookingLog(HOUSEHOLD_ID, user.getId(), new CreateCookingLogRequest(recipe.getId(), LocalDate.of(2026, 4, 7))); assertThat(result.recipeName()).isEqualTo("Spaghetti"); assertThat(result.cookedOn()).isEqualTo(LocalDate.of(2026, 4, 7)); } @Test void listCookingLogsShouldReturnRecent() { var household = testHousehold(); var recipe = testRecipe(household, "Spaghetti"); var user = new UserAccount("sarah@example.com", "Sarah", "hashed"); setId(user, UserAccount.class, UUID.randomUUID()); var log = new CookingLog(recipe, household, LocalDate.of(2026, 4, 7), user); setId(log, CookingLog.class, UUID.randomUUID()); when(cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc(eq(HOUSEHOLD_ID), any())) .thenReturn(List.of(log)); List result = planningService.listCookingLogs(HOUSEHOLD_ID, 30, 0); 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); } // ── Variety preview ── @Test void getVarietyPreviewShouldReturnScoreDeltaForDifferentRecipe() { var household = testHousehold(); var plan = testWeekPlan(household); var planId = plan.getId(); // Plan already has one slot (Mon) with Spaghetti var existingRecipe = testRecipe(household, "Spaghetti"); var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START); setId(slot, WeekPlanSlot.class, UUID.randomUUID()); plan.getSlots().add(slot); // Candidate is Lachsfilet (different recipe, no shared tags/ingredients) var candidate = testRecipe(household, "Lachsfilet"); var candidateId = candidate.getId(); when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan)); when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(candidateId, HOUSEHOLD_ID)) .thenReturn(Optional.of(candidate)); when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty()); when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any())) .thenReturn(List.of()); var result = planningService.getVarietyPreview(HOUSEHOLD_ID, planId, candidateId, WEEK_START.plusDays(1)); // 1 existing slot with no conflicts → currentScore = 10.0 // Adding a different recipe with no tags/ingredients → projectedScore = 10.0, delta = 0 assertThat(result.currentScore()).isEqualTo(10.0); assertThat(result.projectedScore()).isEqualTo(10.0); assertThat(result.scoreDelta()).isEqualTo(0.0); } @Test void getVarietyPreviewShouldReturnNegativeDeltaForDuplicateRecipe() { var household = testHousehold(); var plan = testWeekPlan(household); var planId = plan.getId(); // Plan already has Spaghetti on Mon var existingRecipe = testRecipe(household, "Spaghetti"); var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START); setId(slot, WeekPlanSlot.class, UUID.randomUUID()); plan.getSlots().add(slot); // Candidate is the same Spaghetti recipe → triggers duplicate penalty (wPlanDuplicate = 2.0) when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan)); when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(existingRecipe.getId(), HOUSEHOLD_ID)) .thenReturn(Optional.of(existingRecipe)); when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty()); when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any())) .thenReturn(List.of()); var result = planningService.getVarietyPreview( HOUSEHOLD_ID, planId, existingRecipe.getId(), WEEK_START.plusDays(1)); // currentScore = 10.0 (1 slot, no conflicts) // projectedScore = 10.0 - 1 * 2.0 (duplicate penalty) = 8.0 assertThat(result.currentScore()).isEqualTo(10.0); assertThat(result.projectedScore()).isEqualTo(8.0); assertThat(result.scoreDelta()).isEqualTo(-2.0); } @Test void getVarietyPreviewShouldThrowWhenPlanNotFound() { var planId = UUID.randomUUID(); when(weekPlanRepository.findById(planId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> planningService.getVarietyPreview( HOUSEHOLD_ID, planId, UUID.randomUUID(), WEEK_START)) .isInstanceOf(ResourceNotFoundException.class); } @Test void getVarietyPreviewShouldThrowWhenRecipeNotFound() { 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.getVarietyPreview( HOUSEHOLD_ID, plan.getId(), recipeId, WEEK_START)) .isInstanceOf(ResourceNotFoundException.class); } }