feat(invites): implement invite-based self-service registration backend

- V45 migration: invite_tokens + invite_token_group_ids tables
- InviteToken entity with @ElementCollection group IDs
- InviteService: code generation, validation, redemption (pessimistic lock prevents TOCTOU), revoke, list
- RateLimitInterceptor (Caffeine-backed, 10 req/min per IP) registered via WebMvcConfigurer
- AuthController: GET /api/auth/invite/{code} + POST /api/auth/register (both public)
- InviteController: GET/POST/DELETE /api/invites (ADMIN_USER permission)
- SecurityConfig: permitAll for new public auth endpoints
- ErrorCode: INVITE_NOT_FOUND, INVITE_EXHAUSTED, INVITE_REVOKED, INVITE_EXPIRED
- 36 new tests (InviteServiceTest, AuthControllerTest, InviteControllerTest)

Closes #269

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 00:42:43 +02:00
parent b4004fce56
commit 61fa35df67
18 changed files with 1181 additions and 4 deletions

View File

@@ -0,0 +1,191 @@
package org.raddatz.familienarchiv.controller;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.dto.InvitePrefillDTO;
import org.raddatz.familienarchiv.dto.RegisterRequest;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.InviteToken;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.InviteService;
import org.raddatz.familienarchiv.service.PasswordResetService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(AuthController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class AuthControllerTest {
@Autowired MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean PasswordResetService passwordResetService;
@MockitoBean InviteService inviteService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/auth/invite/{code} ──────────────────────────────────────────
@Test
void getInvitePrefill_returns200_withPrefillData_whenCodeValid() throws Exception {
InviteToken token = InviteToken.builder()
.code("ABCDE12345")
.prefillFirstName("Helga")
.prefillLastName("Muster")
.prefillEmail("helga@muster.de")
.build();
when(inviteService.validateCode("ABCDE12345")).thenReturn(token);
mockMvc.perform(get("/api/auth/invite/ABCDE12345"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Helga"))
.andExpect(jsonPath("$.lastName").value("Muster"))
.andExpect(jsonPath("$.email").value("helga@muster.de"));
}
@Test
void getInvitePrefill_returns404_whenCodeNotFound() throws Exception {
when(inviteService.validateCode("UNKNOWN123"))
.thenThrow(DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "not found"));
mockMvc.perform(get("/api/auth/invite/UNKNOWN123"))
.andExpect(status().isNotFound());
}
@Test
void getInvitePrefill_returns409_whenTokenRevoked() throws Exception {
when(inviteService.validateCode("REVOKED123"))
.thenThrow(DomainException.conflict(ErrorCode.INVITE_REVOKED, "revoked"));
mockMvc.perform(get("/api/auth/invite/REVOKED123"))
.andExpect(status().isConflict());
}
@Test
void getInvitePrefill_returns409_whenTokenExhausted() throws Exception {
when(inviteService.validateCode("EXHAUST123"))
.thenThrow(DomainException.conflict(ErrorCode.INVITE_EXHAUSTED, "exhausted"));
mockMvc.perform(get("/api/auth/invite/EXHAUST123"))
.andExpect(status().isConflict());
}
@Test
void getInvitePrefill_returns410_whenTokenExpired() throws Exception {
when(inviteService.validateCode("EXPIRED123"))
.thenThrow(new DomainException(ErrorCode.INVITE_EXPIRED,
org.springframework.http.HttpStatus.GONE, "expired"));
mockMvc.perform(get("/api/auth/invite/EXPIRED123"))
.andExpect(status().isGone());
}
// ─── POST /api/auth/register ──────────────────────────────────────────────
@Test
void register_returns201_withCreatedUser_onHappyPath() throws Exception {
AppUser user = AppUser.builder()
.id(UUID.randomUUID())
.email("new@test.com")
.firstName("Max")
.lastName("Muster")
.build();
when(inviteService.redeemInvite(any())).thenReturn(user);
RegisterRequest req = new RegisterRequest();
req.setCode("ABCDE12345");
req.setEmail("new@test.com");
req.setPassword("password123");
req.setFirstName("Max");
req.setLastName("Muster");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.email").value("new@test.com"));
}
@Test
void register_returns409_whenEmailAlreadyInUse() throws Exception {
when(inviteService.redeemInvite(any()))
.thenThrow(DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, "already in use"));
RegisterRequest req = new RegisterRequest();
req.setCode("ABCDE12345");
req.setEmail("dupe@test.com");
req.setPassword("password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isConflict());
}
@Test
void register_returns400_whenPasswordTooShort() throws Exception {
when(inviteService.redeemInvite(any()))
.thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "too short"));
RegisterRequest req = new RegisterRequest();
req.setCode("ABCDE12345");
req.setEmail("new@test.com");
req.setPassword("abc");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void register_returns404_whenInviteCodeNotFound() throws Exception {
when(inviteService.redeemInvite(any()))
.thenThrow(DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "not found"));
RegisterRequest req = new RegisterRequest();
req.setCode("INVALID123");
req.setEmail("new@test.com");
req.setPassword("password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound());
}
@Test
void register_isPublic_noAuthRequired() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("pub@test.com").build();
when(inviteService.redeemInvite(any())).thenReturn(user);
RegisterRequest req = new RegisterRequest();
req.setCode("ABCDE12345");
req.setEmail("pub@test.com");
req.setPassword("password123");
// No WithMockUser — must still succeed (no auth challenge)
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated());
}
}

