diff --git a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java index 27c35dc..eb98a48 100644 --- a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java +++ b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java @@ -24,11 +24,13 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/v1/invites/*").permitAll() .requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated()) .exceptionHandling(ex -> ex .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .sessionManagement(session -> session + .sessionFixation().changeSessionId() .maximumSessions(1)); return http.build(); diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdController.java b/backend/src/main/java/com/recipeapp/household/HouseholdController.java index 46c8556..d009393 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdController.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdController.java @@ -1,10 +1,17 @@ package com.recipeapp.household; +import com.recipeapp.auth.entity.UserAccount; import com.recipeapp.common.ApiResponse; import com.recipeapp.household.dto.*; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.*; import java.security.Principal; @@ -71,11 +78,34 @@ public class HouseholdController { return ResponseEntity.ok(ApiResponse.success(response)); } - @PostMapping("/invites/{code}/accept") - public ResponseEntity> acceptInvite( - Principal principal, - @PathVariable String code) { - AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code); + @GetMapping("/invites/{code}") + public ResponseEntity> getInviteInfo(@PathVariable String code) { + InviteInfoResponse response = householdService.getInviteInfo(code); return ResponseEntity.ok(ApiResponse.success(response)); } + + @PostMapping("/invites/{code}/accept") + public ResponseEntity> acceptInvite( + @PathVariable String code, + @Valid @RequestBody AcceptInviteRequest request, + HttpServletRequest httpRequest) { + AcceptInviteResponse response = householdService.acceptInvite( + code, request.name(), request.email(), request.password()); + authenticateInSession(request.email(), httpRequest); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + private void authenticateInSession(String email, HttpServletRequest request) { + var oldSession = request.getSession(false); + if (oldSession != null) { + oldSession.invalidate(); + } + var auth = UsernamePasswordAuthenticationToken.authenticated( + email, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + request.getSession(true).setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index bcf207c..cb26329 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -6,6 +6,7 @@ import com.recipeapp.common.ConflictException; import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ValidationException; import com.recipeapp.household.dto.*; +import org.springframework.security.crypto.password.PasswordEncoder; import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.HouseholdInvite; import com.recipeapp.household.entity.HouseholdMember; @@ -38,6 +39,7 @@ public class HouseholdService { private final IngredientCategoryRepository ingredientCategoryRepository; private final TagRepository tagRepository; private final VarietyScoreConfigRepository varietyScoreConfigRepository; + private final PasswordEncoder passwordEncoder; @Value("${app.base-url}") private String baseUrl; @@ -52,7 +54,8 @@ public class HouseholdService { IngredientRepository ingredientRepository, IngredientCategoryRepository ingredientCategoryRepository, TagRepository tagRepository, - VarietyScoreConfigRepository varietyScoreConfigRepository) { + VarietyScoreConfigRepository varietyScoreConfigRepository, + PasswordEncoder passwordEncoder) { this.userAccountRepository = userAccountRepository; this.householdRepository = householdRepository; this.householdMemberRepository = householdMemberRepository; @@ -61,6 +64,7 @@ public class HouseholdService { this.ingredientCategoryRepository = ingredientCategoryRepository; this.tagRepository = tagRepository; this.varietyScoreConfigRepository = varietyScoreConfigRepository; + this.passwordEncoder = passwordEncoder; } @Transactional @@ -167,30 +171,45 @@ public class HouseholdService { String code = generateInviteCode(); Instant expiresAt = Instant.now().plusSeconds(48 * 3600); - HouseholdInvite invite = householdInviteRepository.save( - new HouseholdInvite(household, code, expiresAt)); + HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt); + invite.setInvitedBy(member.getUser()); + householdInviteRepository.save(invite); return toInviteResponse(invite); } - @Transactional - public AcceptInviteResponse acceptInvite(String userEmail, String code) { - UserAccount user = findUser(userEmail); + @Transactional(readOnly = true) + public InviteInfoResponse getInviteInfo(String code) { + HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) + .orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid")); - if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) { - throw new ConflictException("User is already in a household"); + if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) { + throw new ResourceNotFoundException("Invite not found or invalid"); + } + + String inviterName = invite.getInvitedBy() != null + ? invite.getInvitedBy().getDisplayName() + : invite.getHousehold().getCreatedBy().getDisplayName(); + + return new InviteInfoResponse(invite.getHousehold().getName(), inviterName); + } + + @Transactional + public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) { + if (userAccountRepository.existsByEmailIgnoreCase(email)) { + throw new ConflictException("Email already registered"); } HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) - .orElseThrow(() -> new ResourceNotFoundException("Invite not found")); + .orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid")); - 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"); + if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) { + throw new ResourceNotFoundException("Invite not found or invalid"); } + UserAccount user = userAccountRepository.save( + new UserAccount(email, name, passwordEncoder.encode(rawPassword))); + invite.setStatus("used"); householdInviteRepository.save(invite); diff --git a/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java b/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java new file mode 100644 index 0000000..cc132f4 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java @@ -0,0 +1,11 @@ +package com.recipeapp.household.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AcceptInviteRequest( + @NotBlank String name, + @NotBlank @Email String email, + @NotBlank @Size(min = 8) String password +) {} diff --git a/backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java b/backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java new file mode 100644 index 0000000..97d31c6 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java @@ -0,0 +1,6 @@ +package com.recipeapp.household.dto; + +public record InviteInfoResponse( + String householdName, + String inviterName +) {} diff --git a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java index 04246e6..56e7a9f 100644 --- a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java +++ b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java @@ -1,5 +1,6 @@ package com.recipeapp.household.entity; +import com.recipeapp.auth.entity.UserAccount; import jakarta.persistence.*; import java.time.Instant; import java.util.UUID; @@ -16,6 +17,10 @@ public class HouseholdInvite { @JoinColumn(name = "household_id", nullable = false) private Household household; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "invited_by") + private UserAccount invitedBy; + @Column(name = "invite_code", nullable = false, unique = true, length = 20) private String inviteCode; @@ -38,6 +43,8 @@ public class HouseholdInvite { public UUID getId() { return id; } public Household getHousehold() { return household; } + public UserAccount getInvitedBy() { return invitedBy; } + public void setInvitedBy(UserAccount invitedBy) { this.invitedBy = invitedBy; } public String getInviteCode() { return inviteCode; } public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } diff --git a/backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql b/backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql new file mode 100644 index 0000000..f1519c8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql @@ -0,0 +1,2 @@ +ALTER TABLE household_invite + ADD COLUMN invited_by uuid REFERENCES user_account (id) ON DELETE SET NULL; diff --git a/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java b/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java new file mode 100644 index 0000000..96b7c17 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java @@ -0,0 +1,41 @@ +package com.recipeapp.auth; + +import com.recipeapp.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class SecurityConfigTest extends AbstractIntegrationTest { + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + void inviteInfoEndpointIsAccessibleWithoutAuthentication() throws Exception { + // 404 = unauthenticated request reached the service (ResourceNotFoundException), not 401 + mockMvc.perform(get("/v1/invites/ANYCODE")) + .andExpect(status().isNotFound()); + } + + @Test + void protectedEndpointRequiresAuthentication() throws Exception { + mockMvc.perform(get("/v1/households/mine")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java index 1a33db3..c889963 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java @@ -2,6 +2,8 @@ package com.recipeapp.household; import com.fasterxml.jackson.databind.ObjectMapper; import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ConflictException; import com.recipeapp.household.dto.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -158,16 +160,67 @@ class HouseholdControllerTest { } @Test - void acceptInviteShouldReturn200() throws Exception { - var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); + void getInviteInfoShouldReturn200WithHouseholdAndInviterName() throws Exception { + var response = new InviteInfoResponse("Smith family", "Sarah"); - when(householdService.acceptInvite("tom@example.com", "ABC12XYZ")).thenReturn(response); + when(householdService.getInviteInfo("ABC12XYZ")).thenReturn(response); + + mockMvc.perform(get("/v1/invites/ABC12XYZ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data.householdName").value("Smith family")) + .andExpect(jsonPath("$.data.inviterName").value("Sarah")); + } + + @Test + void getInviteInfoShouldReturn404WhenInvalid() throws Exception { + when(householdService.getInviteInfo("BADTOKEN")) + .thenThrow(new ResourceNotFoundException("Invite not found or invalid")); + + mockMvc.perform(get("/v1/invites/BADTOKEN")) + .andExpect(status().isNotFound()); + } + + @Test + void acceptInviteShouldReturn200AndCreateSession() throws Exception { + var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); + var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123"); + + when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123")) + .thenReturn(response); mockMvc.perform(post("/v1/invites/ABC12XYZ/accept") - .principal(() -> "tom@example.com")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("success")) .andExpect(jsonPath("$.data.householdName").value("Smith family")) .andExpect(jsonPath("$.data.role").value("member")); } + + @Test + void acceptInviteShouldReturn409WhenEmailAlreadyRegistered() throws Exception { + var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123"); + + when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123")) + .thenThrow(new ConflictException("Email already registered")); + + mockMvc.perform(post("/v1/invites/ABC12XYZ/accept") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + void acceptInviteShouldReturn404WhenTokenInvalid() throws Exception { + var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123"); + + when(householdService.acceptInvite("BADTOKEN", "Tom", "tom@example.com", "secret123")) + .thenThrow(new ResourceNotFoundException("Invite not found or invalid")); + + mockMvc.perform(post("/v1/invites/BADTOKEN/accept") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } } diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index 6545566..650f517 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -41,6 +41,7 @@ class HouseholdServiceTest { @Mock private IngredientCategoryRepository ingredientCategoryRepository; @Mock private TagRepository tagRepository; @Mock private VarietyScoreConfigRepository varietyScoreConfigRepository; + @Mock private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder; @InjectMocks private HouseholdService householdService; @@ -154,86 +155,122 @@ class HouseholdServiceTest { assertThat(result.shareUrl()).endsWith(result.inviteCode()); } + // ── getInviteInfo ───────────────────────────────────────────────────────── + @Test - void acceptInviteShouldAddUserAsMember() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); + void getInviteInfoShouldReturnHouseholdNameAndInviterName() { var owner = testUser(); var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400)); + invite.setInvitedBy(owner); - 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"); + InviteInfoResponse result = householdService.getInviteInfo("ABC12XYZ"); assertThat(result.householdName()).isEqualTo("Smith family"); - assertThat(result.role()).isEqualTo("member"); + assertThat(result.inviterName()).isEqualTo("Sarah"); } @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"); + void getInviteInfoShouldThrow404WhenCodeNotFound() { + when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.getInviteInfo("INVALID")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getInviteInfoShouldThrow404WhenCodeExpired() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600)); + invite.setInvitedBy(owner); + + when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.getInviteInfo("EXPIRED")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getInviteInfoShouldThrow404WhenCodeAlreadyUsed() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400)); + invite.setStatus("used"); + invite.setInvitedBy(owner); + + when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.getInviteInfo("USED123")) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── acceptInvite (new: creates account + joins) ─────────────────────────── + + @Test + void acceptInviteShouldCreateAccountAndAddAsMember() { + var owner = testUser(); + var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400)); + invite.setInvitedBy(owner); - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(member)); + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); + when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite)); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0)); + when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0)); + when(passwordEncoder.encode("secret123")).thenReturn("hashed"); - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "ABC12XYZ")) + AcceptInviteResponse result = householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"); + + assertThat(result.householdName()).isEqualTo("Smith family"); + assertThat(result.role()).isEqualTo("member"); + verify(userAccountRepository).save(any(UserAccount.class)); + verify(householdMemberRepository).save(any(HouseholdMember.class)); + } + + @Test + void acceptInviteShouldThrow409WhenEmailAlreadyRegistered() { + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(true); + + assertThatThrownBy(() -> householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123")) .isInstanceOf(ConflictException.class); } @Test - void acceptInviteShouldThrowWhenCodeExpired() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); + void acceptInviteShouldThrow404WhenCodeExpired() { 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(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite)); - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "EXPIRED")) - .isInstanceOf(ValidationException.class); + assertThatThrownBy(() -> householdService.acceptInvite("EXPIRED", "Tom", "tom@example.com", "secret123")) + .isInstanceOf(ResourceNotFoundException.class); } @Test - void acceptInviteShouldThrowWhenCodeAlreadyUsed() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); + void acceptInviteShouldThrow404WhenCodeAlreadyUsed() { 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(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite)); - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123")) - .isInstanceOf(ConflictException.class); - } - - @Test - void acceptInviteShouldThrowWhenInviteNotFound() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); - - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); - when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "INVALID")) + assertThatThrownBy(() -> householdService.acceptInvite("USED123", "Tom", "tom@example.com", "secret123")) .isInstanceOf(ResourceNotFoundException.class); } @Test - void acceptInviteShouldThrowWhenUserNotFound() { - when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); + void acceptInviteShouldThrow404WhenInviteNotFound() { + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); + when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); - assertThatThrownBy(() -> householdService.acceptInvite("unknown@example.com", "ABC12XYZ")) + assertThatThrownBy(() -> householdService.acceptInvite("INVALID", "Tom", "tom@example.com", "secret123")) .isInstanceOf(ResourceNotFoundException.class); }