refactor(invite): move user creation into UserService, add generateCode limit

InviteService was directly injecting AppUserRepository, UserGroupRepository,
and PasswordEncoder — crossing domain boundaries that UserService owns.

- Add UserService.createUser() with duplicate-email guard
- Add UserService.findGroupsByIds() delegation method
- InviteService now only injects UserService (not user repositories)
- generateCode() now throws INTERNAL_ERROR after 10 failed attempts
  instead of looping indefinitely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 09:03:29 +02:00
parent 103d454e14
commit f8f5ea634e
4 changed files with 126 additions and 62 deletions

View File

@@ -13,17 +13,10 @@ 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.*;
@@ -33,9 +26,7 @@ import static org.mockito.Mockito.*;
class InviteServiceTest {
@Mock InviteTokenRepository inviteTokenRepository;
@Mock AppUserRepository appUserRepository;
@Mock UserGroupRepository userGroupRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock UserService userService;
@InjectMocks InviteService inviteService;
private AppUser admin;
@@ -64,6 +55,16 @@ class InviteServiceTest {
verify(inviteTokenRepository, times(2)).findByCode(anyString());
}
@Test
void generateCode_throwsInternalError_afterMaxAttempts() {
when(inviteTokenRepository.findByCode(anyString()))
.thenReturn(Optional.of(InviteToken.builder().code("AAAAAAAAAA").build()));
assertThatThrownBy(() -> inviteService.generateCode())
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INTERNAL_ERROR);
}
// ─── validateCode ─────────────────────────────────────────────────────────
@Test
@@ -145,7 +146,7 @@ class InviteServiceTest {
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(userService.findGroupsByIds(anyList())).thenReturn(List.of(g));
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
CreateInviteRequest req = new CreateInviteRequest();
@@ -164,9 +165,9 @@ class InviteServiceTest {
.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));
AppUser created = AppUser.builder().id(UUID.randomUUID()).email("new@test.com").build();
when(userService.createUser(eq("new@test.com"), eq("password123"), eq("Max"), eq("Muster"), any()))
.thenReturn(created);
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
RegisterRequest req = new RegisterRequest();
@@ -176,11 +177,11 @@ class InviteServiceTest {
req.setFirstName("Max");
req.setLastName("Muster");
AppUser created = inviteService.redeemInvite(req);
AppUser result = inviteService.redeemInvite(req);
assertThat(created.getEmail()).isEqualTo("new@test.com");
assertThat(result.getEmail()).isEqualTo("new@test.com");
assertThat(token.getUseCount()).isEqualTo(1);
verify(appUserRepository).save(any());
verify(userService).createUser(eq("new@test.com"), eq("password123"), eq("Max"), eq("Muster"), any());
verify(inviteTokenRepository).save(token);
}
@@ -204,8 +205,8 @@ class InviteServiceTest {
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()));
when(userService.createUser(eq("dupe@test.com"), any(), any(), any(), any()))
.thenThrow(DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, "Email already registered"));
RegisterRequest req = new RegisterRequest();
req.setCode("ABCDE12345");
@@ -219,18 +220,15 @@ class InviteServiceTest {
}
@Test
void redeemInvite_assignsGroupsFromToken() {
void redeemInvite_passesGroupIdsFromTokenToUserService() {
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));
AppUser created = AppUser.builder().id(UUID.randomUUID()).email("new@test.com").build();
when(userService.createUser(any(), any(), any(), any(), eq(Set.of(groupId)))).thenReturn(created);
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
RegisterRequest req = new RegisterRequest();
@@ -238,8 +236,9 @@ class InviteServiceTest {
req.setEmail("new@test.com");
req.setPassword("password123");
AppUser created = inviteService.redeemInvite(req);
assertThat(created.getGroups()).contains(g);
inviteService.redeemInvite(req);
verify(userService).createUser(any(), any(), any(), any(), eq(Set.of(groupId)));
}
// ─── revokeInvite ─────────────────────────────────────────────────────────

View File

@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
@@ -644,6 +645,60 @@ class UserServiceTest {
verify(groupRepository, never()).findAllById(any());
}
// ─── createUser ───────────────────────────────────────────────────────────
@Test
void createUser_savesNewUser_withEncodedPassword() {
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("secret")).thenReturn("hashed");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUser("new@example.com", "secret", "Max", "Muster", Set.of());
assertThat(result).isEqualTo(saved);
verify(passwordEncoder).encode("secret");
verify(userRepository).save(any());
}
@Test
void createUser_throwsConflict_whenEmailAlreadyExists() {
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("dupe@example.com").build();
when(userRepository.findByEmail("dupe@example.com")).thenReturn(Optional.of(existing));
assertThatThrownBy(() -> userService.createUser("dupe@example.com", "pass", null, null, Set.of()))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.EMAIL_ALREADY_IN_USE);
}
@Test
void createUser_assignsGroupsFromIds() {
UUID groupId = UUID.randomUUID();
UserGroup g = UserGroup.builder().id(groupId).name("Familie").build();
when(userRepository.findByEmail("u@example.com")).thenReturn(Optional.empty());
when(groupRepository.findAllById(Set.of(groupId))).thenReturn(List.of(g));
when(passwordEncoder.encode(any())).thenReturn("hashed");
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AppUser result = userService.createUser("u@example.com", "pass", null, null, Set.of(groupId));
assertThat(result.getGroups()).contains(g);
}
// ─── findGroupsByIds ───────────────────────────────────────────────────────
@Test
void findGroupsByIds_delegatesToRepository() {
UUID id = UUID.randomUUID();
UserGroup g = UserGroup.builder().id(id).name("Admins").build();
when(groupRepository.findAllById(List.of(id))).thenReturn(List.of(g));
List<UserGroup> result = userService.findGroupsByIds(List.of(id));
assertThat(result).containsExactly(g);
}
// ─── createGroup ──────────────────────────────────────────────────────────
@Test