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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.household.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record AcceptInviteResponse(
|
||||
UUID householdId,
|
||||
String householdName,
|
||||
String role
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.household.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record InviteResponse(
|
||||
String inviteCode,
|
||||
String shareUrl,
|
||||
Instant expiresAt
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
46
backend/src/main/java/com/recipeapp/recipe/entity/Tag.java
Normal file
46
backend/src/main/java/com/recipeapp/recipe/entity/Tag.java
Normal 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; }
|
||||
}
|
||||
@@ -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