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

@ -0,0 +1,18 @@
package se.urmo.hemhub.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.time.Clock;
@Configuration
@EnableScheduling
public class SchedulingConfig {
// Single source of time (makes scheduling & tests deterministic)
@Bean
public Clock systemClock() {
return Clock.systemDefaultZone();
}
}

View File

@ -0,0 +1,30 @@
package se.urmo.hemhub.domain;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "projects")
public class Project {
@Id private UUID id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "household_id")
private Household household;
@Column(nullable=false, length=160) private String name;
@Column(columnDefinition="text") private String description;
@Column(name="created_at", nullable=false) private Instant createdAt = Instant.now();
protected Project() {}
public Project(UUID id, Household household, String name, String description) {
this.id=id; this.household=household; this.name=name; this.description=description;
}
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public String getName() { return name; }
public String getDescription() { return description; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@ -0,0 +1,89 @@
package se.urmo.hemhub.domain;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "tasks")
public class Task {
public enum Priority { LOW, MEDIUM, HIGH }
public enum Status { OPEN, IN_PROGRESS, DONE }
@Id
private UUID id;
// Optional project
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "project_id")
private Project project;
// Required household (always set, even when project is null)
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "household_id")
private Household household;
@Column(nullable=false, length=200)
private String title;
@Column(columnDefinition="text")
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable=false, length=12)
private Priority priority = Priority.MEDIUM;
@Enumerated(EnumType.STRING)
@Column(nullable=false, length=12)
private Status status = Status.OPEN;
@Column(name="due_date")
private LocalDate dueDate;
@Column(name="assignee_sub", length=64)
private String assigneeSub;
@Column(name="created_at", nullable=false)
private Instant createdAt = Instant.now();
protected Task() {}
// Household-level task (no project)
public Task(UUID id, Household household, String title, String description,
Priority priority, Status status, LocalDate dueDate, String assigneeSub) {
this.id = id;
this.household = household;
this.title = title;
this.description = description;
if (priority != null) this.priority = priority;
if (status != null) this.status = status;
this.dueDate = dueDate;
this.assigneeSub = assigneeSub;
}
// Project-level task (project implies household)
public Task(UUID id, Project project, String title, String description,
Priority priority, Status status, LocalDate dueDate, String assigneeSub) {
this(id, project.getHousehold(), title, description, priority, status, dueDate, assigneeSub);
this.project = project;
}
public UUID getId() { return id; }
public Project getProject() { return project; }
public Household getHousehold() { return household; }
public String getTitle() { return title; }
public String getDescription() { return description; }
public Priority getPriority() { return priority; }
public Status getStatus() { return status; }
public LocalDate getDueDate() { return dueDate; }
public String getAssigneeSub() { return assigneeSub; }
public Instant getCreatedAt() { return createdAt; }
public void setTitle(String s) { this.title = s; }
public void setDescription(String s) { this.description = s; }
public void setPriority(Priority p) { this.priority = p; }
public void setStatus(Status s) { this.status = s; }
public void setDueDate(LocalDate d) { this.dueDate = d; }
public void setAssigneeSub(String a) { this.assigneeSub = a; }
}

View File

@ -0,0 +1,60 @@
package se.urmo.hemhub.dto;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
import java.util.*;
public class ProjectTaskDtos {
// Projects
public record CreateProjectRequest(
@NotNull UUID householdId,
@NotBlank String name,
String description
) {}
public record ProjectResponse(
UUID id,
String name,
String description
) {}
// 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,
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,
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,
LocalDate dueDate,
String assigneeSub
) {}
public record TaskResponse(
UUID id,
String title,
String description,
String priority,
String status,
LocalDate dueDate,
String assigneeSub
) {}
}

View File

