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

@@ -0,0 +1,56 @@
package com.recipeapp.household;
import com.recipeapp.common.ApiResponse;
import com.recipeapp.household.dto.*;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.List;
@RestController
@RequestMapping("/v1")
public class HouseholdController {
private final HouseholdService householdService;
public HouseholdController(HouseholdService householdService) {
this.householdService = householdService;
}
@PostMapping("/households")
public ResponseEntity<ApiResponse<HouseholdResponse>> createHousehold(
Principal principal,
@Valid @RequestBody CreateHouseholdRequest request) {
HouseholdResponse response = householdService.createHousehold(principal.getName(), request);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
}
@GetMapping("/households/mine")
public ResponseEntity<ApiResponse<HouseholdResponse>> getMyHousehold(Principal principal) {
HouseholdResponse response = householdService.getMyHousehold(principal.getName());
return ResponseEntity.ok(ApiResponse.success(response));
}
@GetMapping("/households/mine/members")
public ResponseEntity<List<MemberResponse>> getMembers(Principal principal) {
List<MemberResponse> members = householdService.getMembers(principal.getName());
return ResponseEntity.ok(members);
}
@PostMapping("/households/mine/invites")
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
InviteResponse response = householdService.createInvite(principal.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
}
@PostMapping("/invites/{code}/accept")
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
Principal principal,
@PathVariable String code) {
AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@@ -0,0 +1,11 @@
package com.recipeapp.household;
import com.recipeapp.household.entity.HouseholdInvite;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
Optional<HouseholdInvite> findByInviteCode(String inviteCode);
}

View File

@@ -3,9 +3,11 @@ package com.recipeapp.household;
import com.recipeapp.household.entity.HouseholdMember;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
List<HouseholdMember> findByHouseholdId(UUID householdId);
}

View File

@@ -0,0 +1,9 @@
package com.recipeapp.household;
import com.recipeapp.household.entity.Household;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface HouseholdRepository extends JpaRepository<Household, UUID> {
}

View File

@@ -0,0 +1,13 @@
package com.recipeapp.household;
import com.recipeapp.household.dto.*;
import java.util.List;
public interface HouseholdService {
HouseholdResponse createHousehold(String userEmail, CreateHouseholdRequest request);
HouseholdResponse getMyHousehold(String userEmail);
List<MemberResponse> getMembers(String userEmail);
InviteResponse createInvite(String userEmail);
AcceptInviteResponse acceptInvite(String userEmail, String code);
}

View File

