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 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 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 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); } // ── 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 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 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 result = recipeService.searchIngredients(HOUSEHOLD_ID, null, null); assertThat(result).hasSize(1); } @Test void searchIngredientsShouldReturnEmptyListWhenNoMatches() { when(ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(HOUSEHOLD_ID, "xyz")) .thenReturn(List.of()); List 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 result = recipeService.listTags(HOUSEHOLD_ID); assertThat(result).isEmpty(); } }