Compare commits
19 Commits
e0d041ef67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e41b75502 | |||
| acf9ec8a2c | |||
| 004ea4eca4 | |||
| a56d995d0f | |||
| a3ad34d094 | |||
| b7a3103837 | |||
| c54d0214f3 | |||
| a9e4a187b6 | |||
| 38190307da | |||
| 58478c469d | |||
| 10b2610960 | |||
| 68a0c0eb6a | |||
| 28ade4215a | |||
| 7bfad0ef50 | |||
| 0ab90a320c | |||
| 65c340265f | |||
| 302078fbec | |||
| 24c3b7a72c | |||
| c8dd022395 |
@ -21,10 +21,16 @@ services:
|
|||||||
KEYCLOAK_ADMIN: admin
|
KEYCLOAK_ADMIN: admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||||
# Make issuer consistent & reachable from other containers
|
# Make issuer consistent & reachable from other containers
|
||||||
KC_HOSTNAME: keycloak
|
# KC_HOSTNAME: keycloak
|
||||||
KC_HTTP_ENABLED: "true"
|
KC_HTTP_ENABLED: "true"
|
||||||
KC_HOSTNAME_STRICT: "false"
|
KC_HOSTNAME_STRICT: "false"
|
||||||
KC_PROXY: edge
|
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:
|
ports:
|
||||||
- "8081:8081"
|
- "8081:8081"
|
||||||
volumes:
|
volumes:
|
||||||
@ -40,6 +46,9 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
environment:
|
environment:
|
||||||
|
# Aktivera Spring-profilen "dev"
|
||||||
|
SPRING_PROFILES_ACTIVE: dev
|
||||||
|
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/hemhub
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/hemhub
|
||||||
SPRING_DATASOURCE_USERNAME: hemhub
|
SPRING_DATASOURCE_USERNAME: hemhub
|
||||||
SPRING_DATASOURCE_PASSWORD: hemhub
|
SPRING_DATASOURCE_PASSWORD: hemhub
|
||||||
|
|||||||
1883
export/hemhub-realm.json
Executable file
1883
export/hemhub-realm.json
Executable file
File diff suppressed because it is too large
Load Diff
@ -2,11 +2,18 @@ package se.urmo.hemhub.config;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.method.configuration.EnableMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
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
|
@Configuration
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
@ -16,18 +23,37 @@ public class SecurityConfig {
|
|||||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.cors(Customizer.withDefaults())
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/public/**",
|
"/public/**",
|
||||||
"/actuator/health", "/actuator/info",
|
"/actuator/health", "/actuator/info",
|
||||||
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html"
|
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter())));
|
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter())));
|
||||||
return http.build();
|
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
|
@Bean
|
||||||
JwtAuthenticationConverter jwtConverter() {
|
JwtAuthenticationConverter jwtConverter() {
|
||||||
var converter = new JwtAuthenticationConverter();
|
var converter = new JwtAuthenticationConverter();
|
||||||
@ -35,4 +61,3 @@ public class SecurityConfig {
|
|||||||
return converter;
|
return converter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import java.util.*;
|
|||||||
public class ProjectTaskDtos {
|
public class ProjectTaskDtos {
|
||||||
// Projects
|
// Projects
|
||||||
public record CreateProjectRequest(
|
public record CreateProjectRequest(
|
||||||
@NotNull UUID householdId,
|
@NotNull(message = "householdId is required") UUID householdId,
|
||||||
@NotBlank String name,
|
@NotBlank(message = "name is required") String name,
|
||||||
String description
|
@Size(max = 2000, message = "description too long") String description
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record ProjectResponse(
|
public record ProjectResponse(
|
||||||
@ -20,30 +20,30 @@ public class ProjectTaskDtos {
|
|||||||
|
|
||||||
// Project tasks (existing)
|
// Project tasks (existing)
|
||||||
public record CreateTaskRequest(
|
public record CreateTaskRequest(
|
||||||
@NotNull UUID projectId,
|
@NotNull(message = "projectId is required") UUID projectId,
|
||||||
@NotBlank String title,
|
@NotBlank(message = "title is required") String title,
|
||||||
String description,
|
@Size(max = 8000, message = "description too long") String description,
|
||||||
@Pattern(regexp="LOW|MEDIUM|HIGH") String priority,
|
@Pattern(regexp="LOW|MEDIUM|HIGH", message="priority must be LOW|MEDIUM|HIGH") String priority,
|
||||||
@Pattern(regexp="OPEN|IN_PROGRESS|DONE") String status,
|
@Pattern(regexp="OPEN|IN_PROGRESS|DONE", message="status must be OPEN|IN_PROGRESS|DONE") String status,
|
||||||
LocalDate dueDate,
|
LocalDate dueDate,
|
||||||
String assigneeSub
|
String assigneeSub
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Household tasks (new) – no projectId
|
// Household tasks (new) – no projectId
|
||||||
public record CreateHouseholdTaskRequest(
|
public record CreateHouseholdTaskRequest(
|
||||||
@NotBlank String title,
|
@NotBlank(message = "title is required") String title,
|
||||||
String description,
|
@Size(max = 8000, message = "description too long") String description,
|
||||||
@Pattern(regexp="LOW|MEDIUM|HIGH") String priority,
|
@Pattern(regexp="LOW|MEDIUM|HIGH", message="priority must be LOW|MEDIUM|HIGH") String priority,
|
||||||
@Pattern(regexp="OPEN|IN_PROGRESS|DONE") String status,
|
@Pattern(regexp="OPEN|IN_PROGRESS|DONE", message="status must be OPEN|IN_PROGRESS|DONE") String status,
|
||||||
LocalDate dueDate,
|
LocalDate dueDate,
|
||||||
String assigneeSub
|
String assigneeSub
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record UpdateTaskRequest(
|
public record UpdateTaskRequest(
|
||||||
String title,
|
@Size(min = 1, max = 200, message = "title must be 1..200 characters") String title,
|
||||||
String description,
|
@Size(max = 8000, message = "description too long") String description,
|
||||||
@Pattern(regexp="LOW|MEDIUM|HIGH") String priority,
|
@Pattern(regexp="LOW|MEDIUM|HIGH", message="priority must be LOW|MEDIUM|HIGH") String priority,
|
||||||
@Pattern(regexp="OPEN|IN_PROGRESS|DONE") String status,
|
@Pattern(regexp="OPEN|IN_PROGRESS|DONE", message="status must be OPEN|IN_PROGRESS|DONE") String status,
|
||||||
LocalDate dueDate,
|
LocalDate dueDate,
|
||||||
String assigneeSub
|
String assigneeSub
|
||||||
) {}
|
) {}
|
||||||
@ -57,4 +57,12 @@ public class ProjectTaskDtos {
|
|||||||
LocalDate dueDate,
|
LocalDate dueDate,
|
||||||
String assigneeSub
|
String assigneeSub
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// ---- Filters + paging helper ----
|
||||||
|
public record TaskFilter(
|
||||||
|
String status, // OPEN|IN_PROGRESS|DONE
|
||||||
|
String priority, // LOW|MEDIUM|HIGH
|
||||||
|
LocalDate dueFrom,
|
||||||
|
LocalDate dueTo
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
package se.urmo.hemhub.repo;
|
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 org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import se.urmo.hemhub.domain.Project;
|
import se.urmo.hemhub.domain.Project;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
public interface ProjectRepository extends JpaRepository<Project, UUID> {
|
public interface ProjectRepository extends JpaRepository<Project, UUID> {
|
||||||
List<Project> findByHouseholdId(UUID householdId);
|
Page<Project> findByHouseholdId(UUID householdId, Pageable pageable);
|
||||||
boolean existsByIdAndHouseholdId(UUID projectId, UUID householdId);
|
boolean existsByIdAndHouseholdId(UUID projectId, UUID householdId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,27 @@
|
|||||||
package se.urmo.hemhub.repo;
|
package se.urmo.hemhub.repo;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.*;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import se.urmo.hemhub.domain.Task;
|
import se.urmo.hemhub.domain.Task;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
public interface TaskRepository extends JpaRepository<Task, UUID> {
|
public interface TaskRepository extends JpaRepository<Task, UUID>, JpaSpecificationExecutor<Task> {
|
||||||
|
|
||||||
|
// Simple finders (used in existing flows)
|
||||||
List<Task> findByProjectId(UUID projectId);
|
List<Task> findByProjectId(UUID projectId);
|
||||||
boolean existsByIdAndProjectId(UUID taskId, UUID projectId);
|
boolean existsByIdAndProjectId(UUID taskId, UUID projectId);
|
||||||
|
|
||||||
// Household-level tasks (no project)
|
|
||||||
List<Task> findByHouseholdIdAndProjectIsNull(UUID householdId);
|
List<Task> findByHouseholdIdAndProjectIsNull(UUID householdId);
|
||||||
|
|
||||||
// All tasks due on a given day, excluding a particular status (e.g., DONE)
|
// Paging versions (used by new endpoints)
|
||||||
|
Page<Task> findAll(org.springframework.data.jpa.domain.Specification<Task> spec, Pageable pageable);
|
||||||
|
|
||||||
|
// For Iteration 4 service
|
||||||
List<Task> findByDueDateAndStatusNot(LocalDate date, Task.Status status);
|
List<Task> findByDueDateAndStatusNot(LocalDate date, Task.Status status);
|
||||||
|
|
||||||
// Only tasks for households where the given user is a member (for the /me view)
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select t
|
select t
|
||||||
from Task t
|
from Task t
|
||||||
|
|||||||
34
src/main/java/se/urmo/hemhub/repo/TaskSpecifications.java
Normal file
34
src/main/java/se/urmo/hemhub/repo/TaskSpecifications.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,21 @@
|
|||||||
package se.urmo.hemhub.service;
|
package se.urmo.hemhub.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import se.urmo.hemhub.domain.*;
|
import se.urmo.hemhub.domain.*;
|
||||||
|
import se.urmo.hemhub.dto.ProjectTaskDtos.TaskFilter;
|
||||||
import se.urmo.hemhub.repo.*;
|
import se.urmo.hemhub.repo.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import static org.springframework.data.jpa.domain.Specification.where;
|
||||||
|
import static se.urmo.hemhub.repo.TaskSpecifications.*;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ProjectTaskService {
|
public class ProjectTaskService {
|
||||||
@ -42,16 +48,16 @@ public class ProjectTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<Project> listProjects(UUID householdId, String requesterSub) {
|
public Page<Project> listProjects(UUID householdId, String requesterSub, Pageable pageable) {
|
||||||
ensureMember(householdId, requesterSub);
|
ensureMember(householdId, requesterSub);
|
||||||
return projects.findByHouseholdId(householdId);
|
return projects.findByHouseholdId(householdId, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteProject(UUID projectId, String requesterSub) {
|
public void deleteProject(UUID projectId, String requesterSub) {
|
||||||
var p = projects.findById(projectId).orElseThrow();
|
var p = projects.findById(projectId).orElseThrow();
|
||||||
ensureOwner(p.getHousehold().getId(), requesterSub);
|
ensureOwner(p.getHousehold().getId(), requesterSub);
|
||||||
projects.delete(p); // cascades tasks for that project
|
projects.delete(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Tasks (project-scoped) --------
|
// -------- Tasks (project-scoped) --------
|
||||||
@ -66,10 +72,18 @@ public class ProjectTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<Task> listTasks(UUID projectId, String requesterSub) {
|
public Page<Task> listTasks(UUID projectId, String requesterSub, TaskFilter filter, Pageable pageable) {
|
||||||
var p = projects.findById(projectId).orElseThrow();
|
var p = projects.findById(projectId).orElseThrow();
|
||||||
ensureMember(p.getHousehold().getId(), requesterSub);
|
ensureMember(p.getHousehold().getId(), requesterSub);
|
||||||
return tasks.findByProjectId(projectId);
|
|
||||||
|
var spec = where(inProject(projectId));
|
||||||
|
if (filter != null) {
|
||||||
|
if (filter.status() != null) spec = spec.and(withStatus(Task.Status.valueOf(filter.status())));
|
||||||
|
if (filter.priority() != null) spec = spec.and(withPriority(Task.Priority.valueOf(filter.priority())));
|
||||||
|
if (filter.dueFrom() != null) spec = spec.and(dueFrom(filter.dueFrom()));
|
||||||
|
if (filter.dueTo() != null) spec = spec.and(dueTo(filter.dueTo()));
|
||||||
|
}
|
||||||
|
return tasks.findAll(spec, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Tasks (household-scoped, no project) --------
|
// -------- Tasks (household-scoped, no project) --------
|
||||||
@ -85,9 +99,19 @@ public class ProjectTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<Task> listHouseholdTasks(UUID householdId, String requesterSub) {
|
public Page<Task> listHouseholdTasks(UUID householdId, String requesterSub, TaskFilter filter, Pageable pageable) {
|
||||||
ensureMember(householdId, requesterSub);
|
ensureMember(householdId, requesterSub);
|
||||||
return tasks.findByHouseholdIdAndProjectIsNull(householdId);
|
|
||||||
|
var spec = where(inHousehold(householdId));
|
||||||
|
// limit to non-project tasks? No—design choice: include both unless filter requires "projectless only".
|
||||||
|
// If you want ONLY household-level tasks (no project), add spec = spec.and((r,q,cb)->cb.isNull(r.get("project")));
|
||||||
|
if (filter != null) {
|
||||||
|
if (filter.status() != null) spec = spec.and(withStatus(Task.Status.valueOf(filter.status())));
|
||||||
|
if (filter.priority() != null) spec = spec.and(withPriority(Task.Priority.valueOf(filter.priority())));
|
||||||
|
if (filter.dueFrom() != null) spec = spec.and(dueFrom(filter.dueFrom()));
|
||||||
|
if (filter.dueTo() != null) spec = spec.and(dueTo(filter.dueTo()));
|
||||||
|
}
|
||||||
|
return tasks.findAll(spec, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Common updates --------
|
// -------- Common updates --------
|
||||||
@ -100,9 +124,10 @@ public class ProjectTaskService {
|
|||||||
return tasks.save(t);
|
return tasks.save(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------- Iteration 4 helper --------
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public java.util.List<Task> tasksDueTomorrowForUser(String userSub) {
|
public java.util.List<se.urmo.hemhub.domain.Task> tasksDueTomorrowForUser(String userSub) {
|
||||||
var tomorrow = LocalDate.now(java.time.Clock.systemDefaultZone()).plusDays(1);
|
var tomorrow = java.time.LocalDate.now(java.time.Clock.systemDefaultZone()).plusDays(1);
|
||||||
return tasks.findDueByDateForUser(tomorrow, Task.Status.DONE, userSub);
|
return tasks.findDueByDateForUser(tomorrow, se.urmo.hemhub.domain.Task.Status.DONE, userSub);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/main/java/se/urmo/hemhub/web/ErrorHandling.java
Normal file
86
src/main/java/se/urmo/hemhub/web/ErrorHandling.java
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
package se.urmo.hemhub.web;
|
package se.urmo.hemhub.web;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
public class MeController {
|
public class MeController {
|
||||||
|
|
||||||
@ -18,13 +19,12 @@ public class MeController {
|
|||||||
|
|
||||||
// ---- Läs roller på ett säkert sätt ----
|
// ---- Läs roller på ett säkert sätt ----
|
||||||
List<String> roles = extractRealmRoles(jwt);
|
List<String> roles = extractRealmRoles(jwt);
|
||||||
|
|
||||||
// ---- Bygg svar ----
|
// ---- Bygg svar ----
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
response.put("sub", jwt.getSubject());
|
response.put("sub", jwt.getSubject());
|
||||||
response.put("email", jwt.getClaimAsString("email"));
|
response.put("email", jwt.getClaimAsString("email"));
|
||||||
response.put("preferred_username", jwt.getClaimAsString("preferred_username"));
|
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);
|
response.put("roles", roles);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package se.urmo.hemhub.web;
|
|||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
@ -9,6 +11,7 @@ import se.urmo.hemhub.domain.Task;
|
|||||||
import se.urmo.hemhub.dto.ProjectTaskDtos.*;
|
import se.urmo.hemhub.dto.ProjectTaskDtos.*;
|
||||||
import se.urmo.hemhub.service.ProjectTaskService;
|
import se.urmo.hemhub.service.ProjectTaskService;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import static java.util.stream.Collectors.toList;
|
import static java.util.stream.Collectors.toList;
|
||||||
|
|
||||||
@ -29,10 +32,9 @@ public class ProjectTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/households/{householdId}/projects")
|
@GetMapping("/households/{householdId}/projects")
|
||||||
public List<ProjectResponse> listProjects(@PathVariable UUID householdId, Authentication auth) {
|
public Page<ProjectResponse> listProjects(@PathVariable UUID householdId, Authentication auth, Pageable pageable) {
|
||||||
return svc.listProjects(householdId, sub(auth)).stream()
|
return svc.listProjects(householdId, sub(auth), pageable)
|
||||||
.map(p -> new ProjectResponse(p.getId(), p.getName(), p.getDescription()))
|
.map(p -> new ProjectResponse(p.getId(), p.getName(), p.getDescription()));
|
||||||
.collect(toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/projects/{projectId}")
|
@DeleteMapping("/projects/{projectId}")
|
||||||
@ -54,8 +56,15 @@ public class ProjectTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/projects/{projectId}/tasks")
|
@GetMapping("/projects/{projectId}/tasks")
|
||||||
public List<TaskResponse> listTasks(@PathVariable UUID projectId, Authentication auth) {
|
public Page<TaskResponse> listTasks(@PathVariable UUID projectId,
|
||||||
return svc.listTasks(projectId, sub(auth)).stream().map(this::toDto).collect(toList());
|
@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) --------
|
// -------- Tasks (household-scoped) --------
|
||||||
@ -74,8 +83,15 @@ public class ProjectTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/households/{householdId}/tasks")
|
@GetMapping("/households/{householdId}/tasks")
|
||||||
public List<TaskResponse> listHouseholdTasks(@PathVariable UUID householdId, Authentication auth) {
|
public Page<TaskResponse> listHouseholdTasks(@PathVariable UUID householdId,
|
||||||
return svc.listHouseholdTasks(householdId, sub(auth)).stream().map(this::toDto).collect(toList());
|
@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) --------
|
// -------- Task update (common) --------
|
||||||
@ -95,17 +111,15 @@ public class ProjectTaskController {
|
|||||||
return toDto(t);
|
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) {
|
private TaskResponse toDto(Task t) {
|
||||||
return new TaskResponse(t.getId(), t.getTitle(), t.getDescription(),
|
return new TaskResponse(t.getId(), t.getTitle(), t.getDescription(),
|
||||||
t.getPriority().name(), t.getStatus().name(), t.getDueDate(), t.getAssigneeSub());
|
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, we’ll 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/main/resources/application-dev.yml
Normal file
27
src/main/resources/application-dev.yml
Normal 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
|
||||||
|
|
||||||
@ -11,11 +11,10 @@ spring:
|
|||||||
ddl-auto: none
|
ddl-auto: none
|
||||||
flyway:
|
flyway:
|
||||||
enabled: true
|
enabled: true
|
||||||
security:
|
springdoc:
|
||||||
oauth2:
|
swagger-ui: # (valfritt, behåll om du redan har)
|
||||||
resourceserver:
|
url: /v3/api-docs
|
||||||
jwt:
|
|
||||||
jwk-set-uri: http://keycloak:8081/realms/hemhub/protocol/openid-connect/certs
|
|
||||||
hemhub:
|
hemhub:
|
||||||
schedule:
|
schedule:
|
||||||
reminders:
|
reminders:
|
||||||
|
|||||||
7
src/main/resources/applications-prod.yml
Normal file
7
src/main/resources/applications-prod.yml
Normal 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
|
||||||
@ -113,6 +113,37 @@ Authorization: Bearer {{token}}
|
|||||||
GET http://localhost:8080/api/v1/households/{{householdId}}/tasks
|
GET http://localhost:8080/api/v1/households/{{householdId}}/tasks
|
||||||
Authorization: Bearer {{token}}
|
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
|
### 16) My tasks due tomorrow
|
||||||
GET http://localhost:8080/api/v1/tasks/due/tomorrow
|
GET http://localhost:8080/api/v1/tasks/due/tomorrow
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.context.annotation.Import;
|
||||||
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 se.urmo.hemhub.support.TestSecurityConfig;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -19,6 +21,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
|
@Import(TestSecurityConfig.class)
|
||||||
class HouseholdControllerIT {
|
class HouseholdControllerIT {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.context.annotation.Import;
|
||||||
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 se.urmo.hemhub.support.TestSecurityConfig;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -17,6 +19,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
|
@Import(TestSecurityConfig.class)
|
||||||
class MeControllerBranchesIT {
|
class MeControllerBranchesIT {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.context.annotation.Import;
|
||||||
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 se.urmo.hemhub.support.TestSecurityConfig;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -18,6 +20,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
|
@Import(TestSecurityConfig.class)
|
||||||
class MeControllerIT {
|
class MeControllerIT {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@ -29,7 +32,7 @@ class MeControllerIT {
|
|||||||
j.subject("test-user");
|
j.subject("test-user");
|
||||||
j.claim("email", "test@example.com");
|
j.claim("email", "test@example.com");
|
||||||
j.claim("preferred_username", "test");
|
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")));
|
j.claim("realm_access", Map.of("roles", List.of("MEMBER")));
|
||||||
})))
|
})))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@ -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))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,11 +2,13 @@ package se.urmo.hemhub.integration;
|
|||||||
|
|
||||||
import com.jayway.jsonpath.JsonPath;
|
import com.jayway.jsonpath.JsonPath;
|
||||||
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.context.annotation.Import;
|
||||||
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 se.urmo.hemhub.support.TestSecurityConfig;
|
||||||
|
|
||||||
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.*;
|
||||||
@ -15,6 +17,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
|
@Import(TestSecurityConfig.class)
|
||||||
class ProjectTaskControllerIT {
|
class ProjectTaskControllerIT {
|
||||||
|
|
||||||
@Autowired MockMvc mvc;
|
@Autowired MockMvc mvc;
|
||||||
@ -36,30 +39,32 @@ class ProjectTaskControllerIT {
|
|||||||
// Create project
|
// Create project
|
||||||
var pRes = mvc.perform(post("/api/v1/projects").with(owner)
|
var pRes = mvc.perform(post("/api/v1/projects").with(owner)
|
||||||
.contentType("application/json").content("{\"householdId\":\""+householdId+"\",\"name\":\"Sovrumsrenovering\"}"))
|
.contentType("application/json").content("{\"householdId\":\""+householdId+"\",\"name\":\"Sovrumsrenovering\"}"))
|
||||||
.andExpect(status().isOk()).andExpect(jsonPath("$.id").exists()).andReturn();
|
.andExpect(status().isOk())
|
||||||
var projectId = com.jayway.jsonpath.JsonPath.read(pRes.getResponse().getContentAsString(), "$.id");
|
.andExpect(jsonPath("$.id").exists())
|
||||||
|
.andReturn();
|
||||||
|
var projectId = JsonPath.read(pRes.getResponse().getContentAsString(), "$.id");
|
||||||
|
|
||||||
// List projects (should contain one)
|
// List projects (paged; expect first item name)
|
||||||
mvc.perform(get("/api/v1/households/"+householdId+"/projects").with(owner))
|
mvc.perform(get("/api/v1/households/"+householdId+"/projects").with(owner))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$[0].name").value("Sovrumsrenovering"));
|
.andExpect(jsonPath("$.content[0].name").value("Sovrumsrenovering"));
|
||||||
|
|
||||||
// Create a task
|
// Create a task (project-scoped)
|
||||||
mvc.perform(post("/api/v1/tasks").with(owner)
|
mvc.perform(post("/api/v1/tasks").with(owner)
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
.content("{\"projectId\":\""+projectId+"\",\"title\":\"Damsug soffan\",\"priority\":\"LOW\"}"))
|
.content("{\"projectId\":\""+projectId+"\",\"title\":\"Damsug soffan\",\"priority\":\"LOW\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.title").value("Damsug soffan"));
|
.andExpect(jsonPath("$.title").value("Damsug soffan"));
|
||||||
|
|
||||||
// List tasks
|
// List tasks (paged; expect first item priority)
|
||||||
mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
|
mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$[0].priority").value("LOW"));
|
.andExpect(jsonPath("$.content[0].priority").value("LOW"));
|
||||||
|
|
||||||
// Update task status
|
// Update task status → fetch list to get taskId from page content
|
||||||
var list = mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
|
var listJson = mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner))
|
||||||
.andReturn().getResponse().getContentAsString();
|
.andReturn().getResponse().getContentAsString();
|
||||||
var taskId = com.jayway.jsonpath.JsonPath.read(list, "$[0].id");
|
var taskId = JsonPath.read(listJson, "$.content[0].id");
|
||||||
|
|
||||||
mvc.perform(patch("/api/v1/tasks/"+taskId).with(owner)
|
mvc.perform(patch("/api/v1/tasks/"+taskId).with(owner)
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.context.annotation.Import;
|
||||||
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 se.urmo.hemhub.support.TestSecurityConfig;
|
||||||
|
|
||||||
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.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
@ -14,6 +16,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
|
@Import(TestSecurityConfig.class)
|
||||||
class PublicControllerIT {
|
class PublicControllerIT {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|||||||
@ -5,8 +5,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.context.annotation.Import;
|
||||||
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 se.urmo.hemhub.support.TestSecurityConfig;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@ -19,6 +21,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
|
@Import(TestSecurityConfig.class)
|
||||||
class TaskDueControllerIT {
|
class TaskDueControllerIT {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/test/java/se/urmo/hemhub/support/TestSecurityConfig.java
Normal file
22
src/test/java/se/urmo/hemhub/support/TestSecurityConfig.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@ spring:
|
|||||||
password:
|
password:
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: none # 👈 turn off Hibernate's validate/ddl in tests
|
ddl-auto: none
|
||||||
properties:
|
properties:
|
||||||
hibernate.hbm2ddl.auto: none
|
hibernate.hbm2ddl.auto: none
|
||||||
flyway:
|
flyway:
|
||||||
|
|||||||
Reference in New Issue
Block a user