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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user