@@ -0,0 +1,205 @@
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 com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Tag;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
@Service
public class HouseholdServiceImpl implements HouseholdService {
private final UserAccountRepository userAccountRepository;
private final HouseholdRepository householdRepository;
private final HouseholdMemberRepository householdMemberRepository;
private final HouseholdInviteRepository householdInviteRepository;
private final IngredientRepository ingredientRepository;
private final IngredientCategoryRepository ingredientCategoryRepository;
private final TagRepository tagRepository;
private static final SecureRandom RANDOM = new SecureRandom();
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
public HouseholdServiceImpl(UserAccountRepository userAccountRepository,
HouseholdRepository householdRepository,
HouseholdMemberRepository householdMemberRepository,
HouseholdInviteRepository householdInviteRepository,
IngredientRepository ingredientRepository,
IngredientCategoryRepository ingredientCategoryRepository,
TagRepository tagRepository) {
this.userAccountRepository = userAccountRepository;
this.householdRepository = householdRepository;
this.householdMemberRepository = householdMemberRepository;
this.householdInviteRepository = householdInviteRepository;
this.ingredientRepository = ingredientRepository;
this.ingredientCategoryRepository = ingredientCategoryRepository;
this.tagRepository = tagRepository;
}
@Override
@Transactional
public HouseholdResponse createHousehold(String userEmail, CreateHouseholdRequest request) {
UserAccount user = findUser(userEmail);
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
throw new ConflictException("User is already in a household");
}
Household household = householdRepository.save(new Household(request.name(), user));
HouseholdMember member = householdMemberRepository.save(
new HouseholdMember(household, user, "planner"));
seedDefaultData(household);
return toHouseholdResponse(household, List.of(member));
}
@Override
@Transactional(readOnly = true)
public HouseholdResponse getMyHousehold(String userEmail) {
HouseholdMember member = findMembership(userEmail);
Household household = member.getHousehold();
List<HouseholdMember> members = householdMemberRepository.findByHouseholdId(household.getId());
return toHouseholdResponse(household, members);
}
@Override
@Transactional(readOnly = true)
public List<MemberResponse> getMembers(String userEmail) {
HouseholdMember member = findMembership(userEmail);
return householdMemberRepository.findByHouseholdId(member.getHousehold().getId())
.stream()
.map(this::toMemberResponse)
.toList();
}
@Override
@Transactional
public InviteResponse createInvite(String userEmail) {
HouseholdMember member = findMembership(userEmail);
Household household = member.getHousehold();
String code = generateInviteCode();
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
HouseholdInvite invite = householdInviteRepository.save(
new HouseholdInvite(household, code, expiresAt));
return new InviteResponse(
invite.getInviteCode(),
"https://yourapp.com/join/" + invite.getInviteCode(),
invite.getExpiresAt());
}
@Override
@Transactional
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
UserAccount user = findUser(userEmail);
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
throw new ConflictException("User is already in a household");
}
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
if ("used".equals(invite.getStatus())) {
throw new ConflictException("Invite code already used");
}
if (invite.getExpiresAt().isBefore(Instant.now())) {
throw new ValidationException("Invite code has expired");
}
invite.setStatus("used");
householdInviteRepository.save(invite);
Household household = invite.getHousehold();
householdMemberRepository.save(new HouseholdMember(household, user, "member"));
return new AcceptInviteResponse(household.getId(), household.getName(), "member");
}
private void seedDefaultData(Household household) {
var categories = ingredientCategoryRepository.saveAll(List.of(
new IngredientCategory(household, "Produce", (short) 1),
new IngredientCategory(household, "Fish & Meat", (short) 2),
new IngredientCategory(household, "Dairy & Eggs", (short) 3),
new IngredientCategory(household, "Dry Goods & Pasta", (short) 4),
new IngredientCategory(household, "Canned & Jarred", (short) 5),
new IngredientCategory(household, "Sauces & Condiments", (short) 6),
new IngredientCategory(household, "Frozen", (short) 7),
new IngredientCategory(household, "Bakery & Bread", (short) 8)));
tagRepository.saveAll(List.of(
new Tag(household, "Chicken", "protein"),
new Tag(household, "Fish", "protein"),
new Tag(household, "Beef", "protein"),
new Tag(household, "Pork", "protein"),
new Tag(household, "Vegetarian", "dietary"),
new Tag(household, "Vegan", "dietary"),
new Tag(household, "Pasta", "cuisine"),
new Tag(household, "Quick meal", "other"),
new Tag(household, "Child-friendly", "other")));
ingredientRepository.saveAll(List.of(
new Ingredient(household, "Salt", true),
new Ingredient(household, "Pepper", true),
new Ingredient(household, "Olive oil", true),
new Ingredient(household, "Butter", true),
new Ingredient(household, "Garlic", true),
new Ingredient(household, "Onion", true),
new Ingredient(household, "Sugar", true),
new Ingredient(household, "Flour", true),
new Ingredient(household, "Rice", true),
new Ingredient(household, "Pasta", true)));
}
private String generateInviteCode() {
var sb = new StringBuilder(8);
for (int i = 0; i < 8; i++) {
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
private UserAccount findUser(String email) {
return userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
}
private HouseholdMember findMembership(String email) {
return householdMemberRepository.findByUserEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
}
private HouseholdResponse toHouseholdResponse(Household household, List<HouseholdMember> members) {
return new HouseholdResponse(
household.getId(),
household.getName(),
members.stream().map(this::toMemberResponse).toList());
}
private MemberResponse toMemberResponse(HouseholdMember member) {
return new MemberResponse(
member.getUser().getId(),
member.getUser().getDisplayName(),
member.getRole(),
member.getJoinedAt());
}
}

View File

@@ -0,0 +1,9 @@
package com.recipeapp.household.dto;
import java.util.UUID;
public record AcceptInviteResponse(
UUID householdId,
String householdName,
String role
) {}

View File

@@ -0,0 +1,8 @@
package com.recipeapp.household.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateHouseholdRequest(
@NotBlank @Size(max = 100) String name
) {}

View File

@@ -0,0 +1,10 @@
package com.recipeapp.household.dto;
import java.util.List;
import java.util.UUID;
public record HouseholdResponse(
UUID id,
String name,
List<MemberResponse> members
) {}

View File

@@ -0,0 +1,9 @@
package com.recipeapp.household.dto;
import java.time.Instant;
public record InviteResponse(
String inviteCode,
String shareUrl,
Instant expiresAt
) {}

View File

@@ -0,0 +1,11 @@
package com.recipeapp.household.dto;
import java.time.Instant;
import java.util.UUID;
public record MemberResponse(
UUID userId,
String displayName,
String role,
Instant joinedAt
) {}

View File

@@ -0,0 +1,42 @@
package com.recipeapp.household.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "household_invite")
public class HouseholdInvite {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@Column(name = "invite_code", nullable = false, unique = true, length = 20)
private String inviteCode;
@Column(nullable = false, length = 10)
private String status = "pending";
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
protected HouseholdInvite() {}
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
this.household = household;
this.inviteCode = inviteCode;
this.expiresAt = expiresAt;
}
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public String getInviteCode() { return inviteCode; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Instant getExpiresAt() { return expiresAt; }
}

