feat: add task status transitions
This commit is contained in:
@ -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."));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
package se.rubble.hemhub.task;
|
||||
|
||||
public class InvalidTaskStatusException extends RuntimeException {
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
package se.rubble.hemhub.task;
|
||||
|
||||
public class TaskAssignmentConflictException extends RuntimeException {
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
package se.rubble.hemhub.task;
|
||||
|
||||
public class TaskRequiresAssigneeException extends RuntimeException {
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
"""));
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user