Files
mealprep/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java
Marcel Raddatz ea7113ec53 fix(backend): add role guard to variety-preview and extract shared scoring method
- Add @RequiresHouseholdRole("member") to GET /{planId}/variety-preview endpoint
  to require household membership (was accessible to any authenticated user)
- Extract scoreFromSimulatedSlots() private method eliminating duplicate logic
  between simulateVarietyScore() and the old computeCurrentScore()
- Fix loose variety preview test assertions (isBetween → exact assertEquals)
- Add test verifying negative scoreDelta when candidate is a duplicate recipe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:11:45 +02:00

536 lines
22 KiB
Java

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 <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); }
}
// ── 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<CookingLogResponse> 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);
}
}