Files
mealprep/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java
Marcel Raddatz b673a466e9 feat(planner): replace simulatedScore with scoreDelta + hasConflict in SuggestionItem
SuggestionItem now exposes scoreDelta (simulatedScore − currentScore) and
hasConflict (scoreDelta ≤ 0) so the frontend can render badges without
needing to pass currentVarietyScore as a separate prop.

PlanningService.getSuggestions() computes currentScore once per request
and derives scoreDelta + hasConflict per candidate. Sorting is unchanged
(scoreDelta desc = simulatedScore desc since currentScore is constant).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00

271 lines
12 KiB
Java

package com.recipeapp.planning;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.HouseholdRoleInterceptor;
import com.recipeapp.common.ValidationException;
import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver;
import org.junit.jupiter.api.AfterEach;
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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant;
import java.time.LocalDate;
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 WeekPlanControllerTest {
private MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Mock private PlanningService planningService;
@Mock private HouseholdResolver householdResolver;
@InjectMocks private WeekPlanController weekPlanController;
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
private static final UUID PLAN_ID = UUID.randomUUID();
private static final UUID SLOT_ID = UUID.randomUUID();
private static final LocalDate WEEK_START = LocalDate.of(2026, 4, 6);
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void getWeekPlanShouldReturn200() throws Exception {
var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.getWeekPlan(HOUSEHOLD_ID, WEEK_START)).thenReturn(plan);
mockMvc.perform(get("/v1/week-plans")
.principal(() -> "sarah@example.com")
.param("weekStart", "2026-04-06"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.weekStart").value("2026-04-06"))
.andExpect(jsonPath("$.status").value("draft"));
}
@Test
void createWeekPlanShouldReturn201() throws Exception {
var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START)).thenReturn(plan);
mockMvc.perform(post("/v1/week-plans")
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new CreateWeekPlanRequest(WEEK_START))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.weekStart").value("2026-04-06"));
}
@Test
void addSlotShouldReturn201() throws Exception {
var recipeId = UUID.randomUUID();
var slotRecipe = new SlotResponse.SlotRecipe(recipeId, "Spaghetti", "medium", (short) 45, null);
var slot = new SlotResponse(SLOT_ID, WEEK_START.plusDays(1), slotRecipe);
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.addSlot(eq(HOUSEHOLD_ID), eq(PLAN_ID), any(CreateSlotRequest.class)))
.thenReturn(slot);
mockMvc.perform(post("/v1/week-plans/{id}/slots", PLAN_ID)
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new CreateSlotRequest(WEEK_START.plusDays(1), recipeId))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.recipe.name").value("Spaghetti"));
}
@Test
void updateSlotShouldReturn200() throws Exception {
var recipeId = UUID.randomUUID();
var slotRecipe = new SlotResponse.SlotRecipe(recipeId, "Stir Fry", "easy", (short) 15, null);
var slot = new SlotResponse(SLOT_ID, WEEK_START.plusDays(1), slotRecipe);
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.updateSlot(eq(HOUSEHOLD_ID), eq(PLAN_ID), eq(SLOT_ID),
any(UpdateSlotRequest.class))).thenReturn(slot);
mockMvc.perform(patch("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new UpdateSlotRequest(recipeId))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.recipe.name").value("Stir Fry"));
}
@Test
void deleteSlotShouldReturn204() throws Exception {
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
doNothing().when(planningService).deleteSlot(HOUSEHOLD_ID, PLAN_ID, SLOT_ID);
mockMvc.perform(delete("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
.principal(() -> "sarah@example.com"))
.andExpect(status().isNoContent());
}
@Test
void confirmPlanShouldReturn200() throws Exception {
var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "confirmed", Instant.now(), List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.confirmPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(plan);
mockMvc.perform(post("/v1/week-plans/{id}/confirm", PLAN_ID)
.principal(() -> "sarah@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("confirmed"));
}
@Test
void confirmPlanShouldReturn422WhenNoSlots() throws Exception {
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.confirmPlan(HOUSEHOLD_ID, PLAN_ID))
.thenThrow(new ValidationException("Plan has no slots"));
mockMvc.perform(post("/v1/week-plans/{id}/confirm", PLAN_ID)
.principal(() -> "sarah@example.com"))
.andExpect(status().isUnprocessableEntity());
}
@Test
void getSuggestionsShouldReturn200() throws Exception {
var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null);
var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
var response = new SuggestionResponse(List.of(item));
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.getSuggestions(HOUSEHOLD_ID, PLAN_ID, WEEK_START.plusDays(2),
List.of(), null))
.thenReturn(response);
mockMvc.perform(get("/v1/week-plans/{id}/suggestions", PLAN_ID)
.principal(() -> "sarah@example.com")
.param("slotDate", "2026-04-08"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry"))
.andExpect(jsonPath("$.suggestions[0].scoreDelta").value(1.5))
.andExpect(jsonPath("$.suggestions[0].hasConflict").value(false));
}
@Test
void getVarietyScoreShouldReturn200() throws Exception {
var response = new VarietyScoreResponse(7.5, List.of(), List.of(),
List.of(), List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.getVarietyScore(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response);
mockMvc.perform(get("/v1/week-plans/{id}/variety-score", PLAN_ID)
.principal(() -> "sarah@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.score").value(7.5));
}
@Test
void getVarietyPreviewShouldReturn200() throws Exception {
var recipeId = UUID.randomUUID();
var response = new VarietyPreviewResponse(8.0, 9.0, 1.0);
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.getVarietyPreview(HOUSEHOLD_ID, PLAN_ID, recipeId, WEEK_START.plusDays(2)))
.thenReturn(response);
mockMvc.perform(get("/v1/week-plans/{planId}/variety-preview", PLAN_ID)
.principal(() -> "sarah@example.com")
.param("recipeId", recipeId.toString())
.param("date", "2026-04-08"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.currentScore").value(8.0))
.andExpect(jsonPath("$.projectedScore").value(9.0))
.andExpect(jsonPath("$.scoreDelta").value(1.0));
}
@Test
void addSlotShouldReturn403ForMemberRole() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
var recipeId = UUID.randomUUID();
mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/slots", PLAN_ID)
.principal(() -> "member@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new CreateSlotRequest(WEEK_START.plusDays(1), recipeId))))
.andExpect(status().isForbidden());
}
@Test
void updateSlotShouldReturn403ForMemberRole() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
var recipeId = UUID.randomUUID();
mockMvcWithInterceptor.perform(patch("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
.principal(() -> "member@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new UpdateSlotRequest(recipeId))))
.andExpect(status().isForbidden());
}
@Test
void deleteSlotShouldReturn403ForMemberRole() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
mockMvcWithInterceptor.perform(delete("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
.principal(() -> "member@example.com"))
.andExpect(status().isForbidden());
}
}