View File

@@ -0,0 +1,175 @@
package org.raddatz.familienarchiv.controller;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.dto.CreateInviteRequest;
import org.raddatz.familienarchiv.dto.InviteListItemDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.InviteToken;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.InviteService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(InviteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class InviteControllerTest {
@Autowired MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean InviteService inviteService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private InviteListItemDTO makeInviteDTO(UUID id, String code) {
return InviteListItemDTO.builder()
.id(id)
.code(code)
.displayCode(InviteService.formatDisplayCode(code))
.useCount(0)
.revoked(false)
.status("active")
.createdAt(LocalDateTime.now())
.shareableUrl("http://localhost:3000/register?code=" + code)
.build();
}
// ─── GET /api/invites ─────────────────────────────────────────────────────
@Test
void listInvites_returns403_whenUserLacksAdminUserPermission() throws Exception {
mockMvc.perform(get("/api/invites"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "user@test.com")
void listInvites_returns403_whenAuthenticatedWithoutPermission() throws Exception {
mockMvc.perform(get("/api/invites"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
void listInvites_returns200_withActiveInvites_byDefault() throws Exception {
UUID id = UUID.randomUUID();
when(inviteService.listInvites(eq(true), anyString()))
.thenReturn(List.of(makeInviteDTO(id, "ABCDE12345")));
mockMvc.perform(get("/api/invites"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].code").value("ABCDE12345"))
.andExpect(jsonPath("$[0].status").value("active"));
}
@Test
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
void listInvites_returns200_withAllInvites_whenStatusAll() throws Exception {
UUID id = UUID.randomUUID();
InviteListItemDTO revoked = makeInviteDTO(id, "REVOKED1234");
when(inviteService.listInvites(eq(false), anyString()))
.thenReturn(List.of(revoked));
mockMvc.perform(get("/api/invites").param("status", "all"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].code").value("REVOKED1234"));
}
// ─── POST /api/invites ────────────────────────────────────────────────────
@Test
void createInvite_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/invites")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "user@test.com")
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
mockMvc.perform(post("/api/invites")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
void createInvite_returns201_withCreatedInvite() throws Exception {
AppUser admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build();
when(userService.findByEmail("admin@test.com")).thenReturn(admin);
InviteToken savedToken = InviteToken.builder()
.id(UUID.randomUUID())
.code("NEWCODE123")
.label("Für Familie")
.maxUses(1)
.useCount(0)
.build();
when(inviteService.createInvite(any(), eq(admin))).thenReturn(savedToken);
UUID id = savedToken.getId();
InviteListItemDTO dto = makeInviteDTO(id, "NEWCODE123");
dto.setLabel("Für Familie");
when(inviteService.toListItemDTO(eq(savedToken), anyString())).thenReturn(dto);
CreateInviteRequest req = new CreateInviteRequest();
req.setLabel("Für Familie");
req.setMaxUses(1);
mockMvc.perform(post("/api/invites")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value("NEWCODE123"))
.andExpect(jsonPath("$.label").value("Für Familie"));
}
// ─── DELETE /api/invites/{id} ─────────────────────────────────────────────
@Test
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "user@test.com")
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
void revokeInvite_returns204_whenSuccessful() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(delete("/api/invites/" + id))
.andExpect(status().isNoContent());
verify(inviteService).revokeInvite(id);
}
}

View File

@@ -0,0 +1,269 @@
package org.raddatz.familienarchiv.service;
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.raddatz.familienarchiv.dto.CreateInviteRequest;
import org.raddatz.familienarchiv.dto.RegisterRequest;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.InviteToken;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.InviteTokenRepository;
import org.raddatz.familienarchiv.repository.UserGroupRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InviteServiceTest {
@Mock InviteTokenRepository inviteTokenRepository;
@Mock AppUserRepository appUserRepository;
@Mock UserGroupRepository userGroupRepository;
@Mock PasswordEncoder passwordEncoder;
@InjectMocks InviteService inviteService;
private AppUser admin;
@BeforeEach
void setUp() {
admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build();
}
// ─── generateCode ────────────────────────────────────────────────────────────
@Test
void generateCode_produces10CharUppercaseAlphanumeric() {
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
String code = inviteService.generateCode();
assertThat(code).hasSize(10).matches("[A-Z0-9]{10}");
}
@Test
void generateCode_retriesOnCollision() {
when(inviteTokenRepository.findByCode(anyString()))
.thenReturn(Optional.of(InviteToken.builder().code("AAAAAAAAAA").build()))
.thenReturn(Optional.empty());
String code = inviteService.generateCode();
assertThat(code).hasSize(10);
verify(inviteTokenRepository, times(2)).findByCode(anyString());
}
// ─── validateCode ─────────────────────────────────────────────────────────
@Test
void validateCode_throwsNotFound_whenCodeUnknown() {
when(inviteTokenRepository.findByCode("UNKNOWN123")).thenReturn(Optional.empty());
assertThatThrownBy(() -> inviteService.validateCode("UNKNOWN123"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVITE_NOT_FOUND);
}
@Test
void validateCode_throwsRevoked_whenTokenIsRevoked() {
InviteToken token = InviteToken.builder().code("ABCDE12345").revoked(true).build();
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVITE_REVOKED);
}
@Test
void validateCode_throwsExpired_whenTokenIsPastExpiryDate() {
InviteToken token = InviteToken.builder().code("ABCDE12345")
.expiresAt(LocalDateTime.now().minusHours(1))
.build();
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVITE_EXPIRED);
}
@Test
void validateCode_throwsExhausted_whenUseCountMeetsMaxUses() {
InviteToken token = InviteToken.builder().code("ABCDE12345").maxUses(1).useCount(1).build();
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVITE_EXHAUSTED);
}
@Test
void validateCode_returnsToken_whenValid() {
InviteToken token = InviteToken.builder().code("ABCDE12345").build();
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
InviteToken result = inviteService.validateCode("ABCDE12345");
assertThat(result.getCode()).isEqualTo("ABCDE12345");
}
@Test
void validateCode_acceptsUnlimitedInvite_afterManyUses() {
InviteToken token = InviteToken.builder().code("ABCDE12345").maxUses(null).useCount(100).build();
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
assertThatNoException().isThrownBy(() -> inviteService.validateCode("ABCDE12345"));
}
// ─── createInvite ────────────────────────────────────────────────────────────
@Test
void createInvite_savesTokenWithGeneratedCode() {
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
CreateInviteRequest req = new CreateInviteRequest();
req.setLabel("Für Oma Helga");
req.setMaxUses(1);
InviteToken result = inviteService.createInvite(req, admin);
assertThat(result.getLabel()).isEqualTo("Für Oma Helga");
assertThat(result.getMaxUses()).isEqualTo(1);
assertThat(result.getCode()).hasSize(10);
assertThat(result.getCreatedBy()).isEqualTo(admin);
}
@Test
void createInvite_assignsGroups_whenGroupIdsProvided() {
UserGroup g = UserGroup.builder().id(UUID.randomUUID()).name("Familie").build();
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
when(userGroupRepository.findAllById(anyList())).thenReturn(List.of(g));
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
CreateInviteRequest req = new CreateInviteRequest();
req.setGroupIds(List.of(g.getId()));
InviteToken result = inviteService.createInvite(req, admin);
assertThat(result.getGroupIds()).contains(g.getId());
}
// ─── redeemInvite ─────────────────────────────────────────────────────────
@Test
void redeemInvite_createsUserAndIncrementsUseCount() {
InviteToken token = InviteToken.builder()
.code("ABCDE12345").maxUses(2).useCount(0)
.groupIds(new HashSet<>())
.build();
when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
when(appUserRepository.findByEmail(anyString())).thenReturn(Optional.empty());
when(passwordEncoder.encode(anyString())).thenReturn("encoded");
when(appUserRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
RegisterRequest req = new RegisterRequest();
req.setCode("ABCDE12345");
req.setEmail("new@test.com");
req.setPassword("password123");
req.setFirstName("Max");
req.setLastName("Muster");
AppUser created = inviteService.redeemInvite(req);
assertThat(created.getEmail()).isEqualTo("new@test.com");
assertThat(token.getUseCount()).isEqualTo(1);
verify(appUserRepository).save(any());
verify(inviteTokenRepository).save(token);
}
@Test
void redeemInvite_throwsBadRequest_whenPasswordTooShort() {
InviteToken token = InviteToken.builder().code("ABCDE12345").groupIds(new HashSet<>()).build();
when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
RegisterRequest req = new RegisterRequest();
req.setCode("ABCDE12345");
req.setEmail("new@test.com");
req.setPassword("short");
assertThatThrownBy(() -> inviteService.redeemInvite(req))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR);
}
@Test
void redeemInvite_throwsConflict_whenEmailAlreadyInUse() {
InviteToken token = InviteToken.builder().code("ABCDE12345").groupIds(new HashSet<>()).build();
when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
when(appUserRepository.findByEmail("dupe@test.com"))
.thenReturn(Optional.of(AppUser.builder().email("dupe@test.com").build()));
RegisterRequest req = new RegisterRequest();
req.setCode("ABCDE12345");
req.setEmail("dupe@test.com");
req.setPassword("password123");
assertThatThrownBy(() -> inviteService.redeemInvite(req))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.EMAIL_ALREADY_IN_USE);
}
@Test
void redeemInvite_assignsGroupsFromToken() {
UUID groupId = UUID.randomUUID();
UserGroup g = UserGroup.builder().id(groupId).name("Familie").build();
InviteToken token = InviteToken.builder()
.code("ABCDE12345")
.groupIds(new HashSet<>(Set.of(groupId)))
.build();
when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
when(appUserRepository.findByEmail(anyString())).thenReturn(Optional.empty());
when(userGroupRepository.findAllById(any())).thenReturn(List.of(g));
when(passwordEncoder.encode(anyString())).thenReturn("encoded");
when(appUserRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
RegisterRequest req = new RegisterRequest();
req.setCode("ABCDE12345");
req.setEmail("new@test.com");
req.setPassword("password123");
AppUser created = inviteService.redeemInvite(req);
assertThat(created.getGroups()).contains(g);
}
// ─── revokeInvite ─────────────────────────────────────────────────────────
@Test
void revokeInvite_setsRevokedTrue() {
UUID id = UUID.randomUUID();
InviteToken token = InviteToken.builder().id(id).code("ABCDE12345").build();
when(inviteTokenRepository.findById(id)).thenReturn(Optional.of(token));
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
inviteService.revokeInvite(id);
assertThat(token.isRevoked()).isTrue();
verify(inviteTokenRepository).save(token);
}
@Test
void revokeInvite_throwsNotFound_whenIdUnknown() {
UUID id = UUID.randomUUID();
when(inviteTokenRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> inviteService.revokeInvite(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVITE_NOT_FOUND);
}
}