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