feat: add user selection flow

This commit is contained in:
Urban Modig
2026-07-24 20:21:33 +02:00
parent 9957383e88
commit 1ec7a72908
22 changed files with 778 additions and 40 deletions

View File

@ -26,6 +26,19 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@ -42,4 +55,3 @@
</plugins>
</build>
</project>

View File

@ -0,0 +1,5 @@
package se.rubble.hemhub.api;
public record ApiError(String code, String message) {
}

View File

@ -0,0 +1,30 @@
package se.rubble.hemhub.api;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.rubble.hemhub.user.InvalidUserNameException;
import se.rubble.hemhub.user.UserNameAlreadyExistsException;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(InvalidUserNameException.class)
public ResponseEntity<ApiError> handleInvalidUserName() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_USER_NAME",
"Namnet måste innehålla mellan 1 och 50 tecken."));
}
@ExceptionHandler(UserNameAlreadyExistsException.class)
public ResponseEntity<ApiError> handleDuplicateUserName() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"USER_NAME_ALREADY_EXISTS",
"En användare med det namnet finns redan."));
}
}

View File

@ -0,0 +1,5 @@
package se.rubble.hemhub.user;
public record CreateUserRequest(String name) {
}

View File

@ -0,0 +1,5 @@
package se.rubble.hemhub.user;
public class InvalidUserNameException extends RuntimeException {
}

View File

@ -0,0 +1,48 @@
package se.rubble.hemhub.user;
import java.time.Instant;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "app_user")
class User {
@Id
private UUID id;
@Column(nullable = false, length = 50)
private String name;
@Column(name = "normalized_name", nullable = false, length = 150, unique = true)
private String normalizedName;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
protected User() {
}
User(UUID id, String name, String normalizedName, Instant createdAt) {
this.id = id;
this.name = name;
this.normalizedName = normalizedName;
this.createdAt = createdAt;
}
UUID getId() {
return id;
}
String getName() {
return name;
}
Instant getCreatedAt() {
return createdAt;
}
}

View File

@ -0,0 +1,34 @@
package se.rubble.hemhub.user;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<UserResponse> findAll() {
return userService.findAll();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse create(@RequestBody(required = false) CreateUserRequest request) {
return userService.create(request == null ? null : request.name());
}
}

View File

@ -0,0 +1,5 @@
package se.rubble.hemhub.user;
public class UserNameAlreadyExistsException extends RuntimeException {
}

View File

@ -0,0 +1,11 @@
package se.rubble.hemhub.user;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
interface UserRepository extends JpaRepository<User, UUID> {
boolean existsByNormalizedName(String normalizedName);
}

View File

@ -0,0 +1,12 @@
package se.rubble.hemhub.user;
import java.time.Instant;
import java.util.UUID;
public record UserResponse(UUID id, String name, Instant createdAt) {
static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getName(), user.getCreatedAt());
}
}

View File

@ -0,0 +1,66 @@
package se.rubble.hemhub.user;
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class UserService {
private static final Comparator<User> BY_DISPLAY_NAME =
Comparator.comparing(User::getName, String.CASE_INSENSITIVE_ORDER)
.thenComparing(User::getName)
.thenComparing(User::getId);
private final UserRepository userRepository;
private final Clock clock;
@Autowired
UserService(UserRepository userRepository) {
this(userRepository, Clock.systemUTC());
}
UserService(UserRepository userRepository, Clock clock) {
this.userRepository = userRepository;
this.clock = clock;
}
@Transactional(readOnly = true)
List<UserResponse> findAll() {
return userRepository.findAll().stream()
.sorted(BY_DISPLAY_NAME)
.map(UserResponse::from)
.toList();
}
@Transactional
UserResponse create(String requestedName) {
String name = requestedName == null ? "" : requestedName.trim();
if (name.isEmpty() || name.codePointCount(0, name.length()) > 50) {
throw new InvalidUserNameException();
}
String normalizedName = name.toLowerCase(Locale.ROOT);
if (userRepository.existsByNormalizedName(normalizedName)) {
throw new UserNameAlreadyExistsException();
}
User user = new User(UUID.randomUUID(), name, normalizedName, Instant.now(clock));
try {
return UserResponse.from(userRepository.saveAndFlush(user));
} catch (DataIntegrityViolationException exception) {
throw new UserNameAlreadyExistsException();
}
}
}

View File

@ -0,0 +1,7 @@
spring.datasource.url=jdbc:h2:file:./data/hemhub;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
spring.flyway.enabled=true

View File

@ -0,0 +1,7 @@
CREATE TABLE app_user (
id UUID PRIMARY KEY,
name VARCHAR(50) NOT NULL,
normalized_name VARCHAR(150) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT uk_app_user_normalized_name UNIQUE (normalized_name)
);

View File

@ -0,0 +1,108 @@
package se.rubble.hemhub.user;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
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.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;
@SpringBootTest
class UserApiTest {
@Autowired
private WebApplicationContext context;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
userRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@Test
void listsNoUsersWhenDatabaseIsEmpty() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void createsAndListsUserWithTrimmedName() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": " Urban "}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isString())
.andExpect(jsonPath("$.name").value("Urban"))
.andExpect(jsonPath("$.createdAt").isString())
.andExpect(jsonPath("$.normalizedName").doesNotExist());
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Urban"));
}
@Test
void rejectsInvalidNames() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": " "}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_USER_NAME"));
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"%s\"}".formatted("a".repeat(51))))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_USER_NAME"));
}
@Test
void rejectsDuplicateNameIgnoringCase() throws Exception {
createUser("Urban");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "urban"}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("USER_NAME_ALREADY_EXISTS"));
}
@Test
void listsUsersSortedByDisplayName() throws Exception {
createUser("Urban");
createUser("Anna");
createUser("Bertil");
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Anna"))
.andExpect(jsonPath("$[1].name").value("Bertil"))
.andExpect(jsonPath("$[2].name").value("Urban"));
}
private void createUser(String name) throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"%s\"}".formatted(name)))
.andExpect(status().isCreated());
}
}

View File

@ -0,0 +1,7 @@
spring.datasource.url=jdbc:h2:mem:hemhub-test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
spring.flyway.enabled=true