View File

@@ -0,0 +1,11 @@
package com.recipeapp.recipe;
import com.recipeapp.recipe.entity.IngredientCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface IngredientCategoryRepository extends JpaRepository<IngredientCategory, UUID> {
List<IngredientCategory> findByHouseholdIdOrderBySortOrder(UUID householdId);
}

View File

@@ -0,0 +1,11 @@
package com.recipeapp.recipe;
import com.recipeapp.recipe.entity.Ingredient;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface IngredientRepository extends JpaRepository<Ingredient, UUID> {
List<Ingredient> findByHouseholdId(UUID householdId);
}

View File

@@ -0,0 +1,11 @@
package com.recipeapp.recipe;
import com.recipeapp.recipe.entity.Tag;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface TagRepository extends JpaRepository<Tag, UUID> {
List<Tag> findByHouseholdId(UUID householdId);
}

View File

@@ -0,0 +1,45 @@
package com.recipeapp.recipe.entity;
import com.recipeapp.household.entity.Household;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "ingredient")
public class Ingredient {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@Column(nullable = false, columnDefinition = "citext")
private String name;
@Column(name = "is_staple", nullable = false)
private boolean isStaple;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private IngredientCategory category;
protected Ingredient() {}
public Ingredient(Household household, String name, boolean isStaple) {
this.household = household;
this.name = name;
this.isStaple = isStaple;
}
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public boolean isStaple() { return isStaple; }
public void setStaple(boolean staple) { isStaple = staple; }
public IngredientCategory getCategory() { return category; }
public void setCategory(IngredientCategory category) { this.category = category; }
}

View File

@@ -0,0 +1,47 @@
package com.recipeapp.recipe.entity;
import com.recipeapp.household.entity.Household;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "ingredient_category")
public class IngredientCategory {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@Column(nullable = false, columnDefinition = "citext")
private String name;
@Column(name = "sort_order", nullable = false)
private short sortOrder;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
protected IngredientCategory() {}
public IngredientCategory(Household household, String name, short sortOrder) {
this.household = household;
this.name = name;
this.sortOrder = sortOrder;
}
@PrePersist
void onCreate() { createdAt = Instant.now(); }
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public short getSortOrder() { return sortOrder; }
public void setSortOrder(short sortOrder) { this.sortOrder = sortOrder; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -0,0 +1,46 @@
package com.recipeapp.recipe.entity;
import com.recipeapp.household.entity.Household;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "tag")
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@Column(nullable = false, columnDefinition = "citext")
private String name;
@Column(name = "tag_type", nullable = false, length = 20)
private String tagType;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
protected Tag() {}
public Tag(Household household, String name, String tagType) {
this.household = household;
this.name = name;
this.tagType = tagType;
}
@PrePersist
void onCreate() { createdAt = Instant.now(); }
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getTagType() { return tagType; }
public Instant getCreatedAt() { return createdAt; }
}

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