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", null); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(), eq(20), eq(0))) .thenReturn(List.of(summary)); when(recipeService.countRecipes(eq(HOUSEHOLD_ID), 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(30), eq("-cookTimeMin"), eq(10), eq(5))) .thenReturn(List.of()); when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(30))) .thenReturn(0L); mockMvc.perform(get("/v1/recipes") .principal(() -> "sarah@example.com") .param("search", "pasta") .param("effort", "easy") .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", 4, 45, "medium", 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", 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"))); } }