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

@ -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(),