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

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