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:
@@ -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() {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user