feat: add task points

This commit is contained in:
Urban Modig
2026-07-26 17:43:07 +02:00
parent 1654b54a22
commit 059d4da921
16 changed files with 263 additions and 49 deletions

View File

@ -1,5 +1,14 @@
package se.rubble.hemhub.task;
public record CreateTaskRequest(String title, String description) {
}
import tools.jackson.databind.JsonNode;
public record CreateTaskRequest(String title, String description, JsonNode points) {
Integer integerPoints() {
if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) {
return null;
}
return points.intValue();
}
}

View File

@ -27,6 +27,9 @@ class Task {
@Column(nullable = false, length = 20)
private TaskStatus status;
@Column(nullable = false)
private int points;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@ -38,11 +41,18 @@ class Task {
String title,
String description,
TaskStatus status,
int points,
Instant createdAt) {
if (points < 1 || points > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
this.id = id;
this.title = title;
this.description = description;
this.status = status;
this.points = points;
this.createdAt = createdAt;
}
@ -62,8 +72,11 @@ class Task {
return status;
}
int getPoints() {
return points;
}
Instant getCreatedAt() {
return createdAt;
}
}

View File

@ -30,7 +30,7 @@ public class TaskController {
public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) {
return taskService.create(
request == null ? null : request.title(),
request == null ? null : request.description());
request == null ? null : request.description(),
request == null ? null : request.integerPoints());
}
}

View File

@ -8,6 +8,7 @@ public record TaskResponse(
String title,
String description,
TaskStatus status,
int points,
Instant createdAt) {
static TaskResponse from(Task task) {
@ -16,7 +17,7 @@ public record TaskResponse(
task.getTitle(),
task.getDescription(),
task.getStatus(),
task.getPoints(),
task.getCreatedAt());
}
}

View File

@ -33,7 +33,10 @@ class TaskService {
}
@Transactional
TaskResponse create(String requestedTitle, String requestedDescription) {
TaskResponse create(
String requestedTitle,
String requestedDescription,
Integer requestedPoints) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription);
@ -47,11 +50,17 @@ class TaskService {
"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.");
}
Task task = new Task(
UUID.randomUUID(),
title,
description,
TaskStatus.WAITING,
requestedPoints,
Instant.now(clock));
return TaskResponse.from(taskRepository.save(task));
@ -70,4 +79,3 @@ class TaskService {
return value.codePointCount(0, value.length());
}
}

View File

@ -1,7 +1,6 @@
spring.datasource.url=jdbc:h2:file:./data/hemhub;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
spring.datasource.url=jdbc:h2:mem:hemhub;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
spring.flyway.enabled=true

View File

@ -0,0 +1,8 @@
ALTER TABLE task ADD COLUMN points INTEGER;
UPDATE task SET points = 1 WHERE points IS NULL;
ALTER TABLE task ALTER COLUMN points SET NOT NULL;
ALTER TABLE task
ADD CONSTRAINT ck_task_points_range CHECK (points BETWEEN 1 AND 99);

View File

@ -9,6 +9,7 @@ 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;
@ -41,7 +42,8 @@ class TaskApiTest {
.content("""
{
"title": " Dammsuga ",
"description": " Bottenvåningen "
"description": " Bottenvåningen ",
"points": 7
}
"""))
.andExpect(status().isCreated())
@ -49,7 +51,12 @@ class TaskApiTest {
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.createdAt").isString());
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].points").value(7));
}
@Test
@ -57,7 +64,7 @@ class TaskApiTest {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Dammsuga", "description": " "}
{"title": "Dammsuga", "description": " ", "points": 1}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.description").value((Object) null));
@ -66,18 +73,58 @@ class TaskApiTest {
@Test
void rejectsBlankAndTooLongTitles() throws Exception {
assertInvalidTask("""
{"title": " "}
{"title": " ", "points": 1}
""");
assertInvalidTask("{\"title\": \"%s\"}".formatted("a".repeat(101)));
assertInvalidTask(
"{\"title\": \"%s\", \"points\": 1}".formatted("a".repeat(101)));
}
@Test
void rejectsTooLongDescription() throws Exception {
assertInvalidTask("""
{"title": "Dammsuga", "description": "%s"}
{"title": "Dammsuga", "description": "%s", "points": 1}
""".formatted("a".repeat(501)));
}
@Test
void acceptsPointBoundaries() throws Exception {
createTaskWithPoints(1)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.points").value(1));
createTaskWithPoints(99)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.points").value(99));
}
@Test
void rejectsMissingNullAndOutOfRangePoints() throws Exception {
assertInvalidTask("""
{"title": "Saknas"}
""");
assertInvalidTask("""
{"title": "Null", "points": null}
""");
assertInvalidTask("""
{"title": "Noll", "points": 0}
""");
assertInvalidTask("""
{"title": "Negativ", "points": -1}
""");
assertInvalidTask("""
{"title": "För stor", "points": 100}
""");
}
@Test
void rejectsNonIntegerPoints() throws Exception {
assertInvalidTask("""
{"title": "Decimal", "points": 1.5}
""");
assertInvalidTask("""
{"title": "Text", "points": "sju"}
""");
}
@Test
void listsTasksOldestFirstWithIdAsTieBreaker() throws Exception {
Instant older = Instant.parse("2026-07-24T10:00:00Z");
@ -86,17 +133,25 @@ class TaskApiTest {
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));
taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, 3, newer));
taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, older));
taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, 1, older));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Första"))
.andExpect(jsonPath("$[0].points").value(1))
.andExpect(jsonPath("$[1].title").value("Andra"))
.andExpect(jsonPath("$[2].title").value("Nyast"));
}
private ResultActions createTaskWithPoints(int points) throws Exception {
return mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\": \"Uppgift %d\", \"points\": %d}"
.formatted(points, points)));
}
private void assertInvalidTask(String body) throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)

View File

@ -0,0 +1,27 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;
class TaskTest {
@Test
void rejectsPointsOutsideAllowedRange() {
assertThrows(InvalidTaskException.class, () -> taskWithPoints(0));
assertThrows(InvalidTaskException.class, () -> taskWithPoints(100));
}
private Task taskWithPoints(int points) {
return new Task(
UUID.randomUUID(),
"Dammsuga",
null,
TaskStatus.WAITING,
points,
Instant.parse("2026-07-26T12:00:00Z"));
}
}