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