diff --git a/readme.md b/readme.md index a288697..255aa76 100644 --- a/readme.md +++ b/readme.md @@ -131,6 +131,9 @@ layers: | Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. | | Persistence | `persistence`, `repo` | JPA entities, Spring Data repositories, `EntityMapper`; `repo` holds `JdbcTemplate` queries. Entities never reach the API layer. | +`user` owns the tables `users` and `user_settings` through the one JPA entity `UserEntity`. `login` and +`registration` read and write `users` with `JdbcTemplate` (refresh tokens stay JPA). Every query for "a user who +may log in and is shown to others" uses `shared/account/ActiveUser`. `profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package. `version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image. `account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core diff --git a/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java b/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java index 48623fa..5a2fb77 100644 --- a/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java @@ -1,13 +1,11 @@ package auth_service.modules.login.persistence; -import auth_service.shared.account.EmailAddress; import auth_service.modules.login.model.User; -import auth_service.modules.login.persistence.entities.MinimalUserEntity; -import auth_service.modules.login.persistence.mapper.EntityMapper; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.TypedQuery; -import jakarta.transaction.Transactional; +import auth_service.shared.account.EmailAddress; +import auth_service.shared.security.Role; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; import java.time.Clock; @@ -15,39 +13,36 @@ import java.time.LocalDateTime; import java.util.Optional; @Repository -@Transactional +@RequiredArgsConstructor public class MinimalUserRepositoryImpl implements MinimalUserRepository { - @PersistenceContext - private EntityManager entityManager; + + private static final String SELECT_USER = + "SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL AS deleted, role FROM users "; + + private static final RowMapper USER_ROW = (rs, row) -> User.builder() + .id(rs.getLong("id")) + .email(rs.getString("email")) + .password(rs.getString("password")) + .steamId(rs.getObject("steam_id", Long.class)) + .enabled(rs.getBoolean("enabled")) + .deleted(rs.getBoolean("deleted")) + .role(Role.parse(rs.getString("role"))) + .build(); + + private final JdbcTemplate jdbc; @Override public Optional findByEmail(String email) { - var criteriaBuilder = entityManager.getCriteriaBuilder(); - var userEntityCriteriaQuery = criteriaBuilder.createQuery(MinimalUserEntity.class); - var userEntity = userEntityCriteriaQuery.from(MinimalUserEntity.class); - - userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal( - userEntity.get("email"), EmailAddress.normalize(email))); - - TypedQuery typedQuery = entityManager.createQuery(userEntityCriteriaQuery); - - return typedQuery.getResultStream() - .findFirst() - .map(EntityMapper::mapToModel); + return jdbc.query(SELECT_USER + "WHERE email = ?", USER_ROW, EmailAddress.normalize(email)).stream().findFirst(); } @Override public Optional findById(long userId) { - return Optional.ofNullable(entityManager.find(MinimalUserEntity.class, userId)).map(EntityMapper::mapToModel); + return jdbc.query(SELECT_USER + "WHERE id = ?", USER_ROW, userId).stream().findFirst(); } @Override public void updateLoginDate(long userId) { - var userEntity = entityManager.find(MinimalUserEntity.class, userId); - - if (userEntity != null) { - userEntity.setLastLoginDate(LocalDateTime.now(Clock.systemUTC())); - entityManager.flush(); - } + jdbc.update("UPDATE users SET last_login_date = ? WHERE id = ?", LocalDateTime.now(Clock.systemUTC()), userId); } } diff --git a/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java b/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java deleted file mode 100644 index 9fe6dfe..0000000 --- a/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java +++ /dev/null @@ -1,46 +0,0 @@ -package auth_service.modules.login.persistence.entities; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.Instant; -import java.time.LocalDateTime; - -/** - * Represents a user entity mapped to the 'users' table in PostgreSQL. - */ -@Entity -@Table(name = "users") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class MinimalUserEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(unique = true, nullable = false) - private String email; - - @Column(nullable = false) - private String password; - - @Column(nullable = false) - private boolean enabled; - - @Column(name = "last_login_date") - private LocalDateTime lastLoginDate; - - @Column(name = "steam_id") - private Long steamId; - - @Column(name = "deleted_at", insertable = false, updatable = false) - private Instant deletedAt; - - // Set by SQL only (the first admin). - @Column(name = "role", insertable = false, updatable = false) - private String role; -} diff --git a/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java index adfdf82..611297b 100644 --- a/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java @@ -1,8 +1,6 @@ package auth_service.modules.login.persistence.mapper; import auth_service.modules.login.model.RefreshToken; -import auth_service.modules.login.model.User; -import auth_service.modules.login.persistence.entities.MinimalUserEntity; import auth_service.modules.login.persistence.entities.RefreshTokenEntity; import auth_service.shared.security.Role; @@ -11,22 +9,6 @@ public abstract class EntityMapper { private EntityMapper() { } - public static User mapToModel(MinimalUserEntity entity) { - User model = User.builder().build(); - - if (entity != null) { - model.setId(entity.getId()); - model.setEmail(entity.getEmail()); - model.setPassword(entity.getPassword()); - model.setSteamId(entity.getSteamId()); - model.setEnabled(entity.isEnabled()); - model.setDeleted(entity.getDeletedAt() != null); - model.setRole(Role.parse(entity.getRole())); - } - - return model; - } - public static RefreshToken mapToModel(RefreshTokenEntity entity) { return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(), entity.getRotatedAt(), entity.getRevokedAt(), diff --git a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java index 3c24715..8ff36b5 100644 --- a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java +++ b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java @@ -1,6 +1,7 @@ package auth_service.modules.profile.repo; import auth_service.modules.profile.model.PublicProfile; +import auth_service.shared.account.ActiveUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; @@ -32,8 +33,8 @@ public class PublicProfileRepository { SELECT u.id, u.display_name, u.registration_date, s.profile_accent, s.profile_corners, s.profile_banner_appid, s.profile_background_appid, s.profile_tiles FROM users u JOIN user_settings s ON s.user_id = u.id - WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile - """, + WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile + """.formatted(ActiveUser.of("u")), (rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"), rs.getTimestamp("registration_date") != null ? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null, diff --git a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java index 0720cc9..d20708c 100644 --- a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java @@ -1,44 +1,44 @@ package auth_service.modules.registration.persistence; -import auth_service.shared.account.EmailAddress; import auth_service.modules.registration.model.Registration; -import auth_service.modules.registration.persistence.mapper.EntityMapper; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.transaction.Transactional; +import auth_service.shared.account.EmailAddress; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; @Repository -@Transactional +@RequiredArgsConstructor public class RegisteredUserRepositoryImpl implements RegisteredUserRepository { - @PersistenceContext - private EntityManager entityManager; + private final JdbcTemplate jdbc; @Override public boolean existsByEmail(String email) { - var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE u.email = :email"; - var count = entityManager.createQuery(jpql, Long.class) - .setParameter("email", EmailAddress.normalize(email)) - .getSingleResult(); - - return count != null && count > 0; + return Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS (SELECT 1 FROM users WHERE email = ?)", + Boolean.class, EmailAddress.normalize(email))); } @Override public boolean existsByDisplayName(String displayName) { - var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName)"; - var count = entityManager.createQuery(jpql, Long.class) - .setParameter("displayName", displayName) - .getSingleResult(); - - return count != null && count > 0; + return Boolean.TRUE.equals(jdbc.queryForObject( + "SELECT EXISTS (SELECT 1 FROM users WHERE LOWER(display_name) = LOWER(?))", Boolean.class, displayName)); } @Override + @Transactional public long save(Registration registration) { - var userToRegisterEntity = EntityMapper.mapToPersistence(registration); - entityManager.persist(userToRegisterEntity); - return userToRegisterEntity.getId(); + Long id = jdbc.queryForObject(""" + INSERT INTO users (email, password, enabled, display_name, registration_date) + VALUES (?, ?, TRUE, ?, ?) RETURNING id""", Long.class, + registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(), + LocalDateTime.now(Clock.systemUTC())); + if (id == null) { + throw new IllegalStateException("No id for the new user"); + } + return id; } } diff --git a/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java b/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java deleted file mode 100644 index 1556499..0000000 --- a/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java +++ /dev/null @@ -1,38 +0,0 @@ -package auth_service.modules.registration.persistence.entities; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "users") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Inheritance(strategy = InheritanceType.SINGLE_TABLE) -public class RegisteredUserEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(unique = true, nullable = false) - private String email; - - @Column(nullable = false) - private String password; - - @Column(nullable = false) - private boolean enabled; - - @Column(name = "display_name", length = 24) - private String displayName; - - @Column(name = "registration_date", nullable = false) - private LocalDateTime registrationDate; - - @Column(name = "last_login_date") - private LocalDateTime lastLoginDate; -} \ No newline at end of file diff --git a/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java deleted file mode 100644 index e371e05..0000000 --- a/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java +++ /dev/null @@ -1,24 +0,0 @@ -package auth_service.modules.registration.persistence.mapper; - -import auth_service.modules.registration.model.Registration; -import auth_service.modules.registration.persistence.entities.RegisteredUserEntity; - -import java.time.Clock; -import java.time.LocalDateTime; - -public abstract class EntityMapper { - - private EntityMapper() { - } - - public static RegisteredUserEntity mapToPersistence(Registration userModel) { - return RegisteredUserEntity.builder(). - email(userModel.getEmail()). - registrationDate(LocalDateTime.now(Clock.systemUTC())). - password(userModel.getEncryptedPassword()). - enabled(true). - displayName(userModel.getDisplayName()). - lastLoginDate(null). - build(); - } -} diff --git a/src/main/java/auth_service/modules/stats/repo/StatsRepository.java b/src/main/java/auth_service/modules/stats/repo/StatsRepository.java index cd452b4..2d6d00f 100644 --- a/src/main/java/auth_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/auth_service/modules/stats/repo/StatsRepository.java @@ -1,5 +1,6 @@ package auth_service.modules.stats.repo; +import auth_service.shared.account.ActiveUser; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; @@ -15,13 +16,13 @@ public class StatsRepository { } public long countTotalUsers() { - Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL", Long.class); + Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class); return v != null ? v : 0L; } public long countUsersWithSteamLinked() { Long v = jdbc.queryForObject( - "SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL AND steam_id IS NOT NULL", Long.class); + "SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u") + " AND u.steam_id IS NOT NULL", Long.class); return v != null ? v : 0L; } } diff --git a/src/main/java/auth_service/shared/account/ActiveUser.java b/src/main/java/auth_service/shared/account/ActiveUser.java new file mode 100644 index 0000000..0b835a4 --- /dev/null +++ b/src/main/java/auth_service/shared/account/ActiveUser.java @@ -0,0 +1,14 @@ +package auth_service.shared.account; + +/** + * The SQL rule for a user who may log in and is shown to others: not blocked and not deleted. + */ +public final class ActiveUser { + + private ActiveUser() { + } + + public static String of(String alias) { + return alias + ".enabled AND " + alias + ".deleted_at IS NULL"; + } +} diff --git a/src/test/java/auth_service/modules/login/persistence/MinimalUserRepositoryTest.java b/src/test/java/auth_service/modules/login/persistence/MinimalUserRepositoryTest.java index 2aa6ebf..1727f6d 100644 --- a/src/test/java/auth_service/modules/login/persistence/MinimalUserRepositoryTest.java +++ b/src/test/java/auth_service/modules/login/persistence/MinimalUserRepositoryTest.java @@ -1,13 +1,19 @@ package auth_service.modules.login.persistence; -import auth_service.modules.login.persistence.entities.MinimalUserEntity; +import auth_service.modules.login.model.User; +import auth_service.shared.security.Role; import auth_service.shared.test.AbstractPersistenceTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; -import static org.junit.jupiter.api.Assertions.*; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @Import(MinimalUserRepositoryImpl.class) class MinimalUserRepositoryTest extends AbstractPersistenceTest { @@ -16,53 +22,47 @@ class MinimalUserRepositoryTest extends AbstractPersistenceTest { private MinimalUserRepository minimalUserRepository; @Autowired - private TestEntityManager entityManager; + private JdbcTemplate jdbc; - @Test - void findByEmail_Success() { - // Arrange - var email = "minimal@example.com"; - var entity = MinimalUserEntity.builder() - .email(email) - .password("password") - .enabled(true) - .build(); - entityManager.persist(entity); - entityManager.flush(); - - // Act - var result = minimalUserRepository.findByEmail(email); - - // Assert - assertTrue(result.isPresent()); - assertEquals(email, result.get().getEmail()); + private long insertUser(String email) { + Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, registration_date) " + + "VALUES (?, 'password', TRUE, ?) RETURNING id", Long.class, email, LocalDateTime.now()); + return id == null ? 0 : id; } @Test - void updateLoginDate_Success() { - // Arrange - var entity = MinimalUserEntity.builder() - .email("login@example.com") - .password("password") - .enabled(true) - .build(); - entity = entityManager.persist(entity); - entityManager.flush(); + void findByEmail_MapsTheUser() { + long id = insertUser("minimal@example.com"); + jdbc.update("UPDATE users SET steam_id = 76561198000000001, deleted_at = now() WHERE id = ?", id); - // Act - minimalUserRepository.updateLoginDate(entity.getId()); - entityManager.clear(); // Clear cache to force reload from DB + User user = minimalUserRepository.findByEmail("minimal@example.com").orElseThrow(); - // Assert - var updated = entityManager.find(MinimalUserEntity.class, entity.getId()); - assertNotNull(updated.getLastLoginDate()); + assertEquals(id, user.getId()); + assertEquals("password", user.getPassword()); + assertEquals(76561198000000001L, user.getSteamId()); + assertTrue(user.isEnabled()); + assertTrue(user.isDeleted()); + assertEquals(Role.USER, user.getRole()); + } + + @Test + void findById_UnknownUser_IsEmpty() { + assertFalse(minimalUserRepository.findById(-1).isPresent()); + } + + @Test + void updateLoginDate_StoresTheDate() { + long id = insertUser("login@example.com"); + + minimalUserRepository.updateLoginDate(id); + + assertNotNull(jdbc.queryForObject("SELECT last_login_date FROM users WHERE id = ?", LocalDateTime.class, id)); } // An access token from before V19 still carries the email as it was typed. @Test void findByEmail_IgnoresCase() { - entityManager.persist(MinimalUserEntity.builder().email("case@example.com").password("password").enabled(true).build()); - entityManager.flush(); + insertUser("case@example.com"); assertTrue(minimalUserRepository.findByEmail("Case@Example.com").isPresent()); } diff --git a/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java b/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java index a972c1a..e44bb37 100644 --- a/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java +++ b/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java @@ -1,14 +1,19 @@ package auth_service.modules.registration.persistence; import auth_service.modules.registration.model.Registration; -import auth_service.modules.registration.persistence.entities.RegisteredUserEntity; import auth_service.shared.test.AbstractPersistenceTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.context.annotation.Import; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; -import static org.junit.jupiter.api.Assertions.*; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; @Import(RegisteredUserRepositoryImpl.class) class RegisteredUserRepositoryTest extends AbstractPersistenceTest { @@ -17,59 +22,49 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest { private RegisteredUserRepository registeredUserRepository; @Autowired - private TestEntityManager entityManager; + private JdbcTemplate jdbc; + + private static Registration registration(String email, String displayName) { + return Registration.builder().email(email).encryptedPassword("hashed").displayName(displayName).build(); + } @Test - void existsByEmail_Success() { - // Arrange - var email = "exists@example.com"; - var entity = RegisteredUserEntity.builder() - .email(email) - .password("password") - .enabled(true) - .registrationDate(java.time.LocalDateTime.now()) - .build(); - entityManager.persist(entity); - entityManager.flush(); + void existsByEmail_OnlyForAStoredEmail() { + registeredUserRepository.save(registration("exists@example.com", null)); - // Act & Assert - assertTrue(registeredUserRepository.existsByEmail(email)); + assertTrue(registeredUserRepository.existsByEmail("exists@example.com")); assertFalse(registeredUserRepository.existsByEmail("other@example.com")); } @Test - void save_Success() { - // Arrange - var registration = Registration.builder() - .email("save@example.com") - .encryptedPassword("hashed") - .build(); + void existsByDisplayName_IgnoresCase() { + registeredUserRepository.save(registration("named@example.com", "Lobo")); - // Act - long id = registeredUserRepository.save(registration); + assertTrue(registeredUserRepository.existsByDisplayName("lobo")); + assertFalse(registeredUserRepository.existsByDisplayName("Wolf")); + } - // Assert - assertTrue(id > 0); - var saved = entityManager.find(RegisteredUserEntity.class, id); - assertNotNull(saved); - assertEquals("save@example.com", saved.getEmail()); + @Test + void save_StoresAnEnabledUser() { + long id = registeredUserRepository.save(registration("save@example.com", "Saver")); + + Map saved = jdbc.queryForMap("SELECT * FROM users WHERE id = ?", id); + assertEquals("save@example.com", saved.get("email")); + assertEquals("hashed", saved.get("password")); + assertEquals(true, saved.get("enabled")); + assertEquals("Saver", saved.get("display_name")); + assertEquals("USER", saved.get("role")); } // V19: a second account under the same email in other case is refused by the database too. @Test void emailsThatDifferOnlyInCase_AreOneAccount() { - entityManager.persist(RegisteredUserEntity.builder().email("dup@example.com").password("password") - .enabled(true).registrationDate(java.time.LocalDateTime.now()).build()); - entityManager.flush(); + registeredUserRepository.save(registration("dup@example.com", null)); assertTrue(registeredUserRepository.existsByEmail("Dup@Example.com")); - var refused = assertThrows(org.hibernate.exception.ConstraintViolationException.class, () -> { - entityManager.persist(RegisteredUserEntity.builder().email("Dup@example.com").password("password") - .enabled(true).registrationDate(java.time.LocalDateTime.now()).build()); - entityManager.flush(); - }); - // GlobalExceptionController decides on the state; on an English server Hibernate also gives the name. - assertEquals("23505", refused.getSQLState()); - assertEquals("users_email_lower", refused.getConstraintName()); + DuplicateKeyException refused = assertThrows(DuplicateKeyException.class, + () -> registeredUserRepository.save(registration("Dup@example.com", null))); + // GlobalExceptionController finds the name in the message, in every server language. + assertTrue(refused.getMostSpecificCause().getMessage().contains("users_email_lower")); } }