diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdController.java b/backend/src/main/java/com/recipeapp/household/HouseholdController.java new file mode 100644 index 0000000..7f704d7 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/HouseholdController.java @@ -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> 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> getMyHousehold(Principal principal) { + HouseholdResponse response = householdService.getMyHousehold(principal.getName()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @GetMapping("/households/mine/members") + public ResponseEntity> getMembers(Principal principal) { + List members = householdService.getMembers(principal.getName()); + return ResponseEntity.ok(members); + } + + @PostMapping("/households/mine/invites") + public ResponseEntity> createInvite(Principal principal) { + InviteResponse response = householdService.createInvite(principal.getName()); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); + } + + @PostMapping("/invites/{code}/accept") + public ResponseEntity> acceptInvite( + Principal principal, + @PathVariable String code) { + AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java new file mode 100644 index 0000000..7486172 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java @@ -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 { + Optional findByInviteCode(String inviteCode); +} diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java index 772980e..80853b1 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java @@ -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 { Optional findByUserEmailIgnoreCase(String email); + List findByHouseholdId(UUID householdId); } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdRepository.java new file mode 100644 index 0000000..40b793f --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/HouseholdRepository.java @@ -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 { +} diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java new file mode 100644 index 0000000..7e89213 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -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 getMembers(String userEmail); + InviteResponse createInvite(String userEmail); + AcceptInviteResponse acceptInvite(String userEmail, String code); +} diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java b/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java new file mode 100644 index 0000000..728e56b --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java @@ -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 members = householdMemberRepository.findByHouseholdId(household.getId()); + return toHouseholdResponse(household, members); + } + + @Override + @Transactional(readOnly = true) + public List 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 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()); + } +} diff --git a/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteResponse.java b/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteResponse.java new file mode 100644 index 0000000..6d0d4c0 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteResponse.java @@ -0,0 +1,9 @@ +package com.recipeapp.household.dto; + +import java.util.UUID; + +public record AcceptInviteResponse( + UUID householdId, + String householdName, + String role +) {} diff --git a/backend/src/main/java/com/recipeapp/household/dto/CreateHouseholdRequest.java b/backend/src/main/java/com/recipeapp/household/dto/CreateHouseholdRequest.java new file mode 100644 index 0000000..ec4b30a --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/CreateHouseholdRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/com/recipeapp/household/dto/HouseholdResponse.java b/backend/src/main/java/com/recipeapp/household/dto/HouseholdResponse.java new file mode 100644 index 0000000..83b0b87 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/HouseholdResponse.java @@ -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 members +) {} diff --git a/backend/src/main/java/com/recipeapp/household/dto/InviteResponse.java b/backend/src/main/java/com/recipeapp/household/dto/InviteResponse.java new file mode 100644 index 0000000..95bab92 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/InviteResponse.java @@ -0,0 +1,9 @@ +package com.recipeapp.household.dto; + +import java.time.Instant; + +public record InviteResponse( + String inviteCode, + String shareUrl, + Instant expiresAt +) {} diff --git a/backend/src/main/java/com/recipeapp/household/dto/MemberResponse.java b/backend/src/main/java/com/recipeapp/household/dto/MemberResponse.java new file mode 100644 index 0000000..60249d4 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/MemberResponse.java @@ -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 +) {} diff --git a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java new file mode 100644 index 0000000..916fac1 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java @@ -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; } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/.gitkeep b/backend/src/main/java/com/recipeapp/recipe/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryRepository.java b/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryRepository.java new file mode 100644 index 0000000..a0e989a --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/IngredientCategoryRepository.java @@ -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 { + List findByHouseholdIdOrderBySortOrder(UUID householdId); +} diff --git a/backend/src/main/java/com/recipeapp/recipe/IngredientRepository.java b/backend/src/main/java/com/recipeapp/recipe/IngredientRepository.java new file mode 100644 index 0000000..dab72d8 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/IngredientRepository.java @@ -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 { + List findByHouseholdId(UUID householdId); +} diff --git a/backend/src/main/java/com/recipeapp/recipe/TagRepository.java b/backend/src/main/java/com/recipeapp/recipe/TagRepository.java new file mode 100644 index 0000000..47a9216 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/TagRepository.java @@ -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 { + List findByHouseholdId(UUID householdId); +} diff --git a/backend/src/main/java/com/recipeapp/recipe/entity/Ingredient.java b/backend/src/main/java/com/recipeapp/recipe/entity/Ingredient.java new file mode 100644 index 0000000..e13b859 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/entity/Ingredient.java @@ -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; } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/entity/IngredientCategory.java b/backend/src/main/java/com/recipeapp/recipe/entity/IngredientCategory.java new file mode 100644 index 0000000..a5e3dfa --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/entity/IngredientCategory.java @@ -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; } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/entity/Tag.java b/backend/src/main/java/com/recipeapp/recipe/entity/Tag.java new file mode 100644 index 0000000..0514d33 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/entity/Tag.java @@ -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; } +} diff --git a/backend/src/test/java/com/recipeapp/BackendApplicationTests.java b/backend/src/test/java/com/recipeapp/BackendApplicationTests.java index 1e49ed2..5c63996 100644 --- a/backend/src/test/java/com/recipeapp/BackendApplicationTests.java +++ b/backend/src/test/java/com/recipeapp/BackendApplicationTests.java @@ -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() { diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java new file mode 100644 index 0000000..ceee167 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java @@ -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")); + } +} diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java new file mode 100644 index 0000000..8baaf11 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -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); + } +}