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,280 @@
|
||||
package com.recipeapp.planning;
|
||||
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.planning.dto.*;
|
||||
import com.recipeapp.planning.entity.*;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
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 org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
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 PlanningServiceTest {
|
||||
|
||||
@Mock private WeekPlanRepository weekPlanRepository;
|
||||
@Mock private WeekPlanSlotRepository weekPlanSlotRepository;
|
||||
@Mock private CookingLogRepository cookingLogRepository;
|
||||
@Mock private RecipeRepository recipeRepository;
|
||||
@Mock private HouseholdRepository householdRepository;
|
||||
@Mock private UserAccountRepository userAccountRepository;
|
||||
|
||||
@InjectMocks private PlanningServiceImpl planningService;
|
||||
|
||||
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
|
||||
private static final LocalDate WEEK_START = LocalDate.of(2026, 4, 6); // Monday
|
||||
|
||||
private Household testHousehold() {
|
||||
var h = new Household("Test family", null);
|
||||
setId(h, Household.class, HOUSEHOLD_ID);
|
||||
return h;
|
||||
}
|
||||
|
||||
private WeekPlan testWeekPlan(Household household) {
|
||||
var wp = new WeekPlan(household, WEEK_START);
|
||||
setId(wp, WeekPlan.class, UUID.randomUUID());
|
||||
return wp;
|
||||
}
|
||||
|
||||
private Recipe testRecipe(Household household, String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
|
||||
private <T> void setId(T entity, Class<T> clazz, UUID id) {
|
||||
try {
|
||||
var field = clazz.getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
field.set(entity, id);
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
}
|
||||
|
||||
// ── Week Plan CRUD ──
|
||||
|
||||
@Test
|
||||
void getWeekPlanShouldReturnPlanWithSlots() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var recipe = testRecipe(household, "Spaghetti");
|
||||
var slot = new WeekPlanSlot(plan, recipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.of(plan));
|
||||
|
||||
WeekPlanResponse result = planningService.getWeekPlan(HOUSEHOLD_ID, WEEK_START);
|
||||
|
||||
assertThat(result.weekStart()).isEqualTo(WEEK_START);
|
||||
assertThat(result.slots()).hasSize(1);
|
||||
assertThat(result.slots().getFirst().recipe().name()).isEqualTo("Spaghetti");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWeekPlanShouldThrowWhenNotFound() {
|
||||
when(weekPlanRepository.findByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> planningService.getWeekPlan(HOUSEHOLD_ID, WEEK_START))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWeekPlanShouldPersist() {
|
||||
var household = testHousehold();
|
||||
|
||||
when(weekPlanRepository.existsByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)).thenReturn(false);
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||
when(weekPlanRepository.save(any(WeekPlan.class))).thenAnswer(i -> {
|
||||
WeekPlan wp = i.getArgument(0);
|
||||
setId(wp, WeekPlan.class, UUID.randomUUID());
|
||||
return wp;
|
||||
});
|
||||
|
||||
WeekPlanResponse result = planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START);
|
||||
|
||||
assertThat(result.weekStart()).isEqualTo(WEEK_START);
|
||||
assertThat(result.status()).isEqualTo("draft");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWeekPlanShouldThrowConflictWhenExists() {
|
||||
when(weekPlanRepository.existsByHouseholdIdAndWeekStart(HOUSEHOLD_ID, WEEK_START)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START))
|
||||
.isInstanceOf(ConflictException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWeekPlanShouldThrowWhenNotMonday() {
|
||||
var tuesday = LocalDate.of(2026, 4, 7);
|
||||
|
||||
assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, tuesday))
|
||||
.isInstanceOf(ValidationException.class);
|
||||
}
|
||||
|
||||
// ── Slots ──
|
||||
|
||||
@Test
|
||||
void addSlotShouldCreateSlot() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var recipe = testRecipe(household, "Spaghetti");
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipe.getId(), HOUSEHOLD_ID))
|
||||
.thenReturn(Optional.of(recipe));
|
||||
when(weekPlanSlotRepository.save(any(WeekPlanSlot.class))).thenAnswer(i -> {
|
||||
WeekPlanSlot s = i.getArgument(0);
|
||||
setId(s, WeekPlanSlot.class, UUID.randomUUID());
|
||||
return s;
|
||||
});
|
||||
|
||||
SlotResponse result = planningService.addSlot(HOUSEHOLD_ID, plan.getId(),
|
||||
new CreateSlotRequest(WEEK_START.plusDays(1), recipe.getId()));
|
||||
|
||||
assertThat(result.recipe().name()).isEqualTo("Spaghetti");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSlotShouldSwapRecipe() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var oldRecipe = testRecipe(household, "Spaghetti");
|
||||
var newRecipe = testRecipe(household, "Stir Fry");
|
||||
var slot = new WeekPlanSlot(plan, oldRecipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(weekPlanSlotRepository.findById(slot.getId())).thenReturn(Optional.of(slot));
|
||||
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(newRecipe.getId(), HOUSEHOLD_ID))
|
||||
.thenReturn(Optional.of(newRecipe));
|
||||
|
||||
SlotResponse result = planningService.updateSlot(HOUSEHOLD_ID, plan.getId(), slot.getId(),
|
||||
new UpdateSlotRequest(newRecipe.getId()));
|
||||
|
||||
assertThat(result.recipe().name()).isEqualTo("Stir Fry");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSlotShouldRemoveSlot() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var slot = new WeekPlanSlot(plan, testRecipe(household, "X"), WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(weekPlanSlotRepository.findById(slot.getId())).thenReturn(Optional.of(slot));
|
||||
|
||||
planningService.deleteSlot(HOUSEHOLD_ID, plan.getId(), slot.getId());
|
||||
|
||||
verify(weekPlanSlotRepository).delete(slot);
|
||||
}
|
||||
|
||||
// ── Confirm ──
|
||||
|
||||
@Test
|
||||
void confirmPlanShouldSetStatusAndTimestamp() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var slot = new WeekPlanSlot(plan, testRecipe(household, "X"), WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
|
||||
WeekPlanResponse result = planningService.confirmPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
assertThat(result.status()).isEqualTo("confirmed");
|
||||
assertThat(result.confirmedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmPlanShouldThrowWhenNoSlots() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
|
||||
assertThatThrownBy(() -> planningService.confirmPlan(HOUSEHOLD_ID, plan.getId()))
|
||||
.isInstanceOf(ValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmPlanShouldThrowWhenAlreadyConfirmed() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
plan.setStatus("confirmed");
|
||||
var slot = new WeekPlanSlot(plan, testRecipe(household, "X"), WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
|
||||
assertThatThrownBy(() -> planningService.confirmPlan(HOUSEHOLD_ID, plan.getId()))
|
||||
.isInstanceOf(ValidationException.class);
|
||||
}
|
||||
|
||||
// ── Cooking Logs ──
|
||||
|
||||
@Test
|
||||
void createCookingLogShouldPersist() {
|
||||
var household = testHousehold();
|
||||
var recipe = testRecipe(household, "Spaghetti");
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||
setId(user, UserAccount.class, UUID.randomUUID());
|
||||
|
||||
when(recipeRepository.findById(recipe.getId())).thenReturn(Optional.of(recipe));
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||
when(userAccountRepository.findById(user.getId())).thenReturn(Optional.of(user));
|
||||
when(cookingLogRepository.save(any(CookingLog.class))).thenAnswer(i -> {
|
||||
CookingLog cl = i.getArgument(0);
|
||||
setId(cl, CookingLog.class, UUID.randomUUID());
|
||||
return cl;
|
||||
});
|
||||
|
||||
CookingLogResponse result = planningService.createCookingLog(HOUSEHOLD_ID, user.getId(),
|
||||
new CreateCookingLogRequest(recipe.getId(), LocalDate.of(2026, 4, 7)));
|
||||
|
||||
assertThat(result.recipeName()).isEqualTo("Spaghetti");
|
||||
assertThat(result.cookedOn()).isEqualTo(LocalDate.of(2026, 4, 7));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listCookingLogsShouldReturnRecent() {
|
||||
var household = testHousehold();
|
||||
var recipe = testRecipe(household, "Spaghetti");
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||
setId(user, UserAccount.class, UUID.randomUUID());
|
||||
var log = new CookingLog(recipe, household, LocalDate.of(2026, 4, 7), user);
|
||||
setId(log, CookingLog.class, UUID.randomUUID());
|
||||
|
||||
when(cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc(eq(HOUSEHOLD_ID), any()))
|
||||
.thenReturn(List.of(log));
|
||||
|
||||
List<CookingLogResponse> result = planningService.listCookingLogs(HOUSEHOLD_ID, 30, 0);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.getFirst().recipeName()).isEqualTo("Spaghetti");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user