feat: add task editing

This commit is contained in:
Urban Modig
2026-07-28 11:18:13 +02:00
parent b6460a3924
commit 443f686c20
13 changed files with 1409 additions and 30 deletions

View File

@ -112,6 +112,17 @@ class Task {
status = targetStatus;
}
void changeDetails(String title, String description, int points) {
if (points < 1 || points > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
this.title = title;
this.description = description;
this.points = points;
}
Instant getCreatedAt() {
return createdAt;
}

View File

@ -67,6 +67,21 @@ public class TaskController {
request.parsedActiveUserId().value());
}
@PutMapping("/{taskId}/details")
public TaskResponse updateDetails(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskDetailsRequest request) {
if (request == null) {
throw new InvalidTaskException("Requesten måste innehålla uppgiftsdetaljer.");
}
return taskService.updateDetails(
taskId,
request.parsedTitle(),
request.parsedDescription(),
request.parsedPoints());
}
@DeleteMapping("/{taskId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID taskId) {

View File

@ -43,23 +43,9 @@ class TaskService {
String requestedDescription,
Integer requestedPoints,
UUID requestedAssigneeId) {
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.");
}
if (requestedPoints == null) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
String title = validateTitle(requestedTitle);
String description = validateDescription(requestedDescription);
int points = validatePoints(requestedPoints);
User assignee = findAssignee(requestedAssigneeId);
Task task = new Task(
@ -67,7 +53,7 @@ class TaskService {
title,
description,
TaskStatus.WAITING,
requestedPoints,
points,
assignee,
Instant.now(clock));
@ -104,6 +90,22 @@ class TaskService {
return TaskResponse.from(task);
}
@Transactional
TaskResponse updateDetails(
UUID taskId,
String requestedTitle,
String requestedDescription,
Integer requestedPoints) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
String title = validateTitle(requestedTitle);
String description = validateDescription(requestedDescription);
int points = validatePoints(requestedPoints);
task.changeDetails(title, description, points);
return TaskResponse.from(task);
}
@Transactional
void delete(UUID taskId) {
Task task = taskRepository.findById(taskId)
@ -120,13 +122,37 @@ class TaskService {
.orElseThrow(AssigneeNotFoundException::new);
}
private static String normalizeDescription(String requestedDescription) {
private static String validateTitle(String requestedTitle) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
if (title.isEmpty() || codePointLength(title) > 100) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
return title;
}
private static String validateDescription(String requestedDescription) {
if (requestedDescription == null) {
return null;
}
String description = requestedDescription.trim();
return description.isEmpty() ? null : description;
if (description.isEmpty()) {
return null;
}
if (codePointLength(description) > 500) {
throw new InvalidTaskException(
"Beskrivningen får innehålla högst 500 tecken.");
}
return description;
}
private static int validatePoints(Integer requestedPoints) {
if (requestedPoints == null || requestedPoints < 1 || requestedPoints > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
return requestedPoints;
}
private static int codePointLength(String value) {

View File

@ -0,0 +1,40 @@
package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeType;
public record UpdateTaskDetailsRequest(
JsonNode title,
JsonNode description,
JsonNode points) {
String parsedTitle() {
if (title == null || title.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
return title.stringValue();
}
String parsedDescription() {
if (description == null) {
throw new InvalidTaskException("Fältet description måste anges.");
}
if (description.isNull()) {
return null;
}
if (description.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskException(
"Beskrivningen får innehålla högst 500 tecken.");
}
return description.stringValue();
}
Integer parsedPoints() {
if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
return points.intValue();
}
}

View File

@ -0,0 +1,245 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
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 se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.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 TaskEditingApiTest {
private static final Instant CREATED_AT = Instant.parse("2026-07-27T09:00:00Z");
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
userRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void updatesDetailsInEveryStatusAndPreservesOtherFields(TaskStatus statusValue) throws Exception {
User assignee = createUser();
Task task = saveTask(statusValue, assignee, "Före", "Gammal", 3);
mockMvc.perform(put("/api/tasks/{taskId}/details", task.getId())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": " Efter ",
"description": " Ny beskrivning ",
"points": 7
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(task.getId().toString()))
.andExpect(jsonPath("$.title").value("Efter"))
.andExpect(jsonPath("$.description").value("Ny beskrivning"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.status").value(statusValue.name()))
.andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString()))
.andExpect(jsonPath("$.assignee.name").value(assignee.getName()))
.andExpect(jsonPath("$.createdAt").value(CREATED_AT.toString()));
Task updated = taskRepository.findOneById(task.getId()).orElseThrow();
assertEquals(statusValue, updated.getStatus());
assertEquals(assignee.getId(), updated.getAssignee().getId());
assertEquals(CREATED_AT, updated.getCreatedAt());
assertTrue(userRepository.existsById(assignee.getId()));
}
@Test
void acceptsBoundariesBlankDescriptionAndUnchangedValues() throws Exception {
String title = "å".repeat(100);
String description = "å".repeat(500);
Task task = saveTask(TaskStatus.WAITING, null, title, description, 1);
updateDetails(task.getId(), title, description, 99)
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(title))
.andExpect(jsonPath("$.description").value(description))
.andExpect(jsonPath("$.points").value(99));
updateDetails(task.getId(), title, " ", 1)
.andExpect(status().isOk())
.andExpect(jsonPath("$.description").doesNotExist())
.andExpect(jsonPath("$.points").value(1));
updateDetails(task.getId(), title, null, 1)
.andExpect(status().isOk())
.andExpect(jsonPath("$.description").doesNotExist());
}
@ParameterizedTest(name = "{1}")
@MethodSource("invalidRequests")
void rejectsInvalidOrIncompleteRequests(String request, String description) throws Exception {
Task task = saveTask(TaskStatus.WAITING, null, "Före", null, 3);
mockMvc.perform(put("/api/tasks/{taskId}/details", task.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK"));
}
@Test
void returnsNotFoundAndKeepsOtherTaskUnchanged() throws Exception {
Task otherTask = saveTask(TaskStatus.COMPLETED, null, "Annan", "Oförändrad", 9);
UUID unknownId = UUID.randomUUID();
updateDetails(unknownId, "Ny", null, 4)
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(otherTask.getId().toString()))
.andExpect(jsonPath("$[0].title").value("Annan"))
.andExpect(jsonPath("$[0].description").value("Oförändrad"))
.andExpect(jsonPath("$[0].points").value(9));
}
@Test
void keepsExistingBadRequestForInvalidUuid() throws Exception {
mockMvc.perform(put("/api/tasks/{taskId}/details", "inte-ett-uuid")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Ny", "description": null, "points": 4}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
}
private org.springframework.test.web.servlet.ResultActions updateDetails(
UUID taskId,
String title,
String description,
int points) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/details", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": %s,
"description": %s,
"points": %d
}
""".formatted(jsonString(title), jsonString(description), points)));
}
private User createUser() throws Exception {
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Urban"}
"""))
.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,
String title,
String description,
int points) {
return taskRepository.save(new Task(
UUID.randomUUID(),
title,
description,
statusValue,
points,
assignee,
CREATED_AT));
}
private static Stream<Arguments> invalidRequests() {
return Stream.of(
Arguments.of("{}", "alla fält saknas"),
Arguments.of("""
{"description": null, "points": 1}
""", "titel saknas"),
Arguments.of("""
{"title": null, "description": null, "points": 1}
""", "titel är null"),
Arguments.of("""
{"title": " ", "description": null, "points": 1}
""", "titel är blank"),
Arguments.of("""
{"title": "%s", "description": null, "points": 1}
""".formatted("a".repeat(101)), "titel är för lång"),
Arguments.of("""
{"title": "Titel", "description": "%s", "points": 1}
""".formatted("a".repeat(501)), "beskrivning är för lång"),
Arguments.of("""
{"title": "Titel", "points": 1}
""", "beskrivning saknas"),
Arguments.of("""
{"title": "Titel", "description": null}
""", "poäng saknas"),
Arguments.of("""
{"title": "Titel", "description": null, "points": null}
""", "poäng är null"),
Arguments.of("""
{"title": "Titel", "description": null, "points": "7"}
""", "poäng är text"),
Arguments.of("""
{"title": "Titel", "description": null, "points": 1.5}
""", "poäng är decimal"),
Arguments.of("""
{"title": "Titel", "description": null, "points": 0}
""", "poäng är noll"),
Arguments.of("""
{"title": "Titel", "description": null, "points": -1}
""", "poäng är negativ"),
Arguments.of("""
{"title": "Titel", "description": null, "points": 100}
""", "poäng är för hög"));
}
private static String jsonString(String value) {
if (value == null) {
return "null";
}
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
}

View File

@ -5,6 +5,7 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class TaskTest {
@ -29,6 +30,25 @@ class TaskTest {
Instant.parse("2026-07-26T12:00:00Z")));
}
@Test
void changesOnlyEditableDetailsAndProtectsPointsInvariant() {
Task task = taskWithPoints(3);
UUID id = task.getId();
TaskStatus status = task.getStatus();
Instant createdAt = task.getCreatedAt();
task.changeDetails("Ny titel", "Ny beskrivning", 7);
assertEquals(id, task.getId());
assertEquals("Ny titel", task.getTitle());
assertEquals("Ny beskrivning", task.getDescription());
assertEquals(7, task.getPoints());
assertEquals(status, task.getStatus());
assertEquals(createdAt, task.getCreatedAt());
assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 0));
assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 100));
}
private Task taskWithPoints(int points) {
return new Task(
UUID.randomUUID(),