diff --git a/backend/mvnw b/backend/mvnw old mode 100644 new mode 100755 diff --git a/backend/pom.xml b/backend/pom.xml index ee20b58..2fb35d9 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -16,6 +16,7 @@ 21 3.0.2 + 2.0.4 @@ -64,6 +65,18 @@ spring-security-test test + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.testcontainers + postgresql + ${testcontainers.version} + test + diff --git a/backend/src/main/java/com/recipeapp/common/.gitkeep b/backend/src/main/java/com/recipeapp/common/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/main/java/com/recipeapp/common/ApiError.java b/backend/src/main/java/com/recipeapp/common/ApiError.java new file mode 100644 index 0000000..73a8049 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/ApiError.java @@ -0,0 +1,20 @@ +package com.recipeapp.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiError( + String status, + ErrorBody error +) { + public static ApiError of(String code, String message) { + return new ApiError("error", new ErrorBody(code, message, null)); + } + + public static ApiError of(String code, String message, List details) { + return new ApiError("error", new ErrorBody(code, message, details)); + } + + public record ErrorBody(String code, String message, List details) {} +} diff --git a/backend/src/main/java/com/recipeapp/common/ApiResponse.java b/backend/src/main/java/com/recipeapp/common/ApiResponse.java new file mode 100644 index 0000000..9d0f79d --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/ApiResponse.java @@ -0,0 +1,22 @@ +package com.recipeapp.common; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiResponse( + String status, + T data, + Meta meta +) { + public static ApiResponse success(T data) { + return new ApiResponse<>("success", data, null); + } + + public static ApiResponse success(T data, Meta meta) { + return new ApiResponse<>("success", data, meta); + } + + public record Meta(Pagination pagination) {} + + public record Pagination(long total, int limit, int offset, boolean hasMore) {} +} diff --git a/backend/src/main/java/com/recipeapp/common/ConflictException.java b/backend/src/main/java/com/recipeapp/common/ConflictException.java new file mode 100644 index 0000000..688861a --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/ConflictException.java @@ -0,0 +1,7 @@ +package com.recipeapp.common; + +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/recipeapp/common/GlobalExceptionHandler.java b/backend/src/main/java/com/recipeapp/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..a196419 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/GlobalExceptionHandler.java @@ -0,0 +1,40 @@ +package com.recipeapp.common; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + List details = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .toList(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiError.of("VALIDATION_ERROR", "Validation failed", details)); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleNotFound(ResourceNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiError.of("NOT_FOUND", ex.getMessage())); + } + + @ExceptionHandler(ConflictException.class) + public ResponseEntity handleConflict(ConflictException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiError.of("CONFLICT", ex.getMessage())); + } + + @ExceptionHandler(ValidationException.class) + public ResponseEntity handleBusinessValidation(ValidationException ex) { + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiError.of("VALIDATION_ERROR", ex.getMessage())); + } +} diff --git a/backend/src/main/java/com/recipeapp/common/HouseholdContext.java b/backend/src/main/java/com/recipeapp/common/HouseholdContext.java new file mode 100644 index 0000000..e3bf93c --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/HouseholdContext.java @@ -0,0 +1,10 @@ +package com.recipeapp.common; + +import java.util.UUID; + +public record HouseholdContext(UUID householdId, UUID userId, String role) { + + public boolean isPlanner() { + return "planner".equals(role); + } +} diff --git a/backend/src/main/java/com/recipeapp/common/ResourceNotFoundException.java b/backend/src/main/java/com/recipeapp/common/ResourceNotFoundException.java new file mode 100644 index 0000000..189a1ff --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package com.recipeapp.common; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/recipeapp/common/ValidationException.java b/backend/src/main/java/com/recipeapp/common/ValidationException.java new file mode 100644 index 0000000..88194d8 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/ValidationException.java @@ -0,0 +1,7 @@ +package com.recipeapp.common; + +public class ValidationException extends RuntimeException { + public ValidationException(String message) { + super(message); + } +} diff --git a/backend/src/test/java/com/recipeapp/AbstractIntegrationTest.java b/backend/src/test/java/com/recipeapp/AbstractIntegrationTest.java new file mode 100644 index 0000000..04553b4 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/AbstractIntegrationTest.java @@ -0,0 +1,28 @@ +package com.recipeapp; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@Testcontainers +@ActiveProfiles("test") +public abstract class AbstractIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("mealprep") + .withUsername("mealprep") + .withPassword("mealprep"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..dfab6f2 --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: jdbc:tc:postgresql:16-alpine:///mealprep + username: mealprep + password: mealprep + jpa: + open-in-view: false + hibernate: + ddl-auto: validate + flyway: + enabled: true + locations: classpath:db/migration