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>
271 lines
12 KiB
Java
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());
|
|
}
|
|
|
|
}
|