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:
2026-04-01 21:56:51 +02:00
parent 4f457303d8
commit 9ec703abcd
88 changed files with 5267 additions and 0 deletions

View File

@@ -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");
}
}