Implement household domain with outside-in TDD (15 tests)

Controller (5 tests): create household, get mine, get members,
create invite, accept invite.

Service (10 tests): household creation with planner role + seed
data (categories, tags, staple ingredients), conflict when already
in household, invite code generation with 48h expiry, accept invite
with expired/used/conflict validation.

Also includes:
- Household, HouseholdMember, HouseholdInvite JPA entities
- HouseholdInvite repository with findByInviteCode
- Ingredient, IngredientCategory, Tag entities + repositories
  (created early for seed data, will be extended in recipe domain)
- Fixed BackendApplicationTests to use AbstractIntegrationTest

Total: 38 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 21:31:00 +02:00
parent 3253dcfec2
commit 4f457303d8
22 changed files with 870 additions and 3 deletions

View File

@@ -1,10 +1,8 @@
package com.recipeapp;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class BackendApplicationTests {
class BackendApplicationTests extends AbstractIntegrationTest {
@Test
void contextLoads() {

View File

@@ -0,0 +1,119 @@
package com.recipeapp.household;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.household.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.time.Instant;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@ExtendWith(MockitoExtension.class)
class HouseholdControllerTest {
private MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@Mock
private HouseholdService householdService;
@InjectMocks
private HouseholdController householdController;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(householdController)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void createHouseholdShouldReturn201() throws Exception {
var request = new CreateHouseholdRequest("Smith family");
var member = new MemberResponse(UUID.randomUUID(), "Sarah", "planner", Instant.now());
var response = new HouseholdResponse(UUID.randomUUID(), "Smith family", List.of(member));
when(householdService.createHousehold(eq("sarah@example.com"), any(CreateHouseholdRequest.class)))
.thenReturn(response);
mockMvc.perform(post("/v1/households")
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.name").value("Smith family"))
.andExpect(jsonPath("$.data.members[0].role").value("planner"));
}
@Test
void getMyHouseholdShouldReturn200() throws Exception {
var response = new HouseholdResponse(UUID.randomUUID(), "Smith family", List.of());
when(householdService.getMyHousehold("sarah@example.com")).thenReturn(response);
mockMvc.perform(get("/v1/households/mine")
.principal(() -> "sarah@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.name").value("Smith family"));
}
@Test
void getMembersShouldReturn200() throws Exception {
var members = List.of(
new MemberResponse(UUID.randomUUID(), "Sarah", "planner", Instant.now()),
new MemberResponse(UUID.randomUUID(), "Tom", "member", Instant.now()));
when(householdService.getMembers("sarah@example.com")).thenReturn(members);
mockMvc.perform(get("/v1/households/mine/members")
.principal(() -> "sarah@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].role").value("planner"));
}
@Test
void createInviteShouldReturn201() throws Exception {
var response = new InviteResponse("ABC12XYZ", "https://yourapp.com/join/ABC12XYZ",
Instant.now().plusSeconds(172800));
when(householdService.createInvite("sarah@example.com")).thenReturn(response);
mockMvc.perform(post("/v1/households/mine/invites")
.principal(() -> "sarah@example.com"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.inviteCode").value("ABC12XYZ"));
}
@Test
void acceptInviteShouldReturn200() throws Exception {
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
when(householdService.acceptInvite("tom@example.com", "ABC12XYZ")).thenReturn(response);
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
.principal(() -> "tom@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
.andExpect(jsonPath("$.data.role").value("member"));
}
}

View File

@@ -0,0 +1,194 @@
package com.recipeapp.household;
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.dto.*;
import com.recipeapp.household.entity.Household;
import com.recipeapp.household.entity.HouseholdInvite;
import com.recipeapp.household.entity.HouseholdMember;
import com.recipeapp.recipe.IngredientCategoryRepository;
import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.TagRepository;
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 java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class HouseholdServiceTest {
@Mock private UserAccountRepository userAccountRepository;
@Mock private HouseholdRepository householdRepository;
@Mock private HouseholdMemberRepository householdMemberRepository;
@Mock private HouseholdInviteRepository householdInviteRepository;
@Mock private IngredientRepository ingredientRepository;
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
@Mock private TagRepository tagRepository;
@InjectMocks
private HouseholdServiceImpl householdService;
private UserAccount testUser() {
return new UserAccount("sarah@example.com", "Sarah", "hashed");
}
@Test
void createHouseholdShouldCreateWithPlannerRole() {
var user = testUser();
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.empty());
when(householdRepository.save(any(Household.class))).thenAnswer(i -> i.getArgument(0));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
HouseholdResponse result = householdService.createHousehold(
"sarah@example.com", new CreateHouseholdRequest("Smith family"));
assertThat(result.name()).isEqualTo("Smith family");
assertThat(result.members()).hasSize(1);
assertThat(result.members().getFirst().role()).isEqualTo("planner");
verify(householdRepository).save(any(Household.class));
verify(householdMemberRepository).save(any(HouseholdMember.class));
}
@Test
void createHouseholdShouldSeedDefaultData() {
var user = testUser();
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.empty());
when(householdRepository.save(any(Household.class))).thenAnswer(i -> i.getArgument(0));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
householdService.createHousehold("sarah@example.com", new CreateHouseholdRequest("Smith family"));
verify(ingredientCategoryRepository).saveAll(anyList());
verify(tagRepository).saveAll(anyList());
verify(ingredientRepository).saveAll(anyList());
}
@Test
void createHouseholdShouldThrowConflictWhenUserAlreadyInHousehold() {
var user = testUser();
var household = new Household("Existing", user);
var member = new HouseholdMember(household, user, "planner");
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
assertThatThrownBy(() -> householdService.createHousehold(
"sarah@example.com", new CreateHouseholdRequest("New")))
.isInstanceOf(ConflictException.class);
}
@Test
void getMyHouseholdShouldReturnHouseholdWithMembers() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdMemberRepository.findByHouseholdId(any())).thenReturn(List.of(member));
HouseholdResponse result = householdService.getMyHousehold("sarah@example.com");
assertThat(result.name()).isEqualTo("Smith family");
}
@Test
void getMyHouseholdShouldThrowWhenNotInHousehold() {
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.getMyHousehold("sarah@example.com"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void createInviteShouldGenerateCodeWith48hExpiry() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
InviteResponse result = householdService.createInvite("sarah@example.com");
assertThat(result.inviteCode()).isNotBlank();
assertThat(result.expiresAt()).isAfter(Instant.now().plusSeconds(172000));
verify(householdInviteRepository).save(any(HouseholdInvite.class));
}
@Test
void acceptInviteShouldAddUserAsMember() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
AcceptInviteResponse result = householdService.acceptInvite("tom@example.com", "ABC12XYZ");
assertThat(result.householdName()).isEqualTo("Smith family");
assertThat(result.role()).isEqualTo("member");
}
@Test
void acceptInviteShouldThrowWhenAlreadyInHousehold() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Other", user);
var member = new HouseholdMember(household, user, "member");
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(member));
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "ABC12XYZ"))
.isInstanceOf(ConflictException.class);
}
@Test
void acceptInviteShouldThrowWhenCodeExpired() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "EXPIRED"))
.isInstanceOf(ValidationException.class);
}
@Test
void acceptInviteShouldThrowWhenCodeAlreadyUsed() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
invite.setStatus("used");
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123"))
.isInstanceOf(ConflictException.class);
}
}