@ -0,0 +1,31 @@
package se.urmo.hemhub.notify;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import se.urmo.hemhub.domain.Task;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
public class LogNotificationService implements NotificationService {
@Override
public void notifyTasksDueTomorrow(List<Task> tasks) {
if (tasks.isEmpty()) {
log.info("[reminder] No tasks due tomorrow.");
return;
}
var summary = tasks.stream()
.map(t -> String.format("%s (prio=%s, status=%s, household=%s, project=%s, assignee=%s)",
t.getTitle(),
t.getPriority(),
t.getStatus(),
t.getHousehold().getId(),
t.getProject() != null ? t.getProject().getId() : "",
t.getAssigneeSub() != null ? t.getAssigneeSub() : ""))
.collect(Collectors.joining("; "));
log.info("[reminder] Tasks due tomorrow: {}", summary);
}
}

View File

@ -0,0 +1,9 @@
package se.urmo.hemhub.notify;
import se.urmo.hemhub.domain.Task;
import java.util.List;
public interface NotificationService {
void notifyTasksDueTomorrow(List<Task> tasks);
}

View File

@ -0,0 +1,10 @@
package se.urmo.hemhub.repo;
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);
boolean existsByIdAndHouseholdId(UUID projectId, UUID householdId);
}

View File

@ -0,0 +1,36 @@
package se.urmo.hemhub.repo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
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> {
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)
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
where t.dueDate = :date
and t.status <> :excludeStatus
and exists (
select 1 from HouseholdMember m
where m.household = t.household and m.userSub = :userSub
)
""")
List<Task> findDueByDateForUser(@Param("date") LocalDate date,
@Param("excludeStatus") Task.Status excludeStatus,
@Param("userSub") String userSub);
}

View File

@ -0,0 +1,108 @@
package se.urmo.hemhub.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import se.urmo.hemhub.domain.*;
import se.urmo.hemhub.repo.*;
import java.time.LocalDate;
import java.util.*;
import java.util.function.Consumer;
@Service
@RequiredArgsConstructor
public class ProjectTaskService {
private final HouseholdRepository households;
private final HouseholdMemberRepository members;
private final ProjectRepository projects;
private final TaskRepository tasks;
private void ensureMember(UUID householdId, String sub) {
if (!members.existsByHouseholdIdAndUserSub(householdId, sub)) {
throw new SecurityException("Forbidden: not a household member");
}
}
private void ensureOwner(UUID householdId, String sub) {
var m = members.findByHouseholdIdAndUserSub(householdId, sub)
.orElseThrow(() -> new SecurityException("Not a member"));
if (m.getRole() != HouseholdMember.Role.OWNER) {
throw new SecurityException("Forbidden: owner only");
}
}
// -------- Projects --------
@Transactional
public Project createProject(UUID householdId, String requesterSub, String name, String description) {
ensureOwner(householdId, requesterSub);
var h = households.findById(householdId).orElseThrow();
var p = new Project(UUID.randomUUID(), h, name, description);
return projects.save(p);
}
@Transactional(readOnly = true)
public List<Project> listProjects(UUID householdId, String requesterSub) {
ensureMember(householdId, requesterSub);
return projects.findByHouseholdId(householdId);
}
@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
}
// -------- Tasks (project-scoped) --------
@Transactional
public Task createTask(UUID projectId, String requesterSub, String title, String description,
Task.Priority priority, Task.Status status, LocalDate dueDate, String assigneeSub) {
var p = projects.findById(projectId).orElseThrow();
var householdId = p.getHousehold().getId();
ensureMember(householdId, requesterSub);
var t = new Task(UUID.randomUUID(), p, title, description, priority, status, dueDate, assigneeSub);
return tasks.save(t);
}
@Transactional(readOnly = true)
public List<Task> listTasks(UUID projectId, String requesterSub) {
var p = projects.findById(projectId).orElseThrow();
ensureMember(p.getHousehold().getId(), requesterSub);
return tasks.findByProjectId(projectId);
}
// -------- Tasks (household-scoped, no project) --------
@Transactional
public Task createHouseholdTask(UUID householdId, String requesterSub,
String title, String description,
Task.Priority priority, Task.Status status,
LocalDate dueDate, String assigneeSub) {
ensureMember(householdId, requesterSub);
var h = households.findById(householdId).orElseThrow();
var t = new Task(UUID.randomUUID(), h, title, description, priority, status, dueDate, assigneeSub);
return tasks.save(t);
}
@Transactional(readOnly = true)
public List<Task> listHouseholdTasks(UUID householdId, String requesterSub) {
ensureMember(householdId, requesterSub);
return tasks.findByHouseholdIdAndProjectIsNull(householdId);
}
// -------- Common updates --------
@Transactional
public Task updateTask(UUID taskId, String requesterSub, Consumer<Task> applier) {
var t = tasks.findById(taskId).orElseThrow();
var householdId = t.getHousehold().getId();
ensureMember(householdId, requesterSub);
applier.accept(t);
return tasks.save(t);
}
@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);
}
}

View File

@ -0,0 +1,38 @@
package se.urmo.hemhub.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import se.urmo.hemhub.domain.Task;
import se.urmo.hemhub.notify.NotificationService;
import se.urmo.hemhub.repo.TaskRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class TaskDueService {
private final TaskRepository tasks;
private final NotificationService notificationService;
private final Clock clock;
/**
* Runs daily at 08:00 by default.
* CRON can be overridden via property: hemhub.schedule.reminders.cron
*/
@Scheduled(cron = "${hemhub.schedule.reminders.cron:0 0 8 * * *}")
public void sendRemindersForTomorrow() {
List<Task> due = findTasksDueTomorrow();
notificationService.notifyTasksDueTomorrow(due);
}
public List<Task> findTasksDueTomorrow() {
LocalDate tomorrow = LocalDate.now(clock).plusDays(1);
return tasks.findByDueDateAndStatusNot(tomorrow, Task.Status.DONE);
}
}

View File

@ -0,0 +1,111 @@
package se.urmo.hemhub.web;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import se.urmo.hemhub.domain.Task;
import se.urmo.hemhub.dto.ProjectTaskDtos.*;
import se.urmo.hemhub.service.ProjectTaskService;
import java.util.*;
import static java.util.stream.Collectors.toList;
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class ProjectTaskController {
private final ProjectTaskService svc;
private static String sub(Authentication auth) { return ((Jwt)auth.getPrincipal()).getSubject(); }
// -------- Projects --------
@PostMapping("/projects")
public ProjectResponse createProject(@Valid @RequestBody CreateProjectRequest req, Authentication auth) {
var p = svc.createProject(req.householdId(), sub(auth), req.name(), req.description());
return new ProjectResponse(p.getId(), p.getName(), p.getDescription());
}
@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());
}
@DeleteMapping("/projects/{projectId}")
public void deleteProject(@PathVariable UUID projectId, Authentication auth) {
svc.deleteProject(projectId, sub(auth));
}
// -------- Tasks (project-scoped) --------
@PostMapping("/tasks")
public TaskResponse createTask(@Valid @RequestBody CreateTaskRequest req, Authentication auth) {
var t = svc.createTask(
req.projectId(), sub(auth), req.title(), req.description(),
req.priority() == null ? Task.Priority.MEDIUM : Task.Priority.valueOf(req.priority()),
req.status() == null ? Task.Status.OPEN : Task.Status.valueOf(req.status()),
req.dueDate(), req.assigneeSub()
);
return toDto(t);
}
@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());
}
// -------- Tasks (household-scoped) --------
@PostMapping("/households/{householdId}/tasks")
public TaskResponse createHouseholdTask(@PathVariable UUID householdId,
@Valid @RequestBody CreateHouseholdTaskRequest req,
Authentication auth) {
var t = svc.createHouseholdTask(
householdId, sub(auth), req.title(), req.description(),
req.priority() == null ? Task.Priority.MEDIUM : Task.Priority.valueOf(req.priority()),
req.status() == null ? Task.Status.OPEN : Task.Status.valueOf(req.status()),
req.dueDate(), req.assigneeSub()
);
return toDto(t);
}
@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());
}
// -------- Task update (common) --------
@PatchMapping("/tasks/{taskId}")
public TaskResponse updateTask(@PathVariable UUID taskId,
@Valid @RequestBody UpdateTaskRequest req,
Authentication auth) {
var t = svc.updateTask(taskId, sub(auth), entity -> {
if (req.title() != null) entity.setTitle(req.title());
if (req.description() != null) entity.setDescription(req.description());
if (req.priority() != null) entity.setPriority(Task.Priority.valueOf(req.priority()));
if (req.status() != null) entity.setStatus(Task.Status.valueOf(req.status()));
if (req.dueDate() != null) entity.setDueDate(req.dueDate());
if (req.assigneeSub() != null) entity.setAssigneeSub(req.assigneeSub());
});
return toDto(t);
}
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

@ -16,3 +16,9 @@ spring:
resourceserver: resourceserver:
jwt: jwt:
jwk-set-uri: http://keycloak:8081/realms/hemhub/protocol/openid-connect/certs jwk-set-uri: http://keycloak:8081/realms/hemhub/protocol/openid-connect/certs
hemhub:
schedule:
reminders:
# Default: every day at 08:00
cron: "0 0 8 * * *"

View File

@ -0,0 +1,30 @@
-- H2 + Postgres friendly
CREATE TABLE projects (
id UUID PRIMARY KEY,
household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE,
name VARCHAR(160) NOT NULL,
description TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE tasks (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
description TEXT,
priority VARCHAR(12) NOT NULL, -- LOW/MEDIUM/HIGH
status VARCHAR(12) NOT NULL, -- OPEN/IN_PROGRESS/DONE
due_date DATE,
assignee_sub VARCHAR(64), -- optional: user SUB of a member
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_projects_household ON projects(household_id);
CREATE INDEX idx_tasks_project ON tasks(project_id);
ALTER TABLE tasks
ADD CONSTRAINT chk_task_priority CHECK (priority IN ('LOW','MEDIUM','HIGH'));
ALTER TABLE tasks
ADD CONSTRAINT chk_task_status CHECK (status IN ('OPEN','IN_PROGRESS','DONE'));

View File

@ -0,0 +1,28 @@
-- V4: Allow tasks to belong directly to a household (project optional)
ALTER TABLE tasks
ADD COLUMN household_id UUID;
ALTER TABLE tasks
ADD CONSTRAINT fk_tasks_household
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE;
ALTER TABLE tasks
ALTER COLUMN project_id DROP NOT NULL;
-- Create a temporary mapping table (drops harmlessly if exists)
CREATE TABLE IF NOT EXISTS _tmp_project_household_map AS
SELECT p.id AS project_id, p.household_id AS household_id
FROM projects p;
-- Update tasks where household_id is null and project_id matches
UPDATE tasks t
SET household_id = (
SELECT household_id FROM _tmp_project_household_map m WHERE m.project_id = t.project_id
)
WHERE t.household_id IS NULL AND t.project_id IS NOT NULL;
-- Clean up temporary table (ignore if not exists)
DROP TABLE IF EXISTS _tmp_project_household_map;
ALTER TABLE tasks
ALTER COLUMN household_id SET NOT NULL;

View File

@ -28,7 +28,7 @@ Authorization: Bearer {{token}}
"name": "Familjen Andersson" "name": "Familjen Andersson"
} }
> {% client.global.set("householdId", JSON.parse(response.body).id); %} > {% client.global.set("householdId", response.body.id); %}
### 5. List my households ### 5. List my households
GET http://localhost:8080/api/v1/households GET http://localhost:8080/api/v1/households
@ -53,3 +53,68 @@ Authorization: Bearer {{token}}
### 8. List members again (should now include Ulf) ### 8. List members again (should now include Ulf)
GET http://localhost:8080/api/v1/households/{{householdId}}/members GET http://localhost:8080/api/v1/households/{{householdId}}/members
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
### 9) Create project (OWNER only)
POST http://localhost:8080/api/v1/projects
Content-Type: application/json
Authorization: Bearer {{token}}
{
"householdId": "{{householdId}}",
"name": "Sovrumsrenovering",
"description": "Måla, golv, el"
}
> {% client.global.set("projectId", response.body.id); %}
### 10) List projects in household
GET http://localhost:8080/api/v1/households/{{householdId}}/projects
Authorization: Bearer {{token}}
### 11) Create task
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Authorization: Bearer {{token}}
{
"projectId": "{{projectId}}",
"title": "Damsug soffan",
"priority": "LOW"
}
> {% client.global.set("taskId", response.body.id); %}
### 12) List tasks
GET http://localhost:8080/api/v1/projects/{{projectId}}/tasks
Authorization: Bearer {{token}}
### 13) Update task
PATCH http://localhost:8080/api/v1/tasks/{{taskId}}
Content-Type: application/json
Authorization: Bearer {{token}}
{
"status": "DONE"
}
### 14) Create a household-level task (no project)
POST http://localhost:8080/api/v1/households/{{householdId}}/tasks
Content-Type: application/json
Authorization: Bearer {{token}}
{
"title": "Buy milk",
"priority": "HIGH"
}
> {% client.global.set("taskId", response.body.id); %}
### 15) List household-level tasks
GET http://localhost:8080/api/v1/households/{{householdId}}/tasks
Authorization: Bearer {{token}}
### 16) My tasks due tomorrow
GET http://localhost:8080/api/v1/tasks/due/tomorrow
Authorization: Bearer {{token}}

View File

@ -1,24 +1,28 @@
package se.urmo.hemhub.integration; package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test; 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.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; 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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ActiveProfiles("test") @ActiveProfiles("test")
class HouseholdControllerIT { class HouseholdControllerIT {
@Autowired MockMvc mvc; @Autowired
MockMvc mvc;
@Test @Test
void create_household_and_list_for_user() throws Exception { void create_household_and_list_for_user() throws Exception {
@ -26,7 +30,7 @@ class HouseholdControllerIT {
j.subject("sub-user-1"); j.subject("sub-user-1");
j.claim("email", "u1@example.com"); j.claim("email", "u1@example.com");
j.claim("preferred_username", "u1"); j.claim("preferred_username", "u1");
j.claim("realm_access", java.util.Map.of("roles", java.util.List.of("OWNER","MEMBER"))); j.claim("realm_access", Map.of("roles", List.of("OWNER", "MEMBER")));
}); });
// create // create
@ -45,8 +49,16 @@ class HouseholdControllerIT {
@Test @Test
void add_member_requires_owner() throws Exception { 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 owner = jwt().jwt(j -> {
var other = jwt().jwt(j -> { j.subject("other-sub"); j.claim("email","x@ex.com"); j.claim("preferred_username","x"); }); 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 // create household as owner
var res = mvc.perform(post("/api/v1/households").with(owner) var res = mvc.perform(post("/api/v1/households").with(owner)

View File

@ -1,24 +1,26 @@
package se.urmo.hemhub.integration; package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test; 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.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Map; import java.util.Map;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; 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.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 @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ActiveProfiles("test") @ActiveProfiles("test")
class MeControllerBranchesIT { class MeControllerBranchesIT {
@Autowired MockMvc mvc; @Autowired
MockMvc mvc;
@Test @Test
void me_withoutRealmAccess_rolesEmpty() throws Exception { void me_withoutRealmAccess_rolesEmpty() throws Exception {

View File

@ -1,18 +1,19 @@
package se.urmo.hemhub.integration; package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test; 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.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; 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.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; 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.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 @SpringBootTest
@AutoConfigureMockMvc @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; package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test; 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.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; 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.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 @SpringBootTest
@AutoConfigureMockMvc @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"));
}
}