Compare commits

..

4 Commits

Author SHA1 Message Date
65c340265f Add integration tests for validation, error handling, and task filtering
All checks were successful
continuous-integration/drone/push Build is passing
Introduced `ValidationAndErrorHandlingIT` and `PagingAndFilteringIT` integration tests to verify validations, error responses, and task filtering/pagination behaviors. Updated IntelliJ HTTP client script for task-related operations. Enhanced `ProjectTaskControllerIT` assertions for better coverage.
2025-10-11 16:15:19 +02:00
302078fbec Pagination + filtering 2025-10-08 19:54:31 +02:00
24c3b7a72c Validation tightening (DTOs) 2025-10-08 19:53:56 +02:00
c8dd022395 Global error handling 2025-10-08 19:53:32 +02:00
11 changed files with 453 additions and 62 deletions

View File

@ -7,9 +7,9 @@ import java.util.*;
public class ProjectTaskDtos {
// Projects
public record CreateProjectRequest(
@NotNull UUID householdId,
@NotBlank String name,
String description
@NotNull(message = "householdId is required") UUID householdId,
@NotBlank(message = "name is required") String name,
@Size(max = 2000, message = "description too long") String description
) {}
public record ProjectResponse(
@ -20,30 +20,30 @@ public class ProjectTaskDtos {
// Project tasks (existing)
public record CreateTaskRequest(
@NotNull UUID projectId,
@NotBlank String title,
String description,
@Pattern(regexp="LOW|MEDIUM|HIGH") String priority,
@Pattern(regexp="OPEN|IN_PROGRESS|DONE") String status,
@NotNull(message = "projectId is required") UUID projectId,
@NotBlank(message = "title is required") String title,
@Size(max = 8000, message = "description too long") String description,
@Pattern(regexp="LOW|MEDIUM|HIGH", message="priority must be LOW|MEDIUM|HIGH") String priority,
@Pattern(regexp="OPEN|IN_PROGRESS|DONE", message="status must be OPEN|IN_PROGRESS|DONE") String status,
LocalDate dueDate,
String assigneeSub
) {}
// Household tasks (new) no projectId
public record CreateHouseholdTaskRequest(
@NotBlank String title,
String description,
@Pattern(regexp="LOW|MEDIUM|HIGH") String priority,
@Pattern(regexp="OPEN|IN_PROGRESS|DONE") String status,
@NotBlank(message = "title is required") String title,
@Size(max = 8000, message = "description too long") String description,
@Pattern(regexp="LOW|MEDIUM|HIGH", message="priority must be LOW|MEDIUM|HIGH") String priority,
@Pattern(regexp="OPEN|IN_PROGRESS|DONE", message="status must be OPEN|IN_PROGRESS|DONE") String status,
LocalDate dueDate,
String assigneeSub
) {}
public record UpdateTaskRequest(
String title,
String description,
@Pattern(regexp="LOW|MEDIUM|HIGH") String priority,
@Pattern(regexp="OPEN|IN_PROGRESS|DONE") String status,
@Size(min = 1, max = 200, message = "title must be 1..200 characters") String title,
@Size(max = 8000, message = "description too long") String description,
@Pattern(regexp="LOW|MEDIUM|HIGH", message="priority must be LOW|MEDIUM|HIGH") String priority,
@Pattern(regexp="OPEN|IN_PROGRESS|DONE", message="status must be OPEN|IN_PROGRESS|DONE") String status,
LocalDate dueDate,
String assigneeSub
) {}
@ -57,4 +57,12 @@ public class ProjectTaskDtos {
LocalDate dueDate,
String assigneeSub
) {}
// ---- Filters + paging helper ----
public record TaskFilter(
String status, // OPEN|IN_PROGRESS|DONE
String priority, // LOW|MEDIUM|HIGH
LocalDate dueFrom,
LocalDate dueTo
) {}
}

View File

@ -1,10 +1,13 @@
package se.urmo.hemhub.repo;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import se.urmo.hemhub.domain.Project;
import java.util.*;
public interface ProjectRepository extends JpaRepository<Project, UUID> {
List<Project> findByHouseholdId(UUID householdId);
Page<Project> findByHouseholdId(UUID householdId, Pageable pageable);
boolean existsByIdAndHouseholdId(UUID projectId, UUID householdId);
}

View File

@ -1,25 +1,27 @@
package se.urmo.hemhub.repo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import se.urmo.hemhub.domain.Task;
import java.time.LocalDate;
import java.util.*;
public interface TaskRepository extends JpaRepository<Task, UUID> {
public interface TaskRepository extends JpaRepository<Task, UUID>, JpaSpecificationExecutor<Task> {
// Simple finders (used in existing flows)
List<Task> findByProjectId(UUID projectId);
boolean existsByIdAndProjectId(UUID taskId, UUID projectId);
// Household-level tasks (no project)
List<Task> findByHouseholdIdAndProjectIsNull(UUID householdId);
// All tasks due on a given day, excluding a particular status (e.g., DONE)
// Paging versions (used by new endpoints)
Page<Task> findAll(org.springframework.data.jpa.domain.Specification<Task> spec, Pageable pageable);
// For Iteration 4 service
List<Task> findByDueDateAndStatusNot(LocalDate date, Task.Status status);
// Only tasks for households where the given user is a member (for the /me view)
@Query("""
select t
from Task t

View File

@ -0,0 +1,34 @@
package se.urmo.hemhub.repo;
import org.springframework.data.jpa.domain.Specification;
import se.urmo.hemhub.domain.Task;
import java.time.LocalDate;
import java.util.UUID;
public class TaskSpecifications {
public static Specification<Task> inProject(UUID projectId) {
return (root, q, cb) -> cb.equal(root.get("project").get("id"), projectId);
}
public static Specification<Task> inHousehold(UUID householdId) {
return (root, q, cb) -> cb.equal(root.get("household").get("id"), householdId);
}
public static Specification<Task> withStatus(Task.Status status) {
return (root, q, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Task> withPriority(Task.Priority prio) {
return (root, q, cb) -> cb.equal(root.get("priority"), prio);
}
public static Specification<Task> dueFrom(LocalDate from) {
return (root, q, cb) -> cb.greaterThanOrEqualTo(root.get("dueDate"), from);
}
public static Specification<Task> dueTo(LocalDate to) {
return (root, q, cb) -> cb.lessThanOrEqualTo(root.get("dueDate"), to);
}
}

View File

@ -1,15 +1,21 @@
package se.urmo.hemhub.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import se.urmo.hemhub.domain.*;
import se.urmo.hemhub.dto.ProjectTaskDtos.TaskFilter;
import se.urmo.hemhub.repo.*;
import java.time.LocalDate;
import java.util.*;
import java.util.function.Consumer;
import static org.springframework.data.jpa.domain.Specification.where;
import static se.urmo.hemhub.repo.TaskSpecifications.*;
@Service
@RequiredArgsConstructor
public class ProjectTaskService {
@ -42,16 +48,16 @@ public class ProjectTaskService {
}
@Transactional(readOnly = true)
public List<Project> listProjects(UUID householdId, String requesterSub) {
public Page<Project> listProjects(UUID householdId, String requesterSub, Pageable pageable) {
ensureMember(householdId, requesterSub);
return projects.findByHouseholdId(householdId);
return projects.findByHouseholdId(householdId, pageable);
}
@Transactional
public void deleteProject(UUID projectId, String requesterSub) {
var p = projects.findById(projectId).orElseThrow();
ensureOwner(p.getHousehold().getId(), requesterSub);
projects.delete(p); // cascades tasks for that project
projects.delete(p);
}
// -------- Tasks (project-scoped) --------
@ -66,10 +72,18 @@ public class ProjectTaskService {
}
@Transactional(readOnly = true)
public List<Task> listTasks(UUID projectId, String requesterSub) {
public Page<Task> listTasks(UUID projectId, String requesterSub, TaskFilter filter, Pageable pageable) {
var p = projects.findById(projectId).orElseThrow();
ensureMember(p.getHousehold().getId(), requesterSub);
return tasks.findByProjectId(projectId);
var spec = where(inProject(projectId));
if (filter != null) {
if (filter.status() != null) spec = spec.and(withStatus(Task.Status.valueOf(filter.status())));
if (filter.priority() != null) spec = spec.and(withPriority(Task.Priority.valueOf(filter.priority())));
if (filter.dueFrom() != null) spec = spec.and(dueFrom(filter.dueFrom()));
if (filter.dueTo() != null) spec = spec.and(dueTo(filter.dueTo()));
}
return tasks.findAll(spec, pageable);
}
// -------- Tasks (household-scoped, no project) --------
@ -85,9 +99,19 @@ public class ProjectTaskService {
}
@Transactional(readOnly = true)
public List<Task> listHouseholdTasks(UUID householdId, String requesterSub) {
public Page<Task> listHouseholdTasks(UUID householdId, String requesterSub, TaskFilter filter, Pageable pageable) {
ensureMember(householdId, requesterSub);
return tasks.findByHouseholdIdAndProjectIsNull(householdId);
var spec = where(inHousehold(householdId));
// limit to non-project tasks? No—design choice: include both unless filter requires "projectless only".
// If you want ONLY household-level tasks (no project), add spec = spec.and((r,q,cb)->cb.isNull(r.get("project")));
if (filter != null) {
if (filter.status() != null) spec = spec.and(withStatus(Task.Status.valueOf(filter.status())));
if (filter.priority() != null) spec = spec.and(withPriority(Task.Priority.valueOf(filter.priority())));
if (filter.dueFrom() != null) spec = spec.and(dueFrom(filter.dueFrom()));
if (filter.dueTo() != null) spec = spec.and(dueTo(filter.dueTo()));
}
return tasks.findAll(spec, pageable);
}
// -------- Common updates --------
@ -100,9 +124,10 @@ public class ProjectTaskService {
return tasks.save(t);
}
// -------- Iteration 4 helper --------
@Transactional(readOnly = true)
public java.util.List<Task> tasksDueTomorrowForUser(String userSub) {
var tomorrow = LocalDate.now(java.time.Clock.systemDefaultZone()).plusDays(1);
return tasks.findDueByDateForUser(tomorrow, Task.Status.DONE, userSub);
public java.util.List<se.urmo.hemhub.domain.Task> tasksDueTomorrowForUser(String userSub) {
var tomorrow = java.time.LocalDate.now(java.time.Clock.systemDefaultZone()).plusDays(1);
return tasks.findDueByDateForUser(tomorrow, se.urmo.hemhub.domain.Task.Status.DONE, userSub);
}
}

View File

@ -0,0 +1,86 @@
package se.urmo.hemhub.web;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.Map;
import java.util.NoSuchElementException;
@RestControllerAdvice
public class ErrorHandling {
record ErrorResponse(Instant timestamp, int status, String error, String message, String path, Map<String,Object> details){}
private ErrorResponse resp(HttpStatus s, String message, String path, Map<String,Object> details) {
return new ErrorResponse(Instant.now(), s.value(), s.getReasonPhrase(), message, path, details);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
ErrorResponse handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
var fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.collect(java.util.stream.Collectors.toMap(
fe -> fe.getField(),
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value",
(a,b) -> a));
return resp(HttpStatus.BAD_REQUEST, "Validation failed", req.getRequestURI(), Map.of("fields", fieldErrors));
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
ErrorResponse handleConstraintViolation(ConstraintViolationException ex, HttpServletRequest req) {
var errs = ex.getConstraintViolations().stream()
.collect(java.util.stream.Collectors.toMap(
v -> v.getPropertyPath().toString(),
v -> v.getMessage(),
(a,b)->a));
return resp(HttpStatus.BAD_REQUEST, "Validation failed", req.getRequestURI(), Map.of("violations", errs));
}
@ExceptionHandler({ HttpMessageNotReadableException.class })
@ResponseStatus(HttpStatus.BAD_REQUEST)
ErrorResponse handleBadBody(Exception ex, HttpServletRequest req) {
return resp(HttpStatus.BAD_REQUEST, "Malformed request body", req.getRequestURI(), Map.of());
}
@ExceptionHandler(NoSuchElementException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ErrorResponse handleNotFound(NoSuchElementException ex, HttpServletRequest req) {
return resp(HttpStatus.NOT_FOUND, "Not found", req.getRequestURI(), Map.of());
}
@ExceptionHandler({ SecurityException.class, AccessDeniedException.class })
@ResponseStatus(HttpStatus.FORBIDDEN)
ErrorResponse handleForbidden(Exception ex, HttpServletRequest req) {
return resp(HttpStatus.FORBIDDEN, "Forbidden", req.getRequestURI(), Map.of());
}
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.CONFLICT)
ErrorResponse handleConflict(DataIntegrityViolationException ex, HttpServletRequest req) {
return resp(HttpStatus.CONFLICT, "Conflict", req.getRequestURI(), Map.of());
}
@ExceptionHandler({ HttpRequestMethodNotSupportedException.class, HttpMediaTypeNotSupportedException.class })
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
ErrorResponse handleMethod(Exception ex, HttpServletRequest req) {
return resp(HttpStatus.METHOD_NOT_ALLOWED, "Method not allowed", req.getRequestURI(), Map.of());
}
// Fallback (kept last)
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
ErrorResponse handleOther(Exception ex, HttpServletRequest req) {
return resp(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error", req.getRequestURI(),
Map.of("hint","Check server logs"));
}
}

View File

@ -2,6 +2,8 @@ package se.urmo.hemhub.web;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
@ -9,6 +11,7 @@ import se.urmo.hemhub.domain.Task;
import se.urmo.hemhub.dto.ProjectTaskDtos.*;
import se.urmo.hemhub.service.ProjectTaskService;
import java.time.LocalDate;
import java.util.*;
import static java.util.stream.Collectors.toList;
@ -29,10 +32,9 @@ public class ProjectTaskController {
}
@GetMapping("/households/{householdId}/projects")
public List<ProjectResponse> listProjects(@PathVariable UUID householdId, Authentication auth) {
return svc.listProjects(householdId, sub(auth)).stream()
.map(p -> new ProjectResponse(p.getId(), p.getName(), p.getDescription()))
.collect(toList());
public Page<ProjectResponse> listProjects(@PathVariable UUID householdId, Authentication auth, Pageable pageable) {
return svc.listProjects(householdId, sub(auth), pageable)
.map(p -> new ProjectResponse(p.getId(), p.getName(), p.getDescription()));
}
@DeleteMapping("/projects/{projectId}")
@ -54,8 +56,15 @@ public class ProjectTaskController {
}
@GetMapping("/projects/{projectId}/tasks")
public List<TaskResponse> listTasks(@PathVariable UUID projectId, Authentication auth) {
return svc.listTasks(projectId, sub(auth)).stream().map(this::toDto).collect(toList());
public Page<TaskResponse> listTasks(@PathVariable UUID projectId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String priority,
@RequestParam(required = false) LocalDate dueFrom,
@RequestParam(required = false) LocalDate dueTo,
Authentication auth,
Pageable pageable) {
var filter = new TaskFilter(status, priority, dueFrom, dueTo);
return svc.listTasks(projectId, sub(auth), filter, pageable).map(this::toDto);
}
// -------- Tasks (household-scoped) --------
@ -74,8 +83,15 @@ public class ProjectTaskController {
}
@GetMapping("/households/{householdId}/tasks")
public List<TaskResponse> listHouseholdTasks(@PathVariable UUID householdId, Authentication auth) {
return svc.listHouseholdTasks(householdId, sub(auth)).stream().map(this::toDto).collect(toList());
public Page<TaskResponse> listHouseholdTasks(@PathVariable UUID householdId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String priority,
@RequestParam(required = false) LocalDate dueFrom,
@RequestParam(required = false) LocalDate dueTo,
Authentication auth,
Pageable pageable) {
var filter = new TaskFilter(status, priority, dueFrom, dueTo);
return svc.listHouseholdTasks(householdId, sub(auth), filter, pageable).map(this::toDto);
}
// -------- Task update (common) --------
@ -95,17 +111,15 @@ public class ProjectTaskController {
return toDto(t);
}
// -------- Iteration 4 utility --------
@GetMapping("/tasks/due/tomorrow")
public List<TaskResponse> tasksDueTomorrow(Authentication auth) {
return svc.tasksDueTomorrowForUser(sub(auth)).stream().map(this::toDto).toList();
}
private TaskResponse toDto(Task t) {
return new TaskResponse(t.getId(), t.getTitle(), t.getDescription(),
t.getPriority().name(), t.getStatus().name(), t.getDueDate(), t.getAssigneeSub());
}
@GetMapping("/tasks/due/tomorrow")
public List<TaskResponse> tasksDueTomorrow(Authentication auth) {
var todayPlus1 = java.time.LocalDate.now();
// Service method not required; use repository through a lightweight orchestrator or reuse a new service
// To keep controllers thin, well delegate to svc via a tiny method (add this method in ProjectTaskService if you prefer).
var list = svc.tasksDueTomorrowForUser(sub(auth));
return list.stream().map(this::toDto).toList();
}
}

View File

@ -113,6 +113,37 @@ Authorization: Bearer {{token}}
GET http://localhost:8080/api/v1/households/{{householdId}}/tasks
Authorization: Bearer {{token}}
### 15a) Prepare a {{tomorrow}} variable (YYYY-MM-DD)
GET http://localhost:8080/public/info
> {%
const d = new Date();
d.setDate(d.getDate() + 1);
const tomorrow = d.toISOString().slice(0,10);
client.global.set("tomorrow", tomorrow);
%}
### 15b) Capture latest household task id (if not already set)
GET http://localhost:8080/api/v1/households/{{householdId}}/tasks?size=1&sort=createdAt,desc
Authorization: Bearer {{token}}
> {%
if (!client.global.get("taskId")) {
const id = response.body?.content?.length ? response.body.content[0].id : null;
if (id) client.global.set("taskId", id);
}
%}
### 15c) Set that task's dueDate to {{tomorrow}}
PATCH http://localhost:8080/api/v1/tasks/{{taskId}}
Content-Type: application/json
Authorization: Bearer {{token}}
{
"dueDate": "{{tomorrow}}"
}
### 16) My tasks due tomorrow
GET http://localhost:8080/api/v1/tasks/due/tomorrow
Authorization: Bearer {{token}}

View File

@ -0,0 +1,101 @@
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.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import static org.hamcrest.Matchers.*;
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 PagingAndFilteringIT {
@Autowired MockMvc mvc;
@Test
void list_tasks_supports_filters_and_pagination() throws Exception {
var user = jwt().jwt(j -> {
j.subject("sub-owner");
j.claim("email","o@ex.com");
j.claim("preferred_username","owner");
});
// 1) Household
var hh = mvc.perform(post("/api/v1/households").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"H1\"}"))
.andExpect(status().isOk())
.andReturn();
var householdId = JsonPath.read(hh.getResponse().getContentAsString(), "$.id");
// 2) Project (owner-only)
var pr = mvc.perform(post("/api/v1/projects").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"householdId\":\"" + householdId + "\",\"name\":\"Sovrum\"}"))
.andExpect(status().isOk())
.andReturn();
var projectId = JsonPath.read(pr.getResponse().getContentAsString(), "$.id");
// 3) Create several tasks (mixed status/priority/due dates)
var tomorrow = LocalDate.now().plusDays(1);
var nextWeek = LocalDate.now().plusDays(7);
// Project tasks
mvc.perform(post("/api/v1/tasks").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"projectId\":\""+projectId+"\",\"title\":\"A\",\"priority\":\"HIGH\",\"status\":\"OPEN\",\"dueDate\":\""+tomorrow+"\"}"))
.andExpect(status().isOk());
mvc.perform(post("/api/v1/tasks").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"projectId\":\""+projectId+"\",\"title\":\"B\",\"priority\":\"LOW\",\"status\":\"DONE\",\"dueDate\":\""+nextWeek+"\"}"))
.andExpect(status().isOk());
mvc.perform(post("/api/v1/tasks").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"projectId\":\""+projectId+"\",\"title\":\"C\",\"priority\":\"HIGH\",\"status\":\"IN_PROGRESS\",\"dueDate\":\""+nextWeek+"\"}"))
.andExpect(status().isOk());
// Household tasks (no project)
mvc.perform(post("/api/v1/households/"+householdId+"/tasks").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"D\",\"priority\":\"HIGH\",\"status\":\"OPEN\",\"dueDate\":\""+nextWeek+"\"}"))
.andExpect(status().isOk());
mvc.perform(post("/api/v1/households/"+householdId+"/tasks").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"E\",\"priority\":\"MEDIUM\",\"status\":\"OPEN\"}"))
.andExpect(status().isOk());
// 4) Filter: project tasks with priority=HIGH and dueTo=nextWeek, paged size=1
mvc.perform(get("/api/v1/projects/"+projectId+"/tasks")
.with(user)
.param("priority","HIGH")
.param("dueTo", nextWeek.toString())
.param("page","0")
.param("size","1")
.param("sort","title,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].title", anyOf(is("A"), is("C"))))
.andExpect(jsonPath("$.totalElements", is(2))) // A and C match HIGH and due<=nextWeek
.andExpect(jsonPath("$.totalPages", is(2)));
// 5) Household tasks filtered: status=OPEN (should include D and E)
mvc.perform(get("/api/v1/households/"+householdId+"/tasks")
.with(user)
.param("status","OPEN")
.param("sort","title,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[*].title", everyItem(anyOf(is("D"), is("E"), is("A")))))
.andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(2))));
}
}

View File

@ -2,9 +2,9 @@ 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.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@ -36,30 +36,32 @@ class ProjectTaskControllerIT {
// 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");
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").exists())
.andReturn();
var projectId = JsonPath.read(pRes.getResponse().getContentAsString(), "$.id");
// List projects (should contain one)
// List projects (paged; expect first item name)
mvc.perform(get("/api/v1/households/"+householdId+"/projects").with(owner))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Sovrumsrenovering"));
.andExpect(jsonPath("$.content[0].name").value("Sovrumsrenovering"));
// Create a task
// Create a task (project-scoped)
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
// List tasks (paged; expect first item priority)
mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].priority").value("LOW"));
.andExpect(jsonPath("$.content[0].priority").value("LOW"));
// Update task status
var list = mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
// Update task status → fetch list to get taskId from page content
var listJson = mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
.andReturn().getResponse().getContentAsString();
var taskId = com.jayway.jsonpath.JsonPath.read(list, "$[0].id");
var taskId = JsonPath.read(listJson, "$.content[0].id");
mvc.perform(patch("/api/v1/tasks/"+taskId).with(owner)
.contentType("application/json")

View File

@ -0,0 +1,85 @@
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.http.MediaType;
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 ValidationAndErrorHandlingIT {
@Autowired MockMvc mvc;
@Test
void create_household_task_without_title_returns_400_with_field_error() throws Exception {
var user = jwt().jwt(j -> {
j.subject("sub-user");
j.claim("email","user@example.com");
j.claim("preferred_username","user");
});
// Create household
var hh = mvc.perform(post("/api/v1/households").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Test Household\"}"))
.andExpect(status().isOk())
.andReturn();
var householdId = JsonPath.read(hh.getResponse().getContentAsString(), "$.id");
// Try to create a household-level task with missing required 'title'
mvc.perform(post("/api/v1/households/" + householdId + "/tasks").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"priority\":\"HIGH\"}"))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
// Global error envelope
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.message").value("Validation failed"))
.andExpect(jsonPath("$.path").value("/api/v1/households/" + householdId + "/tasks"))
// Field details from @RestControllerAdvice
.andExpect(jsonPath("$.details.fields.title").value("title is required"));
}
@Test
void update_task_with_invalid_enum_returns_400_with_message() throws Exception {
var user = jwt().jwt(j -> {
j.subject("sub-user2");
j.claim("email","u2@example.com");
j.claim("preferred_username","u2");
});
// Create household
var hh = mvc.perform(post("/api/v1/households").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"H2\"}"))
.andExpect(status().isOk())
.andReturn();
var householdId = JsonPath.read(hh.getResponse().getContentAsString(), "$.id");
// Create a valid household task
var tRes = mvc.perform(post("/api/v1/households/" + householdId + "/tasks").with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Valid task\"}"))
.andExpect(status().isOk())
.andReturn();
var taskId = JsonPath.read(tRes.getResponse().getContentAsString(), "$.id");
// Patch with an invalid status enum → should 400 (handled by @ControllerAdvice malformed body or enum conversion)
mvc.perform(patch("/api/v1/tasks/" + taskId).with(user)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"INVALID_ENUM\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.message").exists());
}
}