Compare commits

..

20 Commits

Author SHA1 Message Date
1e41b75502 commented
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-14 21:18:48 +02:00
acf9ec8a2c Add TestSecurityConfig for improved test setup and update configurations
All checks were successful
continuous-integration/drone/push Build is passing
- Introduce `TestSecurityConfig` to simplify JWT usage in test environments.
- Update integration tests to import `TestSecurityConfig`.
- Split environment-specific configurations into new `application-dev.yml` and `applications-prod.yml` files.
- Adjust `docker-compose.yml` for development-specific settings.
- Clean up redundant JWT properties in `application.yml`.
2025-10-14 16:08:01 +02:00
004ea4eca4 Refine CORS settings and update security configuration
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 21:28:25 +02:00
a56d995d0f Add CORS configuration and update Keycloak hostname settings
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 21:12:51 +02:00
a3ad34d094 Remove unnecessary JWT logging in MeController
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-12 21:06:33 +02:00
b7a3103837 Remove unnecessary JWT logging in MeController
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-12 12:15:59 +02:00
c54d0214f3 Update issuer-uri in application.yml to new Keycloak endpoint 2025-10-12 12:10:19 +02:00
a9e4a187b6 Update issuer-uri in application.yml to new Keycloak endpoint
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-12 11:42:14 +02:00
38190307da fixed test
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-12 09:02:02 +02:00
58478c469d Add logging for JWT in MeController
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-10-12 09:00:29 +02:00
10b2610960 Fix incorrect JWT claim key for householdId in MeController
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-11 19:42:23 +02:00
68a0c0eb6a Keycloak port
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-11 18:23:58 +02:00
28ade4215a Keycloak port
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-11 18:02:05 +02:00
7bfad0ef50 Keycloak realm-backup
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-11 17:35:39 +02:00
0ab90a320c Keycloak realm-backup
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-11 16:15:56 +02:00
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
e0d041ef67 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.
2025-10-08 11:07:15 +02:00
33 changed files with 3227 additions and 32 deletions

View File

@ -21,10 +21,16 @@ services:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
# Make issuer consistent & reachable from other containers
KC_HOSTNAME: keycloak
# KC_HOSTNAME: keycloak
KC_HTTP_ENABLED: "true"
KC_HOSTNAME_STRICT: "false"
KC_PROXY: edge
KC_HOSTNAME_URL: "http://localhost:8081/"
KC_HOSTNAME_ADMIN_URL: "http://localhost:8081/"
KC_HOSTNAME_STRICT_HTTPS: "false"
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "8081:8081"
volumes:
@ -40,6 +46,9 @@ services:
depends_on:
postgres: { condition: service_healthy }
environment:
# Aktivera Spring-profilen "dev"
SPRING_PROFILES_ACTIVE: dev
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/hemhub
SPRING_DATASOURCE_USERNAME: hemhub
SPRING_DATASOURCE_PASSWORD: hemhub

1883
export/hemhub-realm.json Executable file

File diff suppressed because it is too large Load Diff

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

@ -2,11 +2,18 @@ package se.urmo.hemhub.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; // 👈
import org.springframework.security.config.Customizer; // 👈
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableMethodSecurity
@ -16,18 +23,37 @@ public class SecurityConfig {
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/public/**",
"/actuator/health", "/actuator/info",
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html"
).permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter())));
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
var config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:5173", // dev-SPA
"https://rubble.se" // prod-origin (SPA ligger under subpath men origin är domen)
));
config.setAllowedMethods(List.of("GET","POST","PATCH","DELETE","OPTIONS"));
config.setAllowedHeaders(List.of("Authorization","Content-Type","Accept"));
config.setAllowCredentials(false); // Bearer, inga cookies
config.setMaxAge(3600L);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
JwtAuthenticationConverter jwtConverter() {
var converter = new JwtAuthenticationConverter();
@ -35,4 +61,3 @@ public class SecurityConfig {
return converter;
}
}

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,68 @@
package se.urmo.hemhub.dto;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
import java.util.*;
public class ProjectTaskDtos {
// Projects
public record CreateProjectRequest(
@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(
UUID id,
String name,
String description
) {}
// Project tasks (existing)
public record CreateTaskRequest(
@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(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(
@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
) {}
public record TaskResponse(
UUID id,
String title,
String description,
String priority,
String status,
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

@ -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,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> {
Page<Project> findByHouseholdId(UUID householdId, Pageable pageable);
boolean existsByIdAndHouseholdId(UUID projectId, UUID householdId);
}

View File

@ -0,0 +1,38 @@
package se.urmo.hemhub.repo;
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>, JpaSpecificationExecutor<Task> {
// Simple finders (used in existing flows)
List<Task> findByProjectId(UUID projectId);
boolean existsByIdAndProjectId(UUID taskId, UUID projectId);
List<Task> findByHouseholdIdAndProjectIsNull(UUID householdId);
// 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);
@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,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

@ -0,0 +1,133 @@
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 {
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 Page<Project> listProjects(UUID householdId, String requesterSub, Pageable pageable) {
ensureMember(householdId, requesterSub);
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);
}
// -------- 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 Page<Task> listTasks(UUID projectId, String requesterSub, TaskFilter filter, Pageable pageable) {
var p = projects.findById(projectId).orElseThrow();
ensureMember(p.getHousehold().getId(), requesterSub);
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) --------
@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 Page<Task> listHouseholdTasks(UUID householdId, String requesterSub, TaskFilter filter, Pageable pageable) {
ensureMember(householdId, requesterSub);
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 --------
@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);
}
// -------- Iteration 4 helper --------
@Transactional(readOnly = true)
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,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,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

@ -1,12 +1,13 @@
package se.urmo.hemhub.web;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.*;
@Slf4j
@RestController
public class MeController {
@ -18,13 +19,12 @@ public class MeController {
// ---- Läs roller på ett säkert sätt ----
List<String> roles = extractRealmRoles(jwt);
// ---- Bygg svar ----
Map<String, Object> response = new LinkedHashMap<>();
response.put("sub", jwt.getSubject());
response.put("email", jwt.getClaimAsString("email"));
response.put("preferred_username", jwt.getClaimAsString("preferred_username"));
response.put("householdId", jwt.getClaimAsString("household_id"));
response.put("householdId", jwt.getClaimAsString("householdId"));
response.put("roles", roles);
return response;

View File

@ -0,0 +1,125 @@
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;
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;
@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 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}")
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 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) --------
@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 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) --------
@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);
}
// -------- 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());
}
}

View File

@ -0,0 +1,27 @@
server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://postgres:5432/hemhub
username: hemhub
password: hemhub
jpa:
hibernate:
ddl-auto: none
flyway:
enabled: true
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://host.docker.internal:8081/realms/hemhub/protocol/openid-connect/certs
springdoc:
swagger-ui: # (valfritt, behåll om du redan har)
url: /v3/api-docs
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: DEBUG

View File

@ -11,8 +11,13 @@ spring:
ddl-auto: none
flyway:
enabled: true
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://keycloak:8081/realms/hemhub/protocol/openid-connect/certs
springdoc:
swagger-ui: # (valfritt, behåll om du redan har)
url: /v3/api-docs
hemhub:
schedule:
reminders:
# Default: every day at 08:00
cron: "0 0 8 * * *"

View File

@ -0,0 +1,7 @@
spring:
security:
oauth2:
resourceserver:
jwt:
#jwk-set-uri: http://keycloak:8080/realms/hemhub/protocol/openid-connect/certs
issuer-uri: https://rubble.se/hemhub/auth/realms/hemhub

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"
}
> {% client.global.set("householdId", JSON.parse(response.body).id); %}
> {% client.global.set("householdId", response.body.id); %}
### 5. List my households
GET http://localhost:8080/api/v1/households
@ -53,3 +53,99 @@ Authorization: Bearer {{token}}
### 8. List members again (should now include Ulf)
GET http://localhost:8080/api/v1/households/{{householdId}}/members
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}}
### 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

@ -1,24 +1,31 @@
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.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import se.urmo.hemhub.support.TestSecurityConfig;
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")
@Import(TestSecurityConfig.class)
class HouseholdControllerIT {
@Autowired MockMvc mvc;
@Autowired
MockMvc mvc;
@Test
void create_household_and_list_for_user() throws Exception {
@ -26,7 +33,7 @@ class HouseholdControllerIT {
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("realm_access", Map.of("roles", List.of("OWNER", "MEMBER")));
});
// create
@ -45,8 +52,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)

View File

@ -1,24 +1,29 @@
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.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import se.urmo.hemhub.support.TestSecurityConfig;
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")
@Import(TestSecurityConfig.class)
class MeControllerBranchesIT {
@Autowired MockMvc mvc;
@Autowired
MockMvc mvc;
@Test
void me_withoutRealmAccess_rolesEmpty() throws Exception {

View File

@ -1,22 +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.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import se.urmo.hemhub.support.TestSecurityConfig;
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
@ActiveProfiles("test")
@Import(TestSecurityConfig.class)
class MeControllerIT {
@Autowired
@ -28,7 +32,7 @@ class MeControllerIT {
j.subject("test-user");
j.claim("email", "test@example.com");
j.claim("preferred_username", "test");
j.claim("household_id", "H-ANDERSSON");
j.claim("householdId", "H-ANDERSSON");
j.claim("realm_access", Map.of("roles", List.of("MEMBER")));
})))
.andExpect(status().isOk())

View File

@ -0,0 +1,104 @@
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.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import se.urmo.hemhub.support.TestSecurityConfig;
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")
@Import(TestSecurityConfig.class)
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

@ -0,0 +1,75 @@
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.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import se.urmo.hemhub.support.TestSecurityConfig;
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")
@Import(TestSecurityConfig.class)
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 = JsonPath.read(pRes.getResponse().getContentAsString(), "$.id");
// List projects (paged; expect first item name)
mvc.perform(get("/api/v1/households/"+householdId+"/projects").with(owner))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].name").value("Sovrumsrenovering"));
// 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 (paged; expect first item priority)
mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].priority").value("LOW"));
// 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 = JsonPath.read(listJson, "$.content[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,18 +1,22 @@
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.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import se.urmo.hemhub.support.TestSecurityConfig;
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")
@Import(TestSecurityConfig.class)
class PublicControllerIT {
@Autowired

View File

@ -0,0 +1,56 @@
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.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import se.urmo.hemhub.support.TestSecurityConfig;
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")
@Import(TestSecurityConfig.class)
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"));
}
}

View File

@ -0,0 +1,88 @@
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.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import se.urmo.hemhub.support.TestSecurityConfig;
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")
@Import(TestSecurityConfig.class)
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());
}
}

View File

@ -0,0 +1,22 @@
package se.urmo.hemhub.support;
import java.time.Instant;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
@TestConfiguration
public class TestSecurityConfig {
@Bean
JwtDecoder jwtDecoder() {
return token -> Jwt.withTokenValue(token)
.header("alg", "none")
.claim("sub", "test-user")
.claim("preferred_username", "test")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
}
}

View File

@ -6,7 +6,7 @@ spring:
password:
jpa:
hibernate:
ddl-auto: none # 👈 turn off Hibernate's validate/ddl in tests
ddl-auto: none
properties:
hibernate.hbm2ddl.auto: none
flyway: