feat(join): A4 — Join household (accept invite) #61

Merged
marcel merged 24 commits from feat/issue-21-join-household into master 2026-04-19 14:29:14 +02:00
10 changed files with 271 additions and 63 deletions
Showing only changes of commit 92f25e56fc - Show all commits

View File

@@ -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();

View File

@@ -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<ApiResponse<AcceptInviteResponse>> acceptInvite(
Principal principal,
@PathVariable String code) {
AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code);
@GetMapping("/invites/{code}")
public ResponseEntity<ApiResponse<InviteInfoResponse>> getInviteInfo(@PathVariable String code) {
InviteInfoResponse response = householdService.getInviteInfo(code);
return ResponseEntity.ok(ApiResponse.success(response));
}
@PostMapping("/invites/{code}/accept")
public ResponseEntity<ApiResponse<AcceptInviteResponse>> 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);
}
}

View File

@@ -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);

View File

@@ -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
) {}

View File

@@ -0,0 +1,6 @@
package com.recipeapp.household.dto;
public record InviteInfoResponse(
String householdName,
String inviterName
) {}

View File

@@ -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; }

View File

@@ -0,0 +1,2 @@
ALTER TABLE household_invite
ADD COLUMN invited_by uuid REFERENCES user_account (id) ON DELETE SET NULL;

View File

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

View File

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

View File

@@ -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);
}