Compare commits
22 Commits
0993164062
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e41b75502 | |||
| acf9ec8a2c | |||
| 004ea4eca4 | |||
| a56d995d0f | |||
| a3ad34d094 | |||
| b7a3103837 | |||
| c54d0214f3 | |||
| a9e4a187b6 | |||
| 38190307da | |||
| 58478c469d | |||
| 10b2610960 | |||
| 68a0c0eb6a | |||
| 28ade4215a | |||
| 7bfad0ef50 | |||
| 0ab90a320c | |||
| 65c340265f | |||
| 302078fbec | |||
| 24c3b7a72c | |||
| c8dd022395 | |||
| e0d041ef67 | |||
| 84d7647481 | |||
| 89315e01dd |
178
Readme.md
Normal file
178
Readme.md
Normal file
@ -0,0 +1,178 @@
|
||||
# 🏡 HemHub – A Home & Household Management API
|
||||
|
||||
HemHub is a Spring Boot 3 application showcasing secure, role-based APIs using **Spring Security**, **OAuth2 JWT**, and **Keycloak**.
|
||||
The system models households, members, and tasks (future iterations), providing a realistic multi-tenant backend suitable for professional portfolio or consulting demonstrations.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### Iteration 1 – Security Foundation
|
||||
- OAuth2 / JWT authentication via **Keycloak 25**.
|
||||
- `/me` endpoint exposing user claims and roles.
|
||||
- Dockerized Keycloak + API stack.
|
||||
- Unit and integration tests for security validation.
|
||||
|
||||
### Iteration 2 – Household & Membership Domain
|
||||
- PostgreSQL persistence with **Flyway** migrations.
|
||||
- Entities: `Household`, `HouseholdMember`.
|
||||
- Role-based access (`OWNER` / `MEMBER`).
|
||||
- Guarded endpoints with method-level security.
|
||||
- Integration tests for domain logic and security.
|
||||
- IntelliJ HTTP Client script for easy manual testing.
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Architecture Overview
|
||||
```bash
|
||||
+-------------+
|
||||
| Keycloak | <-- OAuth2 provider (JWT tokens)
|
||||
+-------------+
|
||||
|
|
||||
v
|
||||
+-------------------+ +-------------------------+
|
||||
| hemhub-api | --> | PostgreSQL 16 database |
|
||||
| Spring Boot app | +-------------------------+
|
||||
| ├─ SecurityConfig |
|
||||
| ├─ Controllers |
|
||||
| ├─ Services |
|
||||
| └─ JPA Entities |
|
||||
+-------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker / Docker Compose
|
||||
- JDK 21+
|
||||
- Gradle 8+
|
||||
|
||||
### Run
|
||||
```bash
|
||||
make run # build + start API + Keycloak + Postgres
|
||||
```
|
||||
After startup:
|
||||
* API available at http://localhost:8080
|
||||
* Keycloak Admin Console: http://localhost:8081
|
||||
* Username: admin
|
||||
* Password: admin
|
||||
|
||||
### Obtain Token
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-d "client_id=hemhub-public" \
|
||||
-d "grant_type=password" \
|
||||
-d "username=maria" \
|
||||
-d "password=Passw0rd" \
|
||||
http://localhost:8081/realms/hemhub/protocol/openid-connect/token \
|
||||
| jq -r .access_token
|
||||
```
|
||||
---
|
||||
## 🧪 Example API Usage
|
||||
|
||||
### Use the included IntelliJ HTTP Client script:
|
||||
```bash
|
||||
src/test/http/hemhub-api.http
|
||||
```
|
||||
This file automates:
|
||||
1. Fetching an access token.
|
||||
2. Creating a household.
|
||||
3. Listing households and members.
|
||||
4. Adding new members.
|
||||
|
||||
Each request stores variables (`{{token}}`, `{{householdId}}`) automatically for later steps.
|
||||
|
||||
---
|
||||
|
||||
## 🧰 API Endpoints
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|-----|------------|---------------|
|
||||
| GET | /me | Current user info from JWT | 🔒Authenticated |
|
||||
| POST | /api/v1/households | Create a new household | 🔒Authenticated |
|
||||
| GET | /api/v1/households | List caller’s households | 🔒Authenticated |
|
||||
| GET | /api/v1/households/{id}/members | List members in household | 🔒Member only |
|
||||
| POST | /api/v1/households/{id}/members | Add new member | 🔒OWNER only |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
Run full test suite:
|
||||
```bash
|
||||
./gradlew clean test
|
||||
```
|
||||
|
||||
* Uses in-memory H2 database.
|
||||
* Applies Flyway migrations automatically.
|
||||
* Covers controllers, services, and access control.
|
||||
* All tests must pass before CI build succeeds.
|
||||
|
||||
Integration tests cover endpoints, security, and service logic.
|
||||
|
||||
## 🐳 Docker Services
|
||||
| Service | Port | Description |
|
||||
| ------------ | ---- | ------------------------ |
|
||||
| `hemhub-api` | 8080 | Spring Boot REST API |
|
||||
| `keycloak` | 8081 | OAuth2 Identity Provider |
|
||||
| `postgres` | 5432 | PostgreSQL 16 database |
|
||||
|
||||
### Common commands
|
||||
```bash
|
||||
make run # Build + start stack
|
||||
make stop # Stop and remove containers
|
||||
make logs # Tail logs from all services
|
||||
make image # Build Docker image for API
|
||||
```
|
||||
---
|
||||
## ⚙️ Configuration Overview
|
||||
| Component | Tool | Version |
|
||||
| --------------------------- | -------------------------------------- | ------- |
|
||||
| **Language** | Java | 21 |
|
||||
| **Framework** | Spring Boot | 3.3.x |
|
||||
| **Security** | Spring Security OAuth2 Resource Server | |
|
||||
| **Database** | PostgreSQL | 16.10 |
|
||||
| **Migrations** | Flyway | 10.17.3 |
|
||||
| **Container Orchestration** | Docker Compose | |
|
||||
| **Build System** | Gradle | 8.x |
|
||||
| **Identity Provider** | Keycloak | 25.x |
|
||||
| **Testing** | JUnit 5, MockMvc | |
|
||||
|
||||
## 🧩 Folder Structure
|
||||
```bash
|
||||
hemhub/
|
||||
├─ src/
|
||||
│ ├─ main/java/se/urmo/hemhub/
|
||||
│ │ ├─ controller/ # REST endpoints
|
||||
│ │ ├─ domain/ # JPA entities
|
||||
│ │ ├─ repo/ # Repositories
|
||||
│ │ ├─ service/ # Business logic
|
||||
│ │ ├─ security/ # Security config & guards
|
||||
│ │ └─ HemhubApplication # Main entry point
|
||||
│ └─ resources/
|
||||
│ ├─ db/migration/ # Flyway migrations (V1, V2, …)
|
||||
│ └─ application.yml
|
||||
│
|
||||
├─ src/test/
|
||||
│ ├─ java/… # Unit & integration tests
|
||||
│ └─ http/hemhub-api.http # IntelliJ HTTP test file
|
||||
│
|
||||
├─ docker-compose.yml
|
||||
├─ Makefile
|
||||
├─ build.gradle
|
||||
└─ README.md
|
||||
```
|
||||
---
|
||||
## 🧰 Development Notes
|
||||
|
||||
* IDE: IntelliJ IDEA recommended.
|
||||
* Run IntelliJ HTTP scripts directly (src/test/http/hemhub-api.http).
|
||||
* For local debugging, set env SPRING_PROFILES_ACTIVE=dev.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
||||
|
||||
26
build.gradle
26
build.gradle
@ -11,22 +11,38 @@ java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
ext['flyway.version'] = '10.17.3' // managed override
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.security:spring-security-oauth2-jose'
|
||||
// --- Spring starters ---
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
// Core Flyway + Postgres driver plugin
|
||||
implementation 'org.flywaydb:flyway-core'
|
||||
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||
|
||||
// DB driver
|
||||
runtimeOnly 'org.postgresql:postgresql:42.7.4'
|
||||
|
||||
// --- Lombok (used in services/guards) ---
|
||||
compileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
testCompileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
testAnnotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
|
||||
// --- Tests ---
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
testImplementation 'com.jayway.jsonpath:json-path:2.9.0'
|
||||
testRuntimeOnly 'com.h2database:h2:2.2.224' // for application-test.yml profile
|
||||
}
|
||||
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
finalizedBy jacocoTestReport
|
||||
|
||||
@ -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
1883
export/hemhub-realm.json
Executable file
File diff suppressed because it is too large
Load Diff
32
src/main/java/se/urmo/hemhub/config/HouseholdGuard.java
Normal file
32
src/main/java/se/urmo/hemhub/config/HouseholdGuard.java
Normal file
@ -0,0 +1,32 @@
|
||||
package se.urmo.hemhub.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.stereotype.Component;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
import se.urmo.hemhub.repo.HouseholdMemberRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HouseholdGuard {
|
||||
private final HouseholdMemberRepository members;
|
||||
|
||||
public boolean isMember(UUID householdId, Authentication auth) {
|
||||
var sub = jwtSub(auth);
|
||||
return members.existsByHouseholdIdAndUserSub(householdId, sub);
|
||||
}
|
||||
|
||||
public boolean isOwner(UUID householdId, Authentication auth) {
|
||||
var sub = jwtSub(auth);
|
||||
return members.findByHouseholdIdAndUserSub(householdId, sub)
|
||||
.map(m -> m.getRole() == HouseholdMember.Role.OWNER).orElse(false);
|
||||
}
|
||||
|
||||
private static String jwtSub(Authentication auth) {
|
||||
if (auth == null || !(auth.getPrincipal() instanceof Jwt jwt)) throw new IllegalStateException("No JWT");
|
||||
return jwt.getSubject();
|
||||
}
|
||||
}
|
||||
18
src/main/java/se/urmo/hemhub/config/SchedulingConfig.java
Normal file
18
src/main/java/se/urmo/hemhub/config/SchedulingConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -2,18 +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.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
@ -22,19 +22,38 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.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();
|
||||
@ -42,4 +61,3 @@ public class SecurityConfig {
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
src/main/java/se/urmo/hemhub/domain/Household.java
Normal file
20
src/main/java/se/urmo/hemhub/domain/Household.java
Normal file
@ -0,0 +1,20 @@
|
||||
package se.urmo.hemhub.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "households")
|
||||
public class Household {
|
||||
@Id private UUID id;
|
||||
@Column(nullable=false, length=120) private String name;
|
||||
@Column(name="created_at", nullable=false) private Instant createdAt = Instant.now();
|
||||
|
||||
protected Household() {}
|
||||
public Household(UUID id, String name) { this.id=id; this.name=name; }
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
39
src/main/java/se/urmo/hemhub/domain/HouseholdMember.java
Normal file
39
src/main/java/se/urmo/hemhub/domain/HouseholdMember.java
Normal file
@ -0,0 +1,39 @@
|
||||
package se.urmo.hemhub.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "household_members",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames={"household_id","user_sub"}))
|
||||
public class HouseholdMember {
|
||||
public enum Role { OWNER, MEMBER }
|
||||
|
||||
@Id private UUID id;
|
||||
|
||||
@ManyToOne(optional=false, fetch=FetchType.LAZY)
|
||||
@JoinColumn(name="household_id")
|
||||
private Household household;
|
||||
|
||||
@Column(name="user_sub", nullable=false, length=64) private String userSub;
|
||||
@Column(nullable=true, length=255) private String email;
|
||||
@Column(name="display_name", length=120) private String displayName;
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
private Role role;
|
||||
@Column(name="created_at", nullable=false) private Instant createdAt = Instant.now();
|
||||
|
||||
protected HouseholdMember() {}
|
||||
public HouseholdMember(UUID id, Household h, String userSub, String email, String displayName, Role role) {
|
||||
this.id=id; this.household=h; this.userSub=userSub; this.email=email; this.displayName=displayName; this.role=role;
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public String getUserSub() { return userSub; }
|
||||
public Role getRole() { return role; }
|
||||
}
|
||||
30
src/main/java/se/urmo/hemhub/domain/Project.java
Normal file
30
src/main/java/se/urmo/hemhub/domain/Project.java
Normal 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; }
|
||||
}
|
||||
89
src/main/java/se/urmo/hemhub/domain/Task.java
Normal file
89
src/main/java/se/urmo/hemhub/domain/Task.java
Normal 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; }
|
||||
}
|
||||
12
src/main/java/se/urmo/hemhub/dto/HouseholdDtos.java
Normal file
12
src/main/java/se/urmo/hemhub/dto/HouseholdDtos.java
Normal file
@ -0,0 +1,12 @@
|
||||
package se.urmo.hemhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.*;
|
||||
|
||||
public class HouseholdDtos {
|
||||
public record CreateHouseholdRequest(@NotBlank String name) {}
|
||||
public record HouseholdResponse(UUID id, String name) {}
|
||||
public record MemberResponse(UUID id, String userSub, String email, String displayName, String role) {}
|
||||
public record AddMemberRequest(@NotNull String userSub, String email, String displayName, @NotNull String role) {}
|
||||
}
|
||||
68
src/main/java/se/urmo/hemhub/dto/ProjectTaskDtos.java
Normal file
68
src/main/java/se/urmo/hemhub/dto/ProjectTaskDtos.java
Normal 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
|
||||
) {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package se.urmo.hemhub.repo;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||
List<HouseholdMember> findByUserSub(String userSub);
|
||||
|
||||
// ✅ Use property path "householdId"
|
||||
boolean existsByHouseholdIdAndUserSub(UUID householdId, String userSub);
|
||||
Optional<HouseholdMember> findByHouseholdIdAndUserSub(UUID householdId, String userSub);
|
||||
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package se.urmo.hemhub.repo;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import se.urmo.hemhub.domain.Household;
|
||||
import java.util.*;
|
||||
|
||||
public interface HouseholdRepository extends JpaRepository<Household, UUID> {}
|
||||
13
src/main/java/se/urmo/hemhub/repo/ProjectRepository.java
Normal file
13
src/main/java/se/urmo/hemhub/repo/ProjectRepository.java
Normal 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);
|
||||
}
|
||||
38
src/main/java/se/urmo/hemhub/repo/TaskRepository.java
Normal file
38
src/main/java/se/urmo/hemhub/repo/TaskRepository.java
Normal 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
58
src/main/java/se/urmo/hemhub/service/HouseholdService.java
Normal file
58
src/main/java/se/urmo/hemhub/service/HouseholdService.java
Normal file
@ -0,0 +1,58 @@
|
||||
package se.urmo.hemhub.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import se.urmo.hemhub.domain.Household;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
import se.urmo.hemhub.repo.HouseholdMemberRepository;
|
||||
import se.urmo.hemhub.repo.HouseholdRepository;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HouseholdService {
|
||||
private final HouseholdRepository households;
|
||||
private final HouseholdMemberRepository members;
|
||||
|
||||
@Transactional
|
||||
public Household createHousehold(String name, String creatorSub, String email, String displayName) {
|
||||
var h = new Household(UUID.randomUUID(), name);
|
||||
households.save(h);
|
||||
var m = new HouseholdMember(UUID.randomUUID(), h, creatorSub, email, displayName, HouseholdMember.Role.OWNER);
|
||||
members.save(m);
|
||||
return h;
|
||||
}
|
||||
|
||||
@Transactional(readOnly=true)
|
||||
public List<Household> listForUser(String userSub) {
|
||||
return members.findByUserSub(userSub).stream().map(HouseholdMember::getHousehold).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public HouseholdMember addMember(UUID householdId, String requesterSub, String userSub, String email, String displayName, HouseholdMember.Role role) {
|
||||
var req = members.findByHouseholdIdAndUserSub(householdId, requesterSub)
|
||||
.orElseThrow(() -> new NoSuchElementException("Requester not member of household"));
|
||||
if (req.getRole() != HouseholdMember.Role.OWNER) throw new SecurityException("Only OWNER may add members");
|
||||
|
||||
var h = households.findById(householdId).orElseThrow();
|
||||
if (members.existsByHouseholdIdAndUserSub(householdId, userSub)) {
|
||||
throw new IllegalStateException("User already member");
|
||||
}
|
||||
var m = new HouseholdMember(UUID.randomUUID(), h, userSub, email, displayName, role);
|
||||
return members.save(m);
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly=true)
|
||||
public List<HouseholdMember> listMembers(UUID householdId, String requesterSub) {
|
||||
ensureMember(householdId, requesterSub);
|
||||
return members.findByHouseholdId(householdId);
|
||||
}
|
||||
|
||||
private void ensureMember(UUID householdId, String sub) {
|
||||
if (!members.existsByHouseholdIdAndUserSub(householdId, sub))
|
||||
throw new SecurityException("Forbidden for non-members");
|
||||
}
|
||||
}
|
||||
133
src/main/java/se/urmo/hemhub/service/ProjectTaskService.java
Normal file
133
src/main/java/se/urmo/hemhub/service/ProjectTaskService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
38
src/main/java/se/urmo/hemhub/service/TaskDueService.java
Normal file
38
src/main/java/se/urmo/hemhub/service/TaskDueService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
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"));
|
||||
}
|
||||
}
|
||||
69
src/main/java/se/urmo/hemhub/web/HouseholdController.java
Normal file
69
src/main/java/se/urmo/hemhub/web/HouseholdController.java
Normal file
@ -0,0 +1,69 @@
|
||||
package se.urmo.hemhub.web;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.AddMemberRequest;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.CreateHouseholdRequest;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.HouseholdResponse;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.MemberResponse;
|
||||
import se.urmo.hemhub.service.HouseholdService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class HouseholdController {
|
||||
private final HouseholdService service;
|
||||
|
||||
@PostMapping("/households")
|
||||
public HouseholdResponse create(@Valid @RequestBody CreateHouseholdRequest req, Authentication auth) {
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
var h = service.createHousehold(req.name(), jwt.getSubject(), jwt.getClaimAsString("email"), jwt.getClaimAsString("preferred_username"));
|
||||
return new HouseholdResponse(h.getId(), h.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/households")
|
||||
public List<HouseholdResponse> myHouseholds(Authentication auth) {
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
return service.listForUser(jwt.getSubject()).stream()
|
||||
.map(h -> new HouseholdResponse(h.getId(), h.getName()))
|
||||
.collect(toList());
|
||||
}
|
||||
|
||||
@PreAuthorize("@householdGuard.isOwner(#householdId, authentication)")
|
||||
@PostMapping("/households/{householdId}/members")
|
||||
public List<MemberResponse> addMember(@PathVariable UUID householdId, @Valid @RequestBody AddMemberRequest req, Authentication auth) {
|
||||
var role = HouseholdMember.Role.valueOf(req.role());
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
service.addMember(householdId, jwt.getSubject(), req.userSub(), req.email(), req.displayName(), role);
|
||||
return listMembersInternal(householdId, auth);
|
||||
}
|
||||
|
||||
@PreAuthorize("@householdGuard.isMember(#householdId, authentication)")
|
||||
@GetMapping("/households/{householdId}/members")
|
||||
public List<MemberResponse> members(@PathVariable UUID householdId, Authentication auth) {
|
||||
return listMembersInternal(householdId, auth);
|
||||
}
|
||||
|
||||
private List<MemberResponse> listMembersInternal(UUID householdId, Authentication auth) {
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
return service.listMembers(householdId, jwt.getSubject()).stream()
|
||||
.map(m -> new MemberResponse(m.getId(), m.getUserSub(), m.getUserSub().equals(jwt.getSubject()) ? jwt.getClaimAsString("email") : m.getEmail(),
|
||||
m.getUserSub().equals(jwt.getSubject()) ? jwt.getClaimAsString("preferred_username") : m.getUserSub(), m.getRole().name()))
|
||||
.collect(toList());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
125
src/main/java/se/urmo/hemhub/web/ProjectTaskController.java
Normal file
125
src/main/java/se/urmo/hemhub/web/ProjectTaskController.java
Normal 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());
|
||||
}
|
||||
}
|
||||
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,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 * * *"
|
||||
|
||||
|
||||
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
|
||||
26
src/main/resources/db/migration/V2__household.sql
Normal file
26
src/main/resources/db/migration/V2__household.sql
Normal file
@ -0,0 +1,26 @@
|
||||
-- src/main/resources/db/migration/V2__households.sql
|
||||
|
||||
-- Households & Members (H2/PG compatible)
|
||||
CREATE TABLE households (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE household_members (
|
||||
id UUID PRIMARY KEY,
|
||||
household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
user_sub VARCHAR(64) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
display_name VARCHAR(120),
|
||||
role VARCHAR(16) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (household_id, user_sub)
|
||||
);
|
||||
|
||||
-- Optional: enforce role values (H2 accepts the CHECK too)
|
||||
ALTER TABLE household_members
|
||||
ADD CONSTRAINT chk_household_role CHECK (role IN ('OWNER','MEMBER'));
|
||||
|
||||
CREATE INDEX idx_household_members_user_sub ON household_members(user_sub);
|
||||
CREATE INDEX idx_household_members_household ON household_members(household_id);
|
||||
30
src/main/resources/db/migration/V3__projects_tasks.sql
Normal file
30
src/main/resources/db/migration/V3__projects_tasks.sql
Normal 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'));
|
||||
28
src/main/resources/db/migration/V4__task_household_link.sql
Normal file
28
src/main/resources/db/migration/V4__task_household_link.sql
Normal 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;
|
||||
151
src/test/http/hemhub-api.http
Normal file
151
src/test/http/hemhub-api.http
Normal file
@ -0,0 +1,151 @@
|
||||
### 1. Get access token for user "maria"
|
||||
POST http://localhost:8081/realms/hemhub/protocol/openid-connect/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
client_id=hemhub-public&
|
||||
grant_type=password&
|
||||
username=maria&
|
||||
password=Passw0rd
|
||||
|
||||
> {%
|
||||
client.global.set("token", response.body.access_token);
|
||||
client.global.set("refresh_token", response.body.refresh_token);
|
||||
%}
|
||||
|
||||
### 2. Decode token (optional, debugging)
|
||||
GET https://jwt.io/?access_token={{token}}
|
||||
|
||||
### 3. Get current user info
|
||||
GET http://localhost:8080/me
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
### 4. Create a household
|
||||
POST http://localhost:8080/api/v1/households
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"name": "Familjen Andersson"
|
||||
}
|
||||
|
||||
> {% client.global.set("householdId", response.body.id); %}
|
||||
|
||||
### 5. List my households
|
||||
GET http://localhost:8080/api/v1/households
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
### 6. List household members
|
||||
GET http://localhost:8080/api/v1/households/{{householdId}}/members
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
### 7. Add a member (requires OWNER role)
|
||||
POST http://localhost:8080/api/v1/households/{{householdId}}/members
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"userSub": "ulf-sub",
|
||||
"email": "ulf@example.com",
|
||||
"displayName": "Ulf",
|
||||
"role": "MEMBER"
|
||||
}
|
||||
|
||||
### 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}}
|
||||
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
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 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.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;
|
||||
|
||||
@Test
|
||||
void create_household_and_list_for_user() throws Exception {
|
||||
var jwtUser = jwt().jwt(j -> {
|
||||
j.subject("sub-user-1");
|
||||
j.claim("email", "u1@example.com");
|
||||
j.claim("preferred_username", "u1");
|
||||
j.claim("realm_access", Map.of("roles", List.of("OWNER", "MEMBER")));
|
||||
});
|
||||
|
||||
// create
|
||||
mvc.perform(post("/api/v1/households")
|
||||
.with(jwtUser)
|
||||
.contentType("application/json")
|
||||
.content("{\"name\":\"Familjen U1\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").exists());
|
||||
|
||||
// list
|
||||
mvc.perform(get("/api/v1/households").with(jwtUser))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("Familjen U1"));
|
||||
}
|
||||
|
||||
@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");
|
||||
});
|
||||
|
||||
// create household as owner
|
||||
var res = mvc.perform(post("/api/v1/households").with(owner)
|
||||
.contentType("application/json").content("{\"name\":\"H1\"}"))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
var id = com.jayway.jsonpath.JsonPath.read(res.getResponse().getContentAsString(), "$.id");
|
||||
|
||||
// cannot add as non-owner
|
||||
mvc.perform(post("/api/v1/households/" + id + "/members").with(other)
|
||||
.contentType("application/json")
|
||||
.content("{\"userSub\":\"u2\",\"role\":\"MEMBER\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
@ -1,24 +1,29 @@
|
||||
package se.urmo.hemhub.web;
|
||||
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 {
|
||||
@ -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())
|
||||
@ -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))));
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,22 @@
|
||||
package se.urmo.hemhub.web;
|
||||
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
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -7,5 +7,11 @@ spring:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
properties:
|
||||
hibernate.hbm2ddl.auto: none
|
||||
flyway:
|
||||
enabled: false
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
logging:
|
||||
level:
|
||||
org.flywaydb.core: INFO
|
||||
Reference in New Issue
Block a user