feat(members): implement DELETE/PATCH member + GET invites backend endpoints
- Add V006 migration: invalidated_at column + partial unique index on household_invite
- Add findByHouseholdIdAndInvalidatedAtIsNull, findByHouseholdIdAndUserId, countByHouseholdIdAndRole
- Add ChangeRoleRequest DTO
- HouseholdService: getActiveInvite, createInvite (regenerate), removeMember, changeMemberRole
- HouseholdController: GET /v1/households/mine/invites, DELETE/PATCH /v1/households/mine/members/{userId}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/v1")
|
@RequestMapping("/v1")
|
||||||
@@ -40,12 +42,35 @@ public class HouseholdController {
|
|||||||
return ResponseEntity.ok(members);
|
return ResponseEntity.ok(members);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/households/mine/invites")
|
||||||
|
public ResponseEntity<ApiResponse<InviteResponse>> getActiveInvite(Principal principal) {
|
||||||
|
Optional<InviteResponse> invite = householdService.getActiveInvite(principal.getName());
|
||||||
|
return invite
|
||||||
|
.map(r -> ResponseEntity.ok(ApiResponse.success(r)))
|
||||||
|
.orElse(ResponseEntity.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/households/mine/invites")
|
@PostMapping("/households/mine/invites")
|
||||||
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
|
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
|
||||||
InviteResponse response = householdService.createInvite(principal.getName());
|
InviteResponse response = householdService.createInvite(principal.getName());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
|
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/households/mine/members/{userId}")
|
||||||
|
public ResponseEntity<Void> removeMember(Principal principal, @PathVariable UUID userId) {
|
||||||
|
householdService.removeMember(principal.getName(), userId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/households/mine/members/{userId}")
|
||||||
|
public ResponseEntity<ApiResponse<MemberResponse>> changeMemberRole(
|
||||||
|
Principal principal,
|
||||||
|
@PathVariable UUID userId,
|
||||||
|
@Valid @RequestBody ChangeRoleRequest request) {
|
||||||
|
MemberResponse response = householdService.changeMemberRole(principal.getName(), userId, request.role());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/invites/{code}/accept")
|
@PostMapping("/invites/{code}/accept")
|
||||||
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
||||||
Principal principal,
|
Principal principal,
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
|
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
|
||||||
Optional<HouseholdInvite> findByInviteCode(String inviteCode);
|
Optional<HouseholdInvite> findByInviteCode(String inviteCode);
|
||||||
|
Optional<HouseholdInvite> findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ import java.util.UUID;
|
|||||||
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||||
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
||||||
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
||||||
|
Optional<HouseholdMember> findByHouseholdIdAndUserId(UUID householdId, UUID userId);
|
||||||
|
long countByHouseholdIdAndRole(UUID householdId, String role);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class HouseholdService {
|
public class HouseholdService {
|
||||||
@@ -91,21 +93,73 @@ public class HouseholdService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MemberResponse changeMemberRole(String requesterEmail, UUID targetUserId, String newRole) {
|
||||||
|
HouseholdMember requester = findMembership(requesterEmail);
|
||||||
|
UUID householdId = requester.getHousehold().getId();
|
||||||
|
|
||||||
|
HouseholdMember target = householdMemberRepository
|
||||||
|
.findByHouseholdIdAndUserId(householdId, targetUserId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
|
||||||
|
|
||||||
|
if (target.getRole().equals(newRole)) {
|
||||||
|
return toMemberResponse(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("member".equals(newRole) && "planner".equals(target.getRole())) {
|
||||||
|
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
|
||||||
|
if (plannerCount <= 1) {
|
||||||
|
throw new ConflictException("Cannot degrade the last planner");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.setRole(newRole);
|
||||||
|
return toMemberResponse(householdMemberRepository.save(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removeMember(String requesterEmail, UUID targetUserId) {
|
||||||
|
HouseholdMember requester = findMembership(requesterEmail);
|
||||||
|
UUID householdId = requester.getHousehold().getId();
|
||||||
|
|
||||||
|
HouseholdMember target = householdMemberRepository
|
||||||
|
.findByHouseholdIdAndUserId(householdId, targetUserId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
|
||||||
|
|
||||||
|
if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) {
|
||||||
|
throw new ConflictException("Planner cannot remove yourself");
|
||||||
|
}
|
||||||
|
|
||||||
|
householdMemberRepository.delete(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<InviteResponse> getActiveInvite(String userEmail) {
|
||||||
|
HouseholdMember member = findMembership(userEmail);
|
||||||
|
return householdInviteRepository
|
||||||
|
.findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId())
|
||||||
|
.filter(invite -> invite.getExpiresAt().isAfter(Instant.now()))
|
||||||
|
.map(this::toInviteResponse);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public InviteResponse createInvite(String userEmail) {
|
public InviteResponse createInvite(String userEmail) {
|
||||||
HouseholdMember member = findMembership(userEmail);
|
HouseholdMember member = findMembership(userEmail);
|
||||||
Household household = member.getHousehold();
|
Household household = member.getHousehold();
|
||||||
|
|
||||||
|
householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId())
|
||||||
|
.ifPresent(existing -> {
|
||||||
|
existing.setInvalidatedAt(Instant.now());
|
||||||
|
householdInviteRepository.save(existing);
|
||||||
|
});
|
||||||
|
|
||||||
String code = generateInviteCode();
|
String code = generateInviteCode();
|
||||||
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
||||||
|
|
||||||
HouseholdInvite invite = householdInviteRepository.save(
|
HouseholdInvite invite = householdInviteRepository.save(
|
||||||
new HouseholdInvite(household, code, expiresAt));
|
new HouseholdInvite(household, code, expiresAt));
|
||||||
|
|
||||||
return new InviteResponse(
|
return toInviteResponse(invite);
|
||||||
invite.getInviteCode(),
|
|
||||||
"https://yourapp.com/join/" + invite.getInviteCode(),
|
|
||||||
invite.getExpiresAt());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -204,4 +258,11 @@ public class HouseholdService {
|
|||||||
member.getRole(),
|
member.getRole(),
|
||||||
member.getJoinedAt());
|
member.getJoinedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private InviteResponse toInviteResponse(HouseholdInvite invite) {
|
||||||
|
return new InviteResponse(
|
||||||
|
invite.getInviteCode(),
|
||||||
|
"https://yourapp.com/join/" + invite.getInviteCode(),
|
||||||
|
invite.getExpiresAt());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.recipeapp.household.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
public record ChangeRoleRequest(
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "planner|member", message = "role must be 'planner' or 'member'")
|
||||||
|
String role
|
||||||
|
) {}
|
||||||
@@ -25,6 +25,9 @@ public class HouseholdInvite {
|
|||||||
@Column(name = "expires_at", nullable = false)
|
@Column(name = "expires_at", nullable = false)
|
||||||
private Instant expiresAt;
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "invalidated_at")
|
||||||
|
private Instant invalidatedAt;
|
||||||
|
|
||||||
protected HouseholdInvite() {}
|
protected HouseholdInvite() {}
|
||||||
|
|
||||||
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
|
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
|
||||||
@@ -39,4 +42,6 @@ public class HouseholdInvite {
|
|||||||
public String getStatus() { return status; }
|
public String getStatus() { return status; }
|
||||||
public void setStatus(String status) { this.status = status; }
|
public void setStatus(String status) { this.status = status; }
|
||||||
public Instant getExpiresAt() { return expiresAt; }
|
public Instant getExpiresAt() { return expiresAt; }
|
||||||
|
public Instant getInvalidatedAt() { return invalidatedAt; }
|
||||||
|
public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE household_invite
|
||||||
|
ADD COLUMN invalidated_at timestamptz;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_household_invite_active
|
||||||
|
ON household_invite (household_id)
|
||||||
|
WHERE invalidated_at IS NULL;
|
||||||
@@ -15,10 +15,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
@@ -103,6 +105,58 @@ class HouseholdControllerTest {
|
|||||||
.andExpect(jsonPath("$.data.inviteCode").value("ABC12XYZ"));
|
.andExpect(jsonPath("$.data.inviteCode").value("ABC12XYZ"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveInviteShouldReturn200WithInvite() throws Exception {
|
||||||
|
var response = new InviteResponse("ACTIVE12", "https://yourapp.com/join/ACTIVE12",
|
||||||
|
Instant.now().plusSeconds(172800));
|
||||||
|
|
||||||
|
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.of(response));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/v1/households/mine/invites")
|
||||||
|
.principal(() -> "sarah@example.com"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("success"))
|
||||||
|
.andExpect(jsonPath("$.data.inviteCode").value("ACTIVE12"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveInviteShouldReturn204WhenNoActiveInvite() throws Exception {
|
||||||
|
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/v1/households/mine/invites")
|
||||||
|
.principal(() -> "sarah@example.com"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteMemberShouldReturn204() throws Exception {
|
||||||
|
var memberId = UUID.randomUUID();
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/v1/households/mine/members/" + memberId)
|
||||||
|
.principal(() -> "sarah@example.com"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
verify(householdService).removeMember("sarah@example.com", memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patchMemberRoleShouldReturn200() throws Exception {
|
||||||
|
var memberId = UUID.randomUUID();
|
||||||
|
var memberResponse = new MemberResponse(memberId, "Tom", "planner", Instant.now());
|
||||||
|
var request = new ChangeRoleRequest("planner");
|
||||||
|
|
||||||
|
when(householdService.changeMemberRole("sarah@example.com", memberId, "planner"))
|
||||||
|
.thenReturn(memberResponse);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/v1/households/mine/members/" + memberId)
|
||||||
|
.principal(() -> "sarah@example.com")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("success"))
|
||||||
|
.andExpect(jsonPath("$.data.role").value("planner"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldReturn200() throws Exception {
|
void acceptInviteShouldReturn200() throws Exception {
|
||||||
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
|
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@@ -223,6 +224,169 @@ class HouseholdServiceTest {
|
|||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── changeMemberRole ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeMemberRoleShouldUpdateRole() {
|
||||||
|
var planner = testUser();
|
||||||
|
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var targetMembership = new HouseholdMember(household, target, "member");
|
||||||
|
var targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||||
|
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "planner");
|
||||||
|
|
||||||
|
assertThat(result.role()).isEqualTo("planner");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeMemberRoleShouldBeIdempotentWhenRoleUnchanged() {
|
||||||
|
var planner = testUser();
|
||||||
|
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var targetMembership = new HouseholdMember(household, target, "member");
|
||||||
|
var targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||||
|
|
||||||
|
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "member");
|
||||||
|
|
||||||
|
assertThat(result.role()).isEqualTo("member");
|
||||||
|
verify(householdMemberRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeMemberRoleShouldThrow409WhenDegradingLastPlanner() {
|
||||||
|
var planner = testUser();
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", targetId, "member"))
|
||||||
|
.isInstanceOf(ConflictException.class)
|
||||||
|
.hasMessageContaining("last planner");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeMemberRoleShouldThrow404WhenTargetNotInHousehold() {
|
||||||
|
var planner = testUser();
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var unknownId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", unknownId, "planner"))
|
||||||
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── removeMember ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeMemberShouldDeleteMember() {
|
||||||
|
var planner = testUser();
|
||||||
|
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var targetMembership = new HouseholdMember(household, target, "member");
|
||||||
|
var targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||||
|
|
||||||
|
householdService.removeMember("sarah@example.com", targetId);
|
||||||
|
|
||||||
|
verify(householdMemberRepository).delete(targetMembership);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeMemberShouldThrow409WhenPlannerTriesToRemoveSelf() {
|
||||||
|
var planner = testUser();
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var plannerId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(plannerId))).thenReturn(Optional.of(plannerMembership));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", plannerId))
|
||||||
|
.isInstanceOf(ConflictException.class)
|
||||||
|
.hasMessageContaining("cannot remove yourself");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeMemberShouldThrow404WhenTargetNotInHousehold() {
|
||||||
|
var planner = testUser();
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var unknownId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", unknownId))
|
||||||
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── getActiveInvite ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveInviteShouldReturnActiveInviteResponse() {
|
||||||
|
var user = testUser();
|
||||||
|
var household = new Household("Smith family", user);
|
||||||
|
var member = new HouseholdMember(household, user, "planner");
|
||||||
|
var invite = new HouseholdInvite(household, "ACTIVE123", Instant.now().plusSeconds(86400));
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||||
|
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
|
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
|
||||||
|
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().inviteCode()).isEqualTo("ACTIVE123");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveInviteShouldReturnEmptyWhenNoActiveInvite() {
|
||||||
|
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.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveInviteShouldReturnEmptyWhenExpired() {
|
||||||
|
var user = testUser();
|
||||||
|
var household = new Household("Smith family", user);
|
||||||
|
var member = new HouseholdMember(household, user, "planner");
|
||||||
|
var invite = new HouseholdInvite(household, "EXPIRED1", Instant.now().minusSeconds(3600));
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||||
|
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
|
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getMembersShouldReturnAllMembers() {
|
void getMembersShouldReturnAllMembers() {
|
||||||
var user1 = testUser();
|
var user1 = testUser();
|
||||||
@@ -256,4 +420,21 @@ class HouseholdServiceTest {
|
|||||||
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
|
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
|
||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createInviteShouldInvalidatePreviousActiveInvite() {
|
||||||
|
var user = testUser();
|
||||||
|
var household = new Household("Smith family", user);
|
||||||
|
var member = new HouseholdMember(household, user, "planner");
|
||||||
|
var existingInvite = new HouseholdInvite(household, "OLD12345", Instant.now().plusSeconds(86400));
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||||
|
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite));
|
||||||
|
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
householdService.createInvite("sarah@example.com");
|
||||||
|
|
||||||
|
assertThat(existingInvite.getInvalidatedAt()).isNotNull();
|
||||||
|
verify(householdInviteRepository, times(2)).save(any(HouseholdInvite.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user