Add project and task management with scheduling and notifications
All checks were successful
continuous-integration/drone/push Build is passing

Introduced `Project` and `Task` entities, along with supporting services, repositories, and APIs. Added features for project-based and household-level task management, including creation, listing, updates, and validation. Implemented scheduled notifications for tasks due tomorrow. Updated Flyway migrations, configuration files, and tests to support these functionalities.
This commit is contained in:
Urban Modig
2025-10-08 11:07:15 +02:00
parent 84d7647481
commit e0d041ef67
21 changed files with 828 additions and 20 deletions

View File

@ -1,32 +1,36 @@
package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID;
import java.util.List;
import java.util.Map;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class HouseholdControllerIT {
@Autowired MockMvc mvc;
@Autowired
MockMvc mvc;
@Test
void create_household_and_list_for_user() throws Exception {
var jwtUser = jwt().jwt(j -> {
j.subject("sub-user-1");
j.claim("email","u1@example.com");
j.claim("preferred_username","u1");
j.claim("realm_access", java.util.Map.of("roles", java.util.List.of("OWNER","MEMBER")));
j.claim("email", "u1@example.com");
j.claim("preferred_username", "u1");
j.claim("realm_access", Map.of("roles", List.of("OWNER", "MEMBER")));
});
// create
@ -45,8 +49,16 @@ class HouseholdControllerIT {
@Test
void add_member_requires_owner() throws Exception {
var owner = jwt().jwt(j -> { j.subject("owner-sub"); j.claim("email","o@ex.com"); j.claim("preferred_username","owner"); });
var other = jwt().jwt(j -> { j.subject("other-sub"); j.claim("email","x@ex.com"); j.claim("preferred_username","x"); });
var owner = jwt().jwt(j -> {
j.subject("owner-sub");
j.claim("email", "o@ex.com");
j.claim("preferred_username", "owner");
});
var other = jwt().jwt(j -> {
j.subject("other-sub");
j.claim("email", "x@ex.com");
j.claim("preferred_username", "x");
});
// create household as owner
var res = mvc.perform(post("/api/v1/households").with(owner)
@ -55,7 +67,7 @@ class HouseholdControllerIT {
var id = com.jayway.jsonpath.JsonPath.read(res.getResponse().getContentAsString(), "$.id");
// cannot add as non-owner
mvc.perform(post("/api/v1/households/"+id+"/members").with(other)
mvc.perform(post("/api/v1/households/" + id + "/members").with(other)
.contentType("application/json")
.content("{\"userSub\":\"u2\",\"role\":\"MEMBER\"}"))
.andExpect(status().isForbidden());

View File

@ -1,24 +1,26 @@
package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class MeControllerBranchesIT {
@Autowired MockMvc mvc;
@Autowired
MockMvc mvc;
@Test
void me_withoutRealmAccess_rolesEmpty() throws Exception {

View File

@ -1,18 +1,19 @@
package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc

View File

@ -0,0 +1,70 @@
package se.urmo.hemhub.integration;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ProjectTaskControllerIT {
@Autowired MockMvc mvc;
@Test
void project_and_tasks_happy_path() throws Exception {
var owner = jwt().jwt(j -> {
j.subject("owner-sub");
j.claim("email","o@ex.com");
j.claim("preferred_username","owner");
});
// Create household first (from Iteration 2 endpoint)
var hRes = mvc.perform(post("/api/v1/households").with(owner)
.contentType("application/json").content("{\"name\":\"H1\"}"))
.andExpect(status().isOk()).andReturn();
var householdId = JsonPath.read(hRes.getResponse().getContentAsString(), "$.id");
// Create project
var pRes = mvc.perform(post("/api/v1/projects").with(owner)
.contentType("application/json").content("{\"householdId\":\""+householdId+"\",\"name\":\"Sovrumsrenovering\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.id").exists()).andReturn();
var projectId = com.jayway.jsonpath.JsonPath.read(pRes.getResponse().getContentAsString(), "$.id");
// List projects (should contain one)
mvc.perform(get("/api/v1/households/"+householdId+"/projects").with(owner))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Sovrumsrenovering"));
// Create a task
mvc.perform(post("/api/v1/tasks").with(owner)
.contentType("application/json")
.content("{\"projectId\":\""+projectId+"\",\"title\":\"Damsug soffan\",\"priority\":\"LOW\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Damsug soffan"));
// List tasks
mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].priority").value("LOW"));
// Update task status
var list = mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
.andReturn().getResponse().getContentAsString();
var taskId = com.jayway.jsonpath.JsonPath.read(list, "$[0].id");
mvc.perform(patch("/api/v1/tasks/"+taskId).with(owner)
.contentType("application/json")
.content("{\"status\":\"DONE\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("DONE"));
}
}

View File

@ -1,14 +1,15 @@
package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc

View File

@ -0,0 +1,53 @@
package se.urmo.hemhub.integration;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class TaskDueControllerIT {
@Autowired
MockMvc mvc;
@Test
void list_tasks_due_tomorrow_returns_results_for_member() throws Exception {
var user = jwt().jwt(j -> {
j.subject("sub-user");
j.claim("email", "u@ex.com");
j.claim("preferred_username", "u");
});
// Create household
var hh = mvc.perform(post("/api/v1/households").with(user)
.contentType("application/json").content("{\"name\":\"H1\"}"))
.andExpect(status().isOk()).andReturn();
var householdId = JsonPath.read(hh.getResponse().getContentAsString(), "$.id");
// Create a household-level task due tomorrow
var tomorrow = LocalDate.now().plusDays(1);
mvc.perform(post("/api/v1/households/" + householdId + "/tasks").with(user)
.contentType("application/json")
.content("{\"title\":\"Påminn mig\",\"priority\":\"MEDIUM\",\"dueDate\":\"" + tomorrow + "\"}"))
.andExpect(status().isOk());
// Expect the new endpoint to include it
mvc.perform(get("/api/v1/tasks/due/tomorrow").with(user))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Påminn mig"));
}
}