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:
@@ -0,0 +1,74 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IngredientCategoryControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock private RecipeService recipeService;
|
||||
@Mock private HouseholdResolver householdResolver;
|
||||
|
||||
@InjectMocks private IngredientCategoryController ingredientCategoryController;
|
||||
|
||||
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(ingredientCategoryController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listCategoriesShouldReturn200() throws Exception {
|
||||
var cat = new IngredientCategoryResponse(UUID.randomUUID(), "Produce");
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.listCategories(HOUSEHOLD_ID)).thenReturn(List.of(cat));
|
||||
|
||||
mockMvc.perform(get("/v1/ingredient-categories")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("Produce"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createCategoryShouldReturn201() throws Exception {
|
||||
var request = new IngredientCategoryCreateRequest("Frozen");
|
||||
var response = new IngredientCategoryResponse(UUID.randomUUID(), "Frozen");
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.createCategory(eq(HOUSEHOLD_ID), any(IngredientCategoryCreateRequest.class)))
|
||||
.thenReturn(response);
|
||||
|
||||
mockMvc.perform(post("/v1/ingredient-categories")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.name").value("Frozen"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IngredientControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock private RecipeService recipeService;
|
||||
@Mock private HouseholdResolver householdResolver;
|
||||
|
||||
@InjectMocks private IngredientController ingredientController;
|
||||
|
||||
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(ingredientController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchIngredientsShouldReturn200() throws Exception {
|
||||
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "meat");
|
||||
var ingredient = new IngredientResponse(UUID.randomUUID(), "chicken breast", catRef, false);
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.searchIngredients(HOUSEHOLD_ID, "chick", null))
|
||||
.thenReturn(List.of(ingredient));
|
||||
|
||||
mockMvc.perform(get("/v1/ingredients")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.param("search", "chick"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("chicken breast"))
|
||||
.andExpect(jsonPath("$[0].category.name").value("meat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchIngredientShouldReturn200() throws Exception {
|
||||
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "oil");
|
||||
var ingredientId = UUID.randomUUID();
|
||||
var response = new IngredientResponse(ingredientId, "olive oil", catRef, true);
|
||||
var request = new IngredientPatchRequest(null, true, null);
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.patchIngredient(eq(HOUSEHOLD_ID), eq(ingredientId), any(IngredientPatchRequest.class)))
|
||||
.thenReturn(response);
|
||||
|
||||
mockMvc.perform(patch("/v1/ingredients/{id}", ingredientId)
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.isStaple").value(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RecipeControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock private RecipeService recipeService;
|
||||
@Mock private HouseholdResolver householdResolver;
|
||||
|
||||
@InjectMocks private RecipeController recipeController;
|
||||
|
||||
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
|
||||
private static final UUID RECIPE_ID = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(recipeController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listRecipesShouldReturn200WithPagination() throws Exception {
|
||||
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
|
||||
(short) 4, (short) 45, "medium", true, null);
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(),
|
||||
isNull(), eq(20), eq(0)))
|
||||
.thenReturn(List.of(summary));
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull()))
|
||||
.thenReturn(1L);
|
||||
|
||||
mockMvc.perform(get("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.param("limit", "20")
|
||||
.param("offset", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data[0].name").value("Spaghetti Bolognese"))
|
||||
.andExpect(jsonPath("$.meta.pagination.total").value(1))
|
||||
.andExpect(jsonPath("$.meta.pagination.hasMore").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listRecipesWithFiltersShouldPassParams() throws Exception {
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true),
|
||||
eq(30), eq("-cookTimeMin"), eq(10), eq(5)))
|
||||
.thenReturn(List.of());
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), eq(30)))
|
||||
.thenReturn(0L);
|
||||
|
||||
mockMvc.perform(get("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.param("search", "pasta")
|
||||
.param("effort", "easy")
|
||||
.param("isChildFriendly", "true")
|
||||
.param("cookTimeMin.lte", "30")
|
||||
.param("sort", "-cookTimeMin")
|
||||
.param("limit", "10")
|
||||
.param("offset", "5"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecipeShouldReturn200WithDetail() throws Exception {
|
||||
var detail = sampleDetail();
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.getRecipe(HOUSEHOLD_ID, RECIPE_ID)).thenReturn(detail);
|
||||
|
||||
mockMvc.perform(get("/v1/recipes/{id}", RECIPE_ID)
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("Spaghetti Bolognese"))
|
||||
.andExpect(jsonPath("$.ingredients[0].name").value("spaghetti"))
|
||||
.andExpect(jsonPath("$.steps[0].instruction").value("Boil water."))
|
||||
.andExpect(jsonPath("$.tags[0].name").value("beef"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecipeShouldReturn404WhenNotFound() throws Exception {
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.getRecipe(HOUSEHOLD_ID, RECIPE_ID))
|
||||
.thenThrow(new ResourceNotFoundException("Recipe not found"));
|
||||
|
||||
mockMvc.perform(get("/v1/recipes/{id}", RECIPE_ID)
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeShouldReturn201() throws Exception {
|
||||
var request = sampleCreateRequest();
|
||||
var detail = sampleDetail();
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.createRecipe(eq(HOUSEHOLD_ID), any(RecipeCreateRequest.class)))
|
||||
.thenReturn(detail);
|
||||
|
||||
mockMvc.perform(post("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.name").value("Spaghetti Bolognese"))
|
||||
.andExpect(header().string("Location", "/v1/recipes/" + RECIPE_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipeShouldReturn200() throws Exception {
|
||||
var request = sampleCreateRequest();
|
||||
var detail = sampleDetail();
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.updateRecipe(eq(HOUSEHOLD_ID), eq(RECIPE_ID), any(RecipeCreateRequest.class)))
|
||||
.thenReturn(detail);
|
||||
|
||||
mockMvc.perform(put("/v1/recipes/{id}", RECIPE_ID)
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("Spaghetti Bolognese"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRecipeShouldReturn204() throws Exception {
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
doNothing().when(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID);
|
||||
|
||||
mockMvc.perform(delete("/v1/recipes/{id}", RECIPE_ID)
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID);
|
||||
}
|
||||
|
||||
private RecipeCreateRequest sampleCreateRequest() {
|
||||
var ingredientId = UUID.randomUUID();
|
||||
return new RecipeCreateRequest(
|
||||
"Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
ingredientId, null, new BigDecimal("400"), "g", (short) 1)),
|
||||
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")),
|
||||
List.of(UUID.randomUUID(), UUID.randomUUID()));
|
||||
}
|
||||
|
||||
private RecipeDetailResponse sampleDetail() {
|
||||
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta");
|
||||
return new RecipeDetailResponse(
|
||||
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
|
||||
List.of(new RecipeDetailResponse.IngredientItem(
|
||||
UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)),
|
||||
List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")),
|
||||
List.of(new RecipeDetailResponse.TagItem(UUID.randomUUID(), "beef", "protein")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TagControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock private RecipeService recipeService;
|
||||
@Mock private HouseholdResolver householdResolver;
|
||||
|
||||
@InjectMocks private TagController tagController;
|
||||
|
||||
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(tagController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTagsShouldReturn200() throws Exception {
|
||||
var tag = new TagResponse(UUID.randomUUID(), "chicken", "protein");
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.listTags(HOUSEHOLD_ID)).thenReturn(List.of(tag));
|
||||
|
||||
mockMvc.perform(get("/v1/tags")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("chicken"))
|
||||
.andExpect(jsonPath("$[0].tagType").value("protein"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createTagShouldReturn201() throws Exception {
|
||||
var request = new TagCreateRequest("Thai", "cuisine");
|
||||
var response = new TagResponse(UUID.randomUUID(), "Thai", "cuisine");
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.createTag(eq(HOUSEHOLD_ID), any(TagCreateRequest.class)))
|
||||
.thenReturn(response);
|
||||
|
||||
mockMvc.perform(post("/v1/tags")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.name").value("Thai"))
|
||||
.andExpect(jsonPath("$.tagType").value("cuisine"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user