Implement Recipe, Planning, Shopping, Pantry, and Admin domains

Outside-in TDD for all 5 remaining domains (128 tests total):
- Recipe: CRUD, ingredients autocomplete/patch, tags, categories (27 tests)
- Planning: week plans, slots, confirm, suggestions, variety score, cooking logs (24 tests)
- Shopping: generate from plan, publish, check/add/remove items (15 tests)
- Pantry: CRUD with expiry sorting (11 tests)
- Admin: user management, password reset, audit logging (13 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 21:56:51 +02:00
parent 4f457303d8
commit 9ec703abcd
88 changed files with 5267 additions and 0 deletions

View File

@@ -0,0 +1,347 @@
package com.recipeapp.recipe;
import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.household.entity.Household;
import com.recipeapp.recipe.dto.*;
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 java.math.BigDecimal;
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 RecipeServiceTest {
@Mock private RecipeRepository recipeRepository;
@Mock private IngredientRepository ingredientRepository;
@Mock private TagRepository tagRepository;
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
@Mock private HouseholdRepository householdRepository;
@InjectMocks private RecipeServiceImpl recipeService;
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
private Household testHousehold() {
var h = new Household("Test family", null);
try {
var field = Household.class.getDeclaredField("id");
field.setAccessible(true);
field.set(h, HOUSEHOLD_ID);
} catch (Exception e) { throw new RuntimeException(e); }
return h;
}
private Recipe testRecipe(Household household) {
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true);
try {
var field = Recipe.class.getDeclaredField("id");
field.setAccessible(true);
field.set(r, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
return r;
}
private Ingredient testIngredient(Household household, String name) {
var ing = new Ingredient(household, name, false);
try {
var field = Ingredient.class.getDeclaredField("id");
field.setAccessible(true);
field.set(ing, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
return ing;
}
private Tag testTag(Household household, String name, String type) {
var tag = new Tag(household, name, type);
try {
var field = Tag.class.getDeclaredField("id");
field.setAccessible(true);
field.set(tag, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
return tag;
}
// ── Recipe CRUD ──
@Test
void getRecipeShouldReturnDetail() {
var household = testHousehold();
var recipe = testRecipe(household);
var ingredient = testIngredient(household, "spaghetti");
recipe.getIngredients().add(new RecipeIngredient(recipe, ingredient, new BigDecimal("400"), "g", (short) 1));
recipe.getSteps().add(new RecipeStep(recipe, (short) 1, "Boil water."));
var tag = testTag(household, "beef", "protein");
recipe.getTags().add(tag);
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipe.getId(), HOUSEHOLD_ID))
.thenReturn(Optional.of(recipe));
RecipeDetailResponse result = recipeService.getRecipe(HOUSEHOLD_ID, recipe.getId());
assertThat(result.name()).isEqualTo("Spaghetti Bolognese");
assertThat(result.ingredients()).hasSize(1);
assertThat(result.ingredients().getFirst().name()).isEqualTo("spaghetti");
assertThat(result.steps()).hasSize(1);
assertThat(result.tags()).hasSize(1);
}
@Test
void getRecipeShouldThrowWhenNotFound() {
var id = UUID.randomUUID();
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(id, HOUSEHOLD_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> recipeService.getRecipe(HOUSEHOLD_ID, id))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void createRecipeShouldPersistWithIngredientsStepsTags() {
var household = testHousehold();
var ingredient = testIngredient(household, "spaghetti");
var tag = testTag(household, "beef", "protein");
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient));
when(tagRepository.findAllById(List.of(tag.getId()))).thenReturn(List.of(tag));
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(
"Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
List.of(new RecipeCreateRequest.IngredientEntry(
ingredient.getId(), null, new BigDecimal("400"), "g", (short) 1)),
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")),
List.of(tag.getId()));
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
assertThat(result.name()).isEqualTo("Spaghetti Bolognese");
assertThat(result.id()).isNotNull();
verify(recipeRepository).save(any(Recipe.class));
}
@Test
void createRecipeShouldCreateNewIngredientWhenNameProvided() {
var household = testHousehold();
var tag = testTag(household, "beef", "protein");
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
when(ingredientRepository.save(any(Ingredient.class))).thenAnswer(i -> {
Ingredient ing = i.getArgument(0);
try {
var field = Ingredient.class.getDeclaredField("id");
field.setAccessible(true);
field.set(ing, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
return ing;
});
when(tagRepository.findAllById(List.of(tag.getId()))).thenReturn(List.of(tag));
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(
"Carbonara", (short) 2, (short) 30, "medium", false, null,
List.of(new RecipeCreateRequest.IngredientEntry(
null, "pancetta", new BigDecimal("100"), "g", (short) 1)),
List.of(),
List.of(tag.getId()));
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
assertThat(result.name()).isEqualTo("Carbonara");
verify(ingredientRepository).save(any(Ingredient.class));
}
@Test
void updateRecipeShouldReplaceChildren() {
var household = testHousehold();
var recipe = testRecipe(household);
var ingredient = testIngredient(household, "rice");
var tag = testTag(household, "chicken", "protein");
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipe.getId(), HOUSEHOLD_ID))
.thenReturn(Optional.of(recipe));
when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient));
when(tagRepository.findAllById(List.of(tag.getId()))).thenReturn(List.of(tag));
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0));
var request = new RecipeCreateRequest(
"Chicken Rice", (short) 3, (short) 25, "easy", true, null,
List.of(new RecipeCreateRequest.IngredientEntry(
ingredient.getId(), null, new BigDecimal("300"), "g", (short) 1)),
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Cook rice.")),
List.of(tag.getId()));
RecipeDetailResponse result = recipeService.updateRecipe(HOUSEHOLD_ID, recipe.getId(), request);
assertThat(result.name()).isEqualTo("Chicken Rice");
assertThat(result.serves()).isEqualTo((short) 3);
}
@Test
void deleteRecipeShouldSoftDelete() {
var household = testHousehold();
var recipe = testRecipe(household);
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipe.getId(), HOUSEHOLD_ID))
.thenReturn(Optional.of(recipe));
recipeService.deleteRecipe(HOUSEHOLD_ID, recipe.getId());
assertThat(recipe.getDeletedAt()).isNotNull();
}
// ── Ingredients ──
@Test
void searchIngredientsShouldReturnMatches() {
var household = testHousehold();
var ingredient = testIngredient(household, "chicken breast");
when(ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(HOUSEHOLD_ID, "chick"))
.thenReturn(List.of(ingredient));
List<IngredientResponse> result = recipeService.searchIngredients(HOUSEHOLD_ID, "chick", null);
assertThat(result).hasSize(1);
assertThat(result.getFirst().name()).isEqualTo("chicken breast");
}
@Test
void patchIngredientShouldUpdateFields() {
var household = testHousehold();
var ingredient = testIngredient(household, "olive oil");
when(ingredientRepository.findById(ingredient.getId())).thenReturn(Optional.of(ingredient));
var request = new IngredientPatchRequest("extra virgin olive oil", true, null);
IngredientResponse result = recipeService.patchIngredient(HOUSEHOLD_ID, ingredient.getId(), request);
assertThat(result.name()).isEqualTo("extra virgin olive oil");
assertThat(result.isStaple()).isTrue();
}
// ── Tags ──
@Test
void listTagsShouldReturnAll() {
var household = testHousehold();
var tag = testTag(household, "chicken", "protein");
when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of(tag));
List<TagResponse> result = recipeService.listTags(HOUSEHOLD_ID);
assertThat(result).hasSize(1);
assertThat(result.getFirst().name()).isEqualTo("chicken");
}
@Test
void createTagShouldPersist() {
var household = testHousehold();
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
when(tagRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "Thai")).thenReturn(false);
when(tagRepository.save(any(Tag.class))).thenAnswer(i -> {
Tag t = i.getArgument(0);
try {
var field = Tag.class.getDeclaredField("id");
field.setAccessible(true);
field.set(t, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
return t;
});
TagResponse result = recipeService.createTag(HOUSEHOLD_ID, new TagCreateRequest("Thai", "cuisine"));
assertThat(result.name()).isEqualTo("Thai");
assertThat(result.tagType()).isEqualTo("cuisine");
}
@Test
void createTagShouldThrowConflictWhenNameExists() {
when(tagRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "Chicken")).thenReturn(true);
assertThatThrownBy(() -> recipeService.createTag(HOUSEHOLD_ID, new TagCreateRequest("Chicken", "protein")))
.isInstanceOf(ConflictException.class);
}
// ── Ingredient Categories ──
@Test
void listCategoriesShouldReturnAllSorted() {
var household = testHousehold();
var cat = new IngredientCategory(household, "Produce", (short) 1);
try {
var field = IngredientCategory.class.getDeclaredField("id");
field.setAccessible(true);
field.set(cat, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
when(ingredientCategoryRepository.findByHouseholdIdOrderBySortOrder(HOUSEHOLD_ID))
.thenReturn(List.of(cat));
List<IngredientCategoryResponse> result = recipeService.listCategories(HOUSEHOLD_ID);
assertThat(result).hasSize(1);
assertThat(result.getFirst().name()).isEqualTo("Produce");
}
@Test
void createCategoryShouldPersist() {
var household = testHousehold();
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
when(ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "Frozen"))
.thenReturn(false);
when(ingredientCategoryRepository.countByHouseholdId(HOUSEHOLD_ID)).thenReturn(8L);
when(ingredientCategoryRepository.save(any(IngredientCategory.class))).thenAnswer(i -> {
IngredientCategory c = i.getArgument(0);
try {
var field = IngredientCategory.class.getDeclaredField("id");
field.setAccessible(true);
field.set(c, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
return c;
});
IngredientCategoryResponse result = recipeService.createCategory(
HOUSEHOLD_ID, new IngredientCategoryCreateRequest("Frozen"));
assertThat(result.name()).isEqualTo("Frozen");
}
@Test
void createCategoryShouldThrowConflictWhenNameExists() {
when(ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(HOUSEHOLD_ID, "Produce"))
.thenReturn(true);
assertThatThrownBy(() -> recipeService.createCategory(
HOUSEHOLD_ID, new IngredientCategoryCreateRequest("Produce")))
.isInstanceOf(ConflictException.class);
}
}