Rewrite variety score and suggestions with configurable scoring

- Add VarietyScoreConfig entity, repository, and V020 migration for
  per-household scoring weights and configurable tag types
- Rewrite getVarietyScore: tag-type repeats on consecutive days,
  non-staple ingredient overlaps, cooking log history, plan duplicates
- Rewrite getSuggestions: simulate variety score for each candidate,
  add tag filter (AND, case-insensitive) and configurable topN param
- Update SuggestionResponse to return simulatedScore instead of
  fitReasons/warnings, update VarietyScoreResponse to new shape
- Seed default VarietyScoreConfig on household creation
- Extend test suite across all domains (+270 tests, all passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 10:33:11 +02:00
parent 9ec703abcd
commit 8221a1fd41
21 changed files with 3225 additions and 110 deletions

View File

@@ -9,6 +9,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);
}
}