diff --git a/src/main/java/auth_service/domain/login/persistence/MinimalUserRepository.java b/src/main/java/auth_service/domain/login/persistence/MinimalUserRepository.java index 9af4592..bf0182f 100644 --- a/src/main/java/auth_service/domain/login/persistence/MinimalUserRepository.java +++ b/src/main/java/auth_service/domain/login/persistence/MinimalUserRepository.java @@ -15,4 +15,6 @@ public interface MinimalUserRepository { * @return An Optional containing the user if found */ Optional findByEmail(String email); + + void updateLoginDate(long userId); } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/login/persistence/MinimalUserRepositoryImpl.java b/src/main/java/auth_service/domain/login/persistence/MinimalUserRepositoryImpl.java index 0bbdf7d..6d66792 100644 --- a/src/main/java/auth_service/domain/login/persistence/MinimalUserRepositoryImpl.java +++ b/src/main/java/auth_service/domain/login/persistence/MinimalUserRepositoryImpl.java @@ -8,6 +8,8 @@ import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; import org.springframework.stereotype.Repository; +import java.time.Clock; +import java.time.LocalDateTime; import java.util.Optional; @Repository @@ -30,4 +32,14 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository { .findFirst() .map(EntityMapper::mapToModel); } + + @Override + public void updateLoginDate(long userId) { + var userEntity = entityManager.find(MinimalUserEntity.class, userId); + + if (userEntity != null) { + userEntity.setLastLoginDate(LocalDateTime.now(Clock.systemUTC())); + entityManager.flush(); + } + } } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/login/persistence/entities/MinimalUserEntity.java b/src/main/java/auth_service/domain/login/persistence/entities/MinimalUserEntity.java index 28532ba..ecd098d 100644 --- a/src/main/java/auth_service/domain/login/persistence/entities/MinimalUserEntity.java +++ b/src/main/java/auth_service/domain/login/persistence/entities/MinimalUserEntity.java @@ -3,6 +3,8 @@ package auth_service.domain.login.persistence.entities; import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + /** * Represents a user entity mapped to the 'users' table in PostgreSQL. */ @@ -27,4 +29,7 @@ public class MinimalUserEntity { @Column(nullable = false) private boolean enabled; + + @Column(name = "last_login_date", nullable = false) + private LocalDateTime lastLoginDate; } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/login/service/LoginService.java b/src/main/java/auth_service/domain/login/service/LoginService.java index ac80d6e..0d9d901 100644 --- a/src/main/java/auth_service/domain/login/service/LoginService.java +++ b/src/main/java/auth_service/domain/login/service/LoginService.java @@ -22,12 +22,12 @@ public class LoginService { * @return The authenticated User model */ public User login(User userToLogin) { - var userFromDatabase = minimalUserRepository.findByEmail(userToLogin.getEmail()).orElseThrow(() -> new UserNotFoundException("User not found with given email")); - var loginSuccessful = passwordEncoder.matches(userToLogin.getPassword(), userFromDatabase.getPassword()); + var foundUser = minimalUserRepository.findByEmail(userToLogin.getEmail()).orElseThrow(() -> new UserNotFoundException("User not found with given email")); + var loginSuccessful = passwordEncoder.matches(userToLogin.getPassword(), foundUser.getPassword()); if (!loginSuccessful) { throw new InvalidCredentialsException("Invalid credentials"); } - - return userFromDatabase; + minimalUserRepository.updateLoginDate(foundUser.getId()); + return foundUser; } } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/controller/RegistrationExceptionController.java b/src/main/java/auth_service/domain/registration/controller/RegistrationExceptionController.java new file mode 100644 index 0000000..70a75ef --- /dev/null +++ b/src/main/java/auth_service/domain/registration/controller/RegistrationExceptionController.java @@ -0,0 +1,45 @@ +package auth_service.domain.registration.controller; + +import auth_service.domain.registration.controller.dto.response.ErrorResponse; +import auth_service.domain.registration.exception.EmailAlreadyInUseException; +import auth_service.domain.registration.exception.InvalidInvitationCodeException; +import auth_service.domain.registration.exception.SteamIdAlreadyRegisteredException; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +@Order(value = 1) +@Slf4j +public class RegistrationExceptionController { + + private static @NonNull ResponseEntity response(ErrorResponse body, HttpStatus status) { + return ResponseEntity.status(status.value()) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(body); + } + + @ExceptionHandler(EmailAlreadyInUseException.class) + public ResponseEntity clientError(EmailAlreadyInUseException ex) { + var body = new ErrorResponse("EMAIL_ALREADY_IN_USE", ex.getMessage()); + return response(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(InvalidInvitationCodeException.class) + public ResponseEntity clientError(InvalidInvitationCodeException ex) { + var body = new ErrorResponse("INVALID_INVITATION_CODE", ex.getMessage()); + return response(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(SteamIdAlreadyRegisteredException.class) + public ResponseEntity clientError(SteamIdAlreadyRegisteredException ex) { + var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", ex.getMessage()); + return response(body, HttpStatus.BAD_REQUEST); + } + + +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/controller/dto/response/ErrorResponse.java b/src/main/java/auth_service/domain/registration/controller/dto/response/ErrorResponse.java new file mode 100644 index 0000000..d870909 --- /dev/null +++ b/src/main/java/auth_service/domain/registration/controller/dto/response/ErrorResponse.java @@ -0,0 +1,4 @@ +package auth_service.domain.registration.controller.dto.response; + +public record ErrorResponse(String code, String message) { +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/exception/EmailAlreadyInUseException.java b/src/main/java/auth_service/domain/registration/exception/EmailAlreadyInUseException.java new file mode 100644 index 0000000..6972720 --- /dev/null +++ b/src/main/java/auth_service/domain/registration/exception/EmailAlreadyInUseException.java @@ -0,0 +1,7 @@ +package auth_service.domain.registration.exception; + +public class EmailAlreadyInUseException extends RuntimeException { + public EmailAlreadyInUseException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/exception/InvalidInvitationCodeException.java b/src/main/java/auth_service/domain/registration/exception/InvalidInvitationCodeException.java new file mode 100644 index 0000000..bb201e5 --- /dev/null +++ b/src/main/java/auth_service/domain/registration/exception/InvalidInvitationCodeException.java @@ -0,0 +1,7 @@ +package auth_service.domain.registration.exception; + +public class InvalidInvitationCodeException extends RuntimeException { + public InvalidInvitationCodeException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/exception/SteamIdAlreadyRegisteredException.java b/src/main/java/auth_service/domain/registration/exception/SteamIdAlreadyRegisteredException.java new file mode 100644 index 0000000..9422e51 --- /dev/null +++ b/src/main/java/auth_service/domain/registration/exception/SteamIdAlreadyRegisteredException.java @@ -0,0 +1,7 @@ +package auth_service.domain.registration.exception; + +public class SteamIdAlreadyRegisteredException extends RuntimeException { + public SteamIdAlreadyRegisteredException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/model/RegisteredUser.java b/src/main/java/auth_service/domain/registration/model/RegisteredUser.java new file mode 100644 index 0000000..85073e2 --- /dev/null +++ b/src/main/java/auth_service/domain/registration/model/RegisteredUser.java @@ -0,0 +1,7 @@ +package auth_service.domain.registration.model; + +public record RegisteredUser( + String id, + String email +) { +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/model/Registration.java b/src/main/java/auth_service/domain/registration/model/Registration.java new file mode 100644 index 0000000..1660b51 --- /dev/null +++ b/src/main/java/auth_service/domain/registration/model/Registration.java @@ -0,0 +1,14 @@ +package auth_service.domain.registration.model; + +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class Registration { + private String email; + private String rawPassword; + private String encryptedPassword; + private String inviteCode; + private long steamId; +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/persistence/RegisteredUserRepository.java b/src/main/java/auth_service/domain/registration/persistence/RegisteredUserRepository.java new file mode 100644 index 0000000..04ed917 --- /dev/null +++ b/src/main/java/auth_service/domain/registration/persistence/RegisteredUserRepository.java @@ -0,0 +1,12 @@ +package auth_service.domain.registration.persistence; + +import auth_service.domain.registration.model.RegisteredUser; +import auth_service.domain.registration.model.Registration; + +public interface RegisteredUserRepository { + boolean existsByEmail(String email); + + boolean existsBySteamId(long steamId); + + RegisteredUser save(Registration registration); +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/persistence/RegisteredUserRepositoryImpl.java b/src/main/java/auth_service/domain/registration/persistence/RegisteredUserRepositoryImpl.java new file mode 100644 index 0000000..529af99 --- /dev/null +++ b/src/main/java/auth_service/domain/registration/persistence/RegisteredUserRepositoryImpl.java @@ -0,0 +1,23 @@ +package auth_service.domain.registration.persistence; + +import auth_service.domain.registration.model.RegisteredUser; +import auth_service.domain.registration.model.Registration; +import org.springframework.stereotype.Repository; + +@Repository +public class RegisteredUserRepositoryImpl implements RegisteredUserRepository { + @Override + public boolean existsByEmail(String email) { + return false; + } + + @Override + public boolean existsBySteamId(long steamId) { + return false; + } + + @Override + public RegisteredUser save(Registration registration) { + return null; + } +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/service/CodeValidationService.java b/src/main/java/auth_service/domain/registration/service/CodeValidationService.java new file mode 100644 index 0000000..4ff0047 --- /dev/null +++ b/src/main/java/auth_service/domain/registration/service/CodeValidationService.java @@ -0,0 +1,19 @@ +package auth_service.domain.registration.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CodeValidationService { + + private static final String RANDOM_INVITATION_CODE = "test-invite-code"; + + public boolean isCodeValid(String code) { + if (code == null || code.isEmpty()) { + return false; + } + + return code.equals(RANDOM_INVITATION_CODE); + } +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/registration/service/RegistrationService.java b/src/main/java/auth_service/domain/registration/service/RegistrationService.java new file mode 100644 index 0000000..bc4f1ca --- /dev/null +++ b/src/main/java/auth_service/domain/registration/service/RegistrationService.java @@ -0,0 +1,39 @@ +package auth_service.domain.registration.service; + +import auth_service.domain.registration.exception.EmailAlreadyInUseException; +import auth_service.domain.registration.exception.InvalidInvitationCodeException; +import auth_service.domain.registration.exception.SteamIdAlreadyRegisteredException; +import auth_service.domain.registration.model.RegisteredUser; +import auth_service.domain.registration.model.Registration; +import auth_service.domain.registration.persistence.RegisteredUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RegistrationService { + + private final CodeValidationService codeValidationService; + private final RegisteredUserRepository registeredUserRepository; + private final PasswordEncoder passwordEncoder; + + public RegisteredUser register(Registration registration) { + if (!codeValidationService.isCodeValid(registration.getInviteCode())) { + throw new InvalidInvitationCodeException("The provided invite code is invalid or has already been used."); + } + + if (registeredUserRepository.existsByEmail(registration.getEmail())) { + throw new EmailAlreadyInUseException("The provided email address is already in use."); + } + + if (registeredUserRepository.existsBySteamId(registration.getSteamId())) { + throw new SteamIdAlreadyRegisteredException("The steam id is already in use."); + } + + var rawPassword = registration.getRawPassword(); + registration.setEncryptedPassword(passwordEncoder.encode(rawPassword)); + + return registeredUserRepository.save(registration); + } +} \ No newline at end of file diff --git a/src/main/java/auth_service/domain/user/controller/dto/response/UserProfileResponse.java b/src/main/java/auth_service/domain/user/controller/dto/response/UserProfileResponse.java index 1b347dc..5692bc2 100644 --- a/src/main/java/auth_service/domain/user/controller/dto/response/UserProfileResponse.java +++ b/src/main/java/auth_service/domain/user/controller/dto/response/UserProfileResponse.java @@ -2,6 +2,8 @@ package auth_service.domain.user.controller.dto.response; import lombok.Builder; +import java.time.LocalDateTime; + @Builder -public record UserProfileResponse(long id, String email, String role) { +public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, long steamId) { } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/user/controller/mapper/ApiMapper.java b/src/main/java/auth_service/domain/user/controller/mapper/ApiMapper.java index fc5c63d..f7eeb72 100644 --- a/src/main/java/auth_service/domain/user/controller/mapper/ApiMapper.java +++ b/src/main/java/auth_service/domain/user/controller/mapper/ApiMapper.java @@ -12,6 +12,9 @@ public abstract class ApiMapper { return UserProfileResponse.builder() .id(user.getId()) .email(user.getEmail()) + .createdDate(user.getCreatedDate()) + .lastLogin(user.getLastLogin()) + .steamId(user.getSteamId()) .role("USER").build(); } } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/user/model/User.java b/src/main/java/auth_service/domain/user/model/User.java index d13cb92..924e0e9 100644 --- a/src/main/java/auth_service/domain/user/model/User.java +++ b/src/main/java/auth_service/domain/user/model/User.java @@ -3,10 +3,15 @@ package auth_service.domain.user.model; import lombok.Builder; import lombok.Data; +import java.time.LocalDateTime; + @Builder @Data public class User { private long id; private String email; private String encryptedPassword; + private LocalDateTime lastLogin; + private LocalDateTime createdDate; + private long steamId; } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/user/persistence/entities/UserEntity.java b/src/main/java/auth_service/domain/user/persistence/entities/UserEntity.java index 49ec533..db0f5bc 100644 --- a/src/main/java/auth_service/domain/user/persistence/entities/UserEntity.java +++ b/src/main/java/auth_service/domain/user/persistence/entities/UserEntity.java @@ -3,6 +3,8 @@ package auth_service.domain.user.persistence.entities; import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + /** * Represents a user entity mapped to the 'users' table in PostgreSQL. */ @@ -27,4 +29,13 @@ public class UserEntity { @Column(nullable = false) private boolean enabled; + + @Column(name = "steam_id", unique = true, nullable = false) + private Long steamId; + + @Column(name = "registration_date", nullable = false) + private LocalDateTime registrationDate; + + @Column(name = "last_login_date", nullable = false) + private LocalDateTime lastLoginDate; } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/domain/user/persistence/mapper/EntityMapper.java index 8f631fa..666d20f 100644 --- a/src/main/java/auth_service/domain/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/domain/user/persistence/mapper/EntityMapper.java @@ -15,6 +15,9 @@ public abstract class EntityMapper { model.setId(entity.getId()); model.setEmail(entity.getEmail()); model.setEncryptedPassword(entity.getPassword()); + model.setLastLogin(entity.getLastLoginDate()); + model.setCreatedDate(entity.getRegistrationDate()); + model.setSteamId(entity.getSteamId()); } return model; diff --git a/src/main/resources/db/migration/V2__Add_User_Details.sql b/src/main/resources/db/migration/V2__Add_User_Details.sql new file mode 100644 index 0000000..3392ebb --- /dev/null +++ b/src/main/resources/db/migration/V2__Add_User_Details.sql @@ -0,0 +1,7 @@ +-- Create the users table +ALTER TABLE users + ADD COLUMN steam_id BIGINT, + ADD COLUMN registration_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN last_login_date TIMESTAMP; + +ALTER TABLE users ADD CONSTRAINT uc_steam_id UNIQUE (steam_id); \ No newline at end of file diff --git a/src/test/java/auth_service/domain/registration/service/RegistrationServiceTest.java b/src/test/java/auth_service/domain/registration/service/RegistrationServiceTest.java new file mode 100644 index 0000000..2890d33 --- /dev/null +++ b/src/test/java/auth_service/domain/registration/service/RegistrationServiceTest.java @@ -0,0 +1,102 @@ +package auth_service.domain.registration.service; + +import auth_service.domain.registration.exception.EmailAlreadyInUseException; +import auth_service.domain.registration.exception.InvalidInvitationCodeException; +import auth_service.domain.registration.exception.SteamIdAlreadyRegisteredException; +import auth_service.domain.registration.model.RegisteredUser; +import auth_service.domain.registration.model.Registration; +import auth_service.domain.registration.persistence.RegisteredUserRepository; +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.springframework.security.crypto.password.PasswordEncoder; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RegistrationServiceTest { + + @Mock + private CodeValidationService codeValidationService; + + @Mock + private RegisteredUserRepository registeredUserRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private RegistrationService registrationService; + + private Registration validRegistration; + + @BeforeEach + void setUp() { + validRegistration = Registration.builder() + .email("test@example.com") + .rawPassword("SuperSecret123!") + .encryptedPassword("") + .inviteCode("TBP-INVITE-2026") + .steamId(314159L) + .build(); + } + + @Test + void register_withValidData_shouldReturnRegisteredUserAndMarkCodeAsUsed() { + // Arrange + when(codeValidationService.isCodeValid("TBP-INVITE-2026")).thenReturn(true); + when(registeredUserRepository.existsByEmail("test@example.com")).thenReturn(false); + when(passwordEncoder.encode("SuperSecret123!")).thenReturn("hashedPasswordXYZ"); + + RegisteredUser expectedUser = new RegisteredUser("uuid-1234", "test@example.com"); + when(registeredUserRepository.save(validRegistration)).thenReturn(expectedUser); + + // Act + RegisteredUser result = registrationService.register(validRegistration); + + // Assert + assertNotNull(result); + assertEquals("uuid-1234", result.id()); + assertEquals("test@example.com", result.email()); + } + + @Test + void register_withInvalidInviteCode_shouldThrowException() { + // Arrange + when(codeValidationService.isCodeValid("TBP-INVITE-2026")).thenReturn(false); + + // Act & Assert + assertThrows(InvalidInvitationCodeException.class, () -> registrationService.register(validRegistration)); + + verify(registeredUserRepository, never()).save(any()); + } + + @Test + void register_withExistingEmail_shouldThrowException() { + // Arrange + when(codeValidationService.isCodeValid("TBP-INVITE-2026")).thenReturn(true); + when(registeredUserRepository.existsByEmail("test@example.com")).thenReturn(true); + + // Act & Assert + assertThrows(EmailAlreadyInUseException.class, () -> registrationService.register(validRegistration)); + + verify(registeredUserRepository, never()).save(any()); + } + + @Test + void register_withExistingSteamId_shouldThrowException() { + // Arrange + when(codeValidationService.isCodeValid("TBP-INVITE-2026")).thenReturn(true); + when(registeredUserRepository.existsByEmail("test@example.com")).thenReturn(false); + when(registeredUserRepository.existsBySteamId(314159L)).thenReturn(true); + + // Act & Assert + assertThrows(SteamIdAlreadyRegisteredException.class, () -> registrationService.register(validRegistration)); + + verify(registeredUserRepository, never()).save(any()); + } +} \ No newline at end of file