feat: add task creation board

This commit is contained in:
Urban Modig
2026-07-25 10:31:37 +02:00
parent 050f248857
commit 3f152eeccc
16 changed files with 962 additions and 29 deletions

View File

@ -5,6 +5,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.rubble.hemhub.task.InvalidTaskException;
import se.rubble.hemhub.user.InvalidUserNameException;
import se.rubble.hemhub.user.UserNameAlreadyExistsException;
@ -26,5 +27,11 @@ public class ApiExceptionHandler {
"USER_NAME_ALREADY_EXISTS",
"En användare med det namnet finns redan."));
}
@ExceptionHandler(InvalidTaskException.class)
public ResponseEntity<ApiError> handleInvalidTask(InvalidTaskException exception) {
return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK", exception.getMessage()));
}
}

View File

@ -0,0 +1,5 @@
package se.rubble.hemhub.task;
public record CreateTaskRequest(String title, String description) {
}

View File

@ -0,0 +1,9 @@
package se.rubble.hemhub.task;
public class InvalidTaskException extends RuntimeException {
public InvalidTaskException(String message) {
super(message);
}
}

View File

@ -0,0 +1,69 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "task")
class Task {
@Id
private UUID id;
@Column(nullable = false, length = 100)
private String title;
@Column(length = 500)
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TaskStatus status;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
protected Task() {
}
Task(
UUID id,
String title,
String description,
TaskStatus status,
Instant createdAt) {
this.id = id;
this.title = title;
this.description = description;
this.status = status;
this.createdAt = createdAt;
}
UUID getId() {
return id;
}
String getTitle() {
return title;
}
String getDescription() {
return description;
}
TaskStatus getStatus() {
return status;
}
Instant getCreatedAt() {
return createdAt;
}
}

View File

@ -0,0 +1,36 @@
package se.rubble.hemhub.task;
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/tasks")
public class TaskController {
private final TaskService taskService;
TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public List<TaskResponse> findAll() {
return taskService.findAll();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) {
return taskService.create(
request == null ? null : request.title(),
request == null ? null : request.description());
}
}

View File

@ -0,0 +1,12 @@
package se.rubble.hemhub.task;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
interface TaskRepository extends JpaRepository<Task, UUID> {
List<Task> findAllByOrderByCreatedAtAscIdAsc();
}

View File

@ -0,0 +1,22 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
public record TaskResponse(
UUID id,
String title,
String description,
TaskStatus status,
Instant createdAt) {
static TaskResponse from(Task task) {
return new TaskResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.getStatus(),
task.getCreatedAt());
}
}

View File

@ -0,0 +1,73 @@
package se.rubble.hemhub.task;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class TaskService {
private final TaskRepository taskRepository;
private final Clock clock;
@Autowired
TaskService(TaskRepository taskRepository) {
this(taskRepository, Clock.systemUTC());
}
TaskService(TaskRepository taskRepository, Clock clock) {
this.taskRepository = taskRepository;
this.clock = clock;
}
@Transactional(readOnly = true)
List<TaskResponse> findAll() {
return taskRepository.findAllByOrderByCreatedAtAscIdAsc().stream()
.map(TaskResponse::from)
.toList();
}
@Transactional
TaskResponse create(String requestedTitle, String requestedDescription) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription);
if (title.isEmpty() || codePointLength(title) > 100) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
if (description != null && codePointLength(description) > 500) {
throw new InvalidTaskException(
"Beskrivningen får innehålla högst 500 tecken.");
}
Task task = new Task(
UUID.randomUUID(),
title,
description,
TaskStatus.WAITING,
Instant.now(clock));
return TaskResponse.from(taskRepository.save(task));
}
private static String normalizeDescription(String requestedDescription) {
if (requestedDescription == null) {
return null;
}
String description = requestedDescription.trim();
return description.isEmpty() ? null : description;
}
private static int codePointLength(String value) {
return value.codePointCount(0, value.length());
}
}

View File

@ -0,0 +1,8 @@
package se.rubble.hemhub.task;
public enum TaskStatus {
WAITING,
IN_PROGRESS,
COMPLETED
}

View File

@ -0,0 +1,8 @@
CREATE TABLE task (
id UUID PRIMARY KEY,
title VARCHAR(100) NOT NULL,
description VARCHAR(500),
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);

View File

@ -0,0 +1,107 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
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 TaskApiTest {
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@Test
void createsWaitingTaskWithTrimmedValues() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": " Dammsuga ",
"description": " Bottenvåningen "
}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isString())
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.createdAt").isString());
}
@Test
void storesBlankDescriptionAsNull() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Dammsuga", "description": " "}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.description").value((Object) null));
}
@Test
void rejectsBlankAndTooLongTitles() throws Exception {
assertInvalidTask("""
{"title": " "}
""");
assertInvalidTask("{\"title\": \"%s\"}".formatted("a".repeat(101)));
}
@Test
void rejectsTooLongDescription() throws Exception {
assertInvalidTask("""
{"title": "Dammsuga", "description": "%s"}
""".formatted("a".repeat(501)));
}
@Test
void listsTasksOldestFirstWithIdAsTieBreaker() throws Exception {
Instant older = Instant.parse("2026-07-24T10:00:00Z");
Instant newer = Instant.parse("2026-07-24T11:00:00Z");
UUID firstId = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003");
taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, newer));
taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, older));
taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, older));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Första"))
.andExpect(jsonPath("$[1].title").value("Andra"))
.andExpect(jsonPath("$[2].title").value("Nyast"));
}
private void assertInvalidTask(String body) throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK"));
}
}