feat: add task status transitions

This commit is contained in:
Urban Modig
2026-07-27 00:46:37 +02:00
parent 6570aad4a2
commit 65a6488c0b
19 changed files with 893 additions and 125 deletions

View File

@ -8,9 +8,10 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.rubble.hemhub.task.InvalidTaskException;
import se.rubble.hemhub.task.InvalidTaskAssignmentException;
import se.rubble.hemhub.task.InvalidTaskStatusException;
import se.rubble.hemhub.task.AssigneeNotFoundException;
import se.rubble.hemhub.task.TaskAssignmentConflictException;
import se.rubble.hemhub.task.TaskNotFoundException;
import se.rubble.hemhub.task.TaskRequiresAssigneeException;
import se.rubble.hemhub.user.InvalidUserNameException;
import se.rubble.hemhub.user.UserNameAlreadyExistsException;
@ -66,11 +67,19 @@ public class ApiExceptionHandler {
.body(new ApiError("USER_NOT_FOUND", "Användaren finns inte."));
}
@ExceptionHandler(TaskAssignmentConflictException.class)
public ResponseEntity<ApiError> handleTaskAssignmentConflict() {
@ExceptionHandler(InvalidTaskStatusException.class)
public ResponseEntity<ApiError> handleInvalidTaskStatus() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_TASK_STATUS",
"Status måste vara WAITING, IN_PROGRESS eller COMPLETED."));
}
@ExceptionHandler(TaskRequiresAssigneeException.class)
public ResponseEntity<ApiError> handleTaskRequiresAssignee() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"TASK_ASSIGNMENT_NOT_ALLOWED",
"Ansvarig kan endast ändras för väntande uppgifter."));
"TASK_REQUIRES_ASSIGNEE",
"En pågående uppgift måste ha en ansvarig."));
}
}

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class InvalidTaskStatusException extends RuntimeException {
}

View File

@ -56,6 +56,9 @@ class Task {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
if (status == TaskStatus.IN_PROGRESS && assignee == null) {
throw new TaskRequiresAssigneeException();
}
this.id = id;
this.title = title;
@ -91,14 +94,22 @@ class Task {
}
void changeAssignee(User assignee) {
ensureAssignmentChangeAllowed();
if (status == TaskStatus.IN_PROGRESS && assignee == null) {
throw new TaskRequiresAssigneeException();
}
this.assignee = assignee;
}
void ensureAssignmentChangeAllowed() {
if (status != TaskStatus.WAITING) {
throw new TaskAssignmentConflictException();
void changeStatus(TaskStatus targetStatus, User automaticAssignee) {
if (targetStatus == TaskStatus.IN_PROGRESS && assignee == null) {
if (automaticAssignee == null) {
throw new TaskRequiresAssigneeException();
}
assignee = automaticAssignee;
}
status = targetStatus;
}
Instant getCreatedAt() {

View File

@ -1,4 +0,0 @@
package se.rubble.hemhub.task;
public class TaskAssignmentConflictException extends RuntimeException {
}

View File

@ -51,4 +51,18 @@ public class TaskController {
return taskService.updateAssignee(taskId, request.parsedAssigneeId().value());
}
@PutMapping("/{taskId}/status")
public TaskResponse updateStatus(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskStatusRequest request) {
if (request == null) {
throw new InvalidTaskStatusException();
}
return taskService.updateStatus(
taskId,
request.parsedStatus(),
request.parsedActiveUserId().value());
}
}

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class TaskRequiresAssigneeException extends RuntimeException {
}

View File

@ -78,13 +78,32 @@ class TaskService {
TaskResponse updateAssignee(UUID taskId, UUID requestedAssigneeId) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
task.ensureAssignmentChangeAllowed();
User assignee = findAssignee(requestedAssigneeId);
task.changeAssignee(assignee);
return TaskResponse.from(task);
}
@Transactional
TaskResponse updateStatus(
UUID taskId,
TaskStatus targetStatus,
UUID activeUserId) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
User automaticAssignee = null;
if (targetStatus == TaskStatus.IN_PROGRESS && task.getAssignee() == null) {
if (activeUserId == null) {
throw new TaskRequiresAssigneeException();
}
automaticAssignee = findAssignee(activeUserId);
}
task.changeStatus(targetStatus, automaticAssignee);
return TaskResponse.from(task);
}
private User findAssignee(UUID requestedAssigneeId) {
if (requestedAssigneeId == null) {
return null;

View File

@ -0,0 +1,23 @@
package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeType;
public record UpdateTaskStatusRequest(JsonNode status, JsonNode activeUserId) {
TaskStatus parsedStatus() {
if (status == null || status.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskStatusException();
}
try {
return TaskStatus.valueOf(status.stringValue());
} catch (IllegalArgumentException exception) {
throw new InvalidTaskStatusException();
}
}
UUIDValue parsedActiveUserId() {
return UUIDValue.optional(activeUserId);
}
}

View File

@ -199,7 +199,7 @@ class TaskApiTest {
taskRepository.save(new Task(
newestId, "Nyast", null, TaskStatus.WAITING, 3, null, newer));
taskRepository.save(new Task(
secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, null, older));
secondId, "Andra", null, TaskStatus.WAITING, 2, null, older));
taskRepository.save(new Task(
firstId, "Första", null, TaskStatus.COMPLETED, 1, null, older));
@ -279,25 +279,6 @@ class TaskApiTest {
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"));
}
@Test
void rejectsAssigneeChangeForTaskThatIsNotWaiting() throws Exception {
UUID taskId = UUID.randomUUID();
taskRepository.save(new Task(
taskId,
"Pågående",
null,
TaskStatus.IN_PROGRESS,
3,
null,
Instant.parse("2026-07-26T12:00:00Z")));
updateAssignee(taskId.toString(), """
{"assigneeId": null}
""")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TASK_ASSIGNMENT_NOT_ALLOWED"));
}
private ResultActions createTaskWithPoints(int points) throws Exception {
return mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)

View File

@ -0,0 +1,255 @@
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
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.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
class TaskStatusApiTest {
private static final Instant CREATED_AT = Instant.parse("2026-07-27T10:15:30Z");
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@ParameterizedTest
@CsvSource({
"WAITING, IN_PROGRESS",
"WAITING, COMPLETED",
"IN_PROGRESS, WAITING",
"IN_PROGRESS, COMPLETED",
"COMPLETED, WAITING",
"COMPLETED, IN_PROGRESS"
})
void allowsEveryDirectStatusTransition(TaskStatus initial, TaskStatus target)
throws Exception {
User assignee = createUser();
Task task = saveTask(initial, assignee);
updateStatus(task.getId(), target, assignee.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(target.name()))
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.createdAt").value(CREATED_AT.toString()))
.andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString()));
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void acceptsCurrentStatusAsIdempotentTarget(TaskStatus statusValue) throws Exception {
User assignee = createUser();
Task task = saveTask(statusValue, assignee);
updateStatus(task.getId(), statusValue, null)
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(statusValue.name()))
.andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString()));
}
@Test
void automaticallyAssignsActiveUserWhenUnassignedTaskStarts() throws Exception {
User activeUser = createUser();
Task task = saveTask(TaskStatus.WAITING, null);
updateStatus(task.getId(), TaskStatus.IN_PROGRESS, activeUser.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("IN_PROGRESS"))
.andExpect(jsonPath("$.assignee.id").value(activeUser.getId().toString()))
.andExpect(jsonPath("$.assignee.name").value(activeUser.getName()));
}
@Test
void keepsExistingAssigneeAndDoesNotResolveActiveUser() throws Exception {
User assignee = createUser();
Task task = saveTask(TaskStatus.WAITING, assignee);
updateStatus(
task.getId(),
TaskStatus.IN_PROGRESS,
UUID.fromString("00000000-0000-0000-0000-000000000099"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString()));
}
@Test
void requiresValidActiveUserWhenUnassignedTaskStarts() throws Exception {
Task task = saveTask(TaskStatus.COMPLETED, null);
updateStatus(task.getId(), TaskStatus.IN_PROGRESS, null)
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TASK_REQUIRES_ASSIGNEE"));
updateStatus(
task.getId(),
TaskStatus.IN_PROGRESS,
UUID.fromString("00000000-0000-0000-0000-000000000099"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"));
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void allowsAssigningAndChangingAssigneeInEveryStatus(TaskStatus statusValue)
throws Exception {
User first = createUser();
User second = createUser();
Task task = saveTask(statusValue, first);
updateAssignee(task.getId(), second.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(statusValue.name()))
.andExpect(jsonPath("$.assignee.id").value(second.getId().toString()));
}
@ParameterizedTest
@EnumSource(value = TaskStatus.class, names = {"WAITING", "COMPLETED"})
void allowsRemovingAssigneeOutsideInProgress(TaskStatus statusValue) throws Exception {
Task task = saveTask(statusValue, createUser());
removeAssignee(task.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(statusValue.name()))
.andExpect(jsonPath("$.assignee").value((Object) null));
}
@Test
void rejectsRemovingAssigneeFromInProgressTask() throws Exception {
User assignee = createUser();
Task task = saveTask(TaskStatus.IN_PROGRESS, assignee);
removeAssignee(task.getId())
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TASK_REQUIRES_ASSIGNEE"))
.andExpect(jsonPath("$.message")
.value("En pågående uppgift måste ha en ansvarig."));
}
@Test
void validatesStatusRequestAndTaskId() throws Exception {
Task task = saveTask(TaskStatus.WAITING, null);
rawStatusUpdate(task.getId().toString(), "{}")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_STATUS"));
rawStatusUpdate(task.getId().toString(), """
{"status": null}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_STATUS"));
rawStatusUpdate(task.getId().toString(), """
{"status": "UNKNOWN"}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_STATUS"));
rawStatusUpdate(task.getId().toString(), """
{"status": "IN_PROGRESS", "activeUserId": "inte-ett-uuid"}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
rawStatusUpdate(task.getId().toString(), "{")
.andExpect(status().isBadRequest());
rawStatusUpdate("00000000-0000-0000-0000-000000000099", """
{"status": "WAITING"}
""")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
}
private User createUser() throws Exception {
String name = "U" + UUID.randomUUID();
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "%s"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String id = com.jayway.jsonpath.JsonPath.read(response, "$.id");
return userRepository.findById(UUID.fromString(id)).orElseThrow();
}
private Task saveTask(TaskStatus statusValue, User assignee) {
return taskRepository.save(new Task(
UUID.randomUUID(),
"Dammsuga",
"Bottenvåningen",
statusValue,
7,
assignee,
CREATED_AT));
}
private ResultActions updateStatus(
UUID taskId,
TaskStatus target,
UUID activeUserId) throws Exception {
String activeUserJson = activeUserId == null
? ""
: ", \"activeUserId\": \"%s\"".formatted(activeUserId);
return rawStatusUpdate(
taskId.toString(),
"""
{"status": "%s"%s}
""".formatted(target.name(), activeUserJson));
}
private ResultActions rawStatusUpdate(String taskId, String body) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/status", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content(body));
}
private ResultActions updateAssignee(UUID taskId, UUID assigneeId) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"assigneeId": "%s"}
""".formatted(assigneeId)));
}
private ResultActions removeAssignee(UUID taskId) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"assigneeId": null}
"""));
}
}

View File

@ -15,6 +15,20 @@ class TaskTest {
assertThrows(InvalidTaskException.class, () -> taskWithPoints(100));
}
@Test
void rejectsInProgressTaskWithoutAssignee() {
assertThrows(
TaskRequiresAssigneeException.class,
() -> new Task(
UUID.randomUUID(),
"Dammsuga",
null,
TaskStatus.IN_PROGRESS,
3,
null,
Instant.parse("2026-07-26T12:00:00Z")));
}
private Task taskWithPoints(int points) {
return new Task(
UUID.randomUUID(),