diff --git a/backend/pom.xml b/backend/pom.xml
index 2fb35d9..f4c4a47 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -36,8 +36,8 @@
spring-boot-starter-web
- org.flywaydb
- flyway-core
+ org.springframework.boot
+ spring-boot-flyway
org.flywaydb
@@ -67,13 +67,13 @@
org.testcontainers
- junit-jupiter
+ testcontainers
${testcontainers.version}
test
org.testcontainers
- postgresql
+ testcontainers-postgresql
${testcontainers.version}
test
diff --git a/backend/src/main/java/com/recipeapp/auth/.gitkeep b/backend/src/main/java/com/recipeapp/auth/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/backend/src/main/java/com/recipeapp/auth/AuthController.java b/backend/src/main/java/com/recipeapp/auth/AuthController.java
new file mode 100644
index 0000000..3e5e9ee
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/AuthController.java
@@ -0,0 +1,65 @@
+package com.recipeapp.auth;
+
+import com.recipeapp.auth.dto.*;
+import com.recipeapp.common.ApiResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpSession;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.security.Principal;
+
+@RestController
+@RequestMapping("/v1/auth")
+public class AuthController {
+
+ private final AuthService authService;
+
+ public AuthController(AuthService authService) {
+ this.authService = authService;
+ }
+
+ @PostMapping("/signup")
+ public ResponseEntity> signup(
+ @Valid @RequestBody SignupRequest request,
+ HttpServletRequest httpRequest) {
+ UserResponse user = authService.signup(request);
+ httpRequest.getSession(true);
+ return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
+ }
+
+ @PostMapping("/login")
+ public ResponseEntity> login(
+ @Valid @RequestBody LoginRequest request,
+ HttpServletRequest httpRequest) {
+ UserResponse user = authService.login(request);
+ HttpSession session = httpRequest.getSession(true);
+ session.setAttribute("user_email", user.email());
+ return ResponseEntity.ok(ApiResponse.success(user));
+ }
+
+ @PostMapping("/logout")
+ public ResponseEntity logout(HttpServletRequest httpRequest) {
+ HttpSession session = httpRequest.getSession(false);
+ if (session != null) {
+ session.invalidate();
+ }
+ return ResponseEntity.noContent().build();
+ }
+
+ @GetMapping("/me")
+ public ResponseEntity> me(Principal principal) {
+ UserResponse user = authService.getCurrentUser(principal.getName());
+ return ResponseEntity.ok(ApiResponse.success(user));
+ }
+
+ @PatchMapping("/me")
+ public ResponseEntity> updateProfile(
+ Principal principal,
+ @Valid @RequestBody UpdateProfileRequest request) {
+ UserResponse user = authService.updateProfile(principal.getName(), request);
+ return ResponseEntity.ok(ApiResponse.success(user));
+ }
+}
diff --git a/backend/src/main/java/com/recipeapp/auth/AuthService.java b/backend/src/main/java/com/recipeapp/auth/AuthService.java
new file mode 100644
index 0000000..0905fc2
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/AuthService.java
@@ -0,0 +1,10 @@
+package com.recipeapp.auth;
+
+import com.recipeapp.auth.dto.*;
+
+public interface AuthService {
+ UserResponse signup(SignupRequest request);
+ UserResponse login(LoginRequest request);
+ UserResponse getCurrentUser(String email);
+ UserResponse updateProfile(String email, UpdateProfileRequest request);
+}
diff --git a/backend/src/main/java/com/recipeapp/auth/AuthServiceImpl.java b/backend/src/main/java/com/recipeapp/auth/AuthServiceImpl.java
new file mode 100644
index 0000000..3e132ba
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/AuthServiceImpl.java
@@ -0,0 +1,99 @@
+package com.recipeapp.auth;
+
+import com.recipeapp.auth.dto.*;
+import com.recipeapp.auth.entity.UserAccount;
+import com.recipeapp.common.ConflictException;
+import com.recipeapp.common.ResourceNotFoundException;
+import com.recipeapp.common.ValidationException;
+import com.recipeapp.household.HouseholdMemberRepository;
+import com.recipeapp.household.entity.HouseholdMember;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class AuthServiceImpl implements AuthService {
+
+ private final UserAccountRepository userAccountRepository;
+ private final HouseholdMemberRepository householdMemberRepository;
+ private final PasswordEncoder passwordEncoder;
+
+ public AuthServiceImpl(UserAccountRepository userAccountRepository,
+ HouseholdMemberRepository householdMemberRepository,
+ PasswordEncoder passwordEncoder) {
+ this.userAccountRepository = userAccountRepository;
+ this.householdMemberRepository = householdMemberRepository;
+ this.passwordEncoder = passwordEncoder;
+ }
+
+ @Override
+ @Transactional
+ public UserResponse signup(SignupRequest request) {
+ if (userAccountRepository.existsByEmailIgnoreCase(request.email())) {
+ throw new ConflictException("Email already registered");
+ }
+ var user = new UserAccount(
+ request.email(),
+ request.displayName(),
+ passwordEncoder.encode(request.password())
+ );
+ user = userAccountRepository.save(user);
+ return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public UserResponse login(LoginRequest request) {
+ UserAccount user = userAccountRepository.findByEmailIgnoreCase(request.email())
+ .orElseThrow(() -> new ResourceNotFoundException("Invalid email or password"));
+
+ if (!user.isActive()) {
+ throw new ValidationException("Account is deactivated");
+ }
+ if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
+ throw new ValidationException("Invalid email or password");
+ }
+ return toUserResponse(user);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public UserResponse getCurrentUser(String email) {
+ UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
+ .orElseThrow(() -> new ResourceNotFoundException("User not found"));
+ return toUserResponse(user);
+ }
+
+ @Override
+ @Transactional
+ public UserResponse updateProfile(String email, UpdateProfileRequest request) {
+ UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
+ .orElseThrow(() -> new ResourceNotFoundException("User not found"));
+
+ if (request.displayName() != null) {
+ user.setDisplayName(request.displayName());
+ }
+ if (request.newPassword() != null) {
+ if (request.currentPassword() == null) {
+ throw new ValidationException("Current password is required to set a new password");
+ }
+ if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
+ throw new ValidationException("Current password is incorrect");
+ }
+ user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
+ }
+ user = userAccountRepository.save(user);
+ return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
+ }
+
+ private UserResponse toUserResponse(UserAccount user) {
+ return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
+ .map(member -> UserResponse.withHousehold(
+ user.getId(), user.getEmail(), user.getDisplayName(),
+ member.getHousehold().getId(), member.getHousehold().getName(),
+ member.getRole(), user.getSystemRole()))
+ .orElse(UserResponse.withHousehold(
+ user.getId(), user.getEmail(), user.getDisplayName(),
+ null, null, null, user.getSystemRole()));
+ }
+}
diff --git a/backend/src/main/java/com/recipeapp/auth/CustomUserDetailsService.java b/backend/src/main/java/com/recipeapp/auth/CustomUserDetailsService.java
new file mode 100644
index 0000000..0676702
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/CustomUserDetailsService.java
@@ -0,0 +1,35 @@
+package com.recipeapp.auth;
+
+import com.recipeapp.auth.entity.UserAccount;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class CustomUserDetailsService implements UserDetailsService {
+
+ private final UserAccountRepository userAccountRepository;
+
+ public CustomUserDetailsService(UserAccountRepository userAccountRepository) {
+ this.userAccountRepository = userAccountRepository;
+ }
+
+ @Override
+ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
+ UserAccount account = userAccountRepository.findByEmailIgnoreCase(email)
+ .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
+
+ if (!account.isActive()) {
+ throw new UsernameNotFoundException("Account is deactivated: " + email);
+ }
+
+ var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + account.getSystemRole().toUpperCase()));
+
+ return new User(account.getEmail(), account.getPasswordHash(), authorities);
+ }
+}
diff --git a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java
new file mode 100644
index 0000000..c084836
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java
@@ -0,0 +1,43 @@
+package com.recipeapp.auth;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.HttpStatusEntryPoint;
+import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
+import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
+
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(csrf -> csrf
+ .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
+ .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
+ .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
+ .requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
+ .anyRequest().authenticated())
+ .exceptionHandling(ex -> ex
+ .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
+ .sessionManagement(session -> session
+ .maximumSessions(1));
+
+ return http.build();
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
diff --git a/backend/src/main/java/com/recipeapp/auth/UserAccountRepository.java b/backend/src/main/java/com/recipeapp/auth/UserAccountRepository.java
new file mode 100644
index 0000000..c0f9e6b
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/UserAccountRepository.java
@@ -0,0 +1,12 @@
+package com.recipeapp.auth;
+
+import com.recipeapp.auth.entity.UserAccount;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface UserAccountRepository extends JpaRepository {
+ Optional findByEmailIgnoreCase(String email);
+ boolean existsByEmailIgnoreCase(String email);
+}
diff --git a/backend/src/main/java/com/recipeapp/auth/dto/LoginRequest.java b/backend/src/main/java/com/recipeapp/auth/dto/LoginRequest.java
new file mode 100644
index 0000000..337cf72
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/dto/LoginRequest.java
@@ -0,0 +1,9 @@
+package com.recipeapp.auth.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public record LoginRequest(
+ @NotBlank @Email String email,
+ @NotBlank String password
+) {}
diff --git a/backend/src/main/java/com/recipeapp/auth/dto/SignupRequest.java b/backend/src/main/java/com/recipeapp/auth/dto/SignupRequest.java
new file mode 100644
index 0000000..4cfed66
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/dto/SignupRequest.java
@@ -0,0 +1,11 @@
+package com.recipeapp.auth.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record SignupRequest(
+ @NotBlank @Email String email,
+ @NotBlank @Size(min = 8) String password,
+ @NotBlank @Size(max = 100) String displayName
+) {}
diff --git a/backend/src/main/java/com/recipeapp/auth/dto/UpdateProfileRequest.java b/backend/src/main/java/com/recipeapp/auth/dto/UpdateProfileRequest.java
new file mode 100644
index 0000000..dd22ea5
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/dto/UpdateProfileRequest.java
@@ -0,0 +1,9 @@
+package com.recipeapp.auth.dto;
+
+import jakarta.validation.constraints.Size;
+
+public record UpdateProfileRequest(
+ @Size(max = 100) String displayName,
+ String currentPassword,
+ @Size(min = 8) String newPassword
+) {}
diff --git a/backend/src/main/java/com/recipeapp/auth/dto/UserResponse.java b/backend/src/main/java/com/recipeapp/auth/dto/UserResponse.java
new file mode 100644
index 0000000..7abe216
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/dto/UserResponse.java
@@ -0,0 +1,26 @@
+package com.recipeapp.auth.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import java.util.UUID;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record UserResponse(
+ UUID id,
+ String email,
+ String displayName,
+ UUID householdId,
+ String householdName,
+ String householdRole,
+ String systemRole
+) {
+ public static UserResponse basic(UUID id, String email, String displayName) {
+ return new UserResponse(id, email, displayName, null, null, null, null);
+ }
+
+ public static UserResponse withHousehold(UUID id, String email, String displayName,
+ UUID householdId, String householdName,
+ String householdRole, String systemRole) {
+ return new UserResponse(id, email, displayName, householdId, householdName,
+ householdRole, systemRole);
+ }
+}
diff --git a/backend/src/main/java/com/recipeapp/auth/entity/UserAccount.java b/backend/src/main/java/com/recipeapp/auth/entity/UserAccount.java
new file mode 100644
index 0000000..21575ae
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/auth/entity/UserAccount.java
@@ -0,0 +1,68 @@
+package com.recipeapp.auth.entity;
+
+import jakarta.persistence.*;
+import java.time.Instant;
+import java.util.UUID;
+
+@Entity
+@Table(name = "user_account")
+public class UserAccount {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @Column(nullable = false, unique = true, columnDefinition = "citext")
+ private String email;
+
+ @Column(name = "display_name", nullable = false, length = 100)
+ private String displayName;
+
+ @Column(name = "password_hash", nullable = false)
+ private String passwordHash;
+
+ @Column(name = "system_role", nullable = false, length = 10)
+ private String systemRole = "user";
+
+ @Column(name = "is_active", nullable = false)
+ private boolean isActive = true;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private Instant createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt;
+
+ protected UserAccount() {}
+
+ public UserAccount(String email, String displayName, String passwordHash) {
+ this.email = email;
+ this.displayName = displayName;
+ this.passwordHash = passwordHash;
+ }
+
+ @PrePersist
+ void onCreate() {
+ createdAt = Instant.now();
+ updatedAt = Instant.now();
+ }
+
+ @PreUpdate
+ void onUpdate() {
+ updatedAt = Instant.now();
+ }
+
+ public UUID getId() { return id; }
+ public String getEmail() { return email; }
+ public void setEmail(String email) { this.email = email; }
+ public String getDisplayName() { return displayName; }
+ public void setDisplayName(String displayName) { this.displayName = displayName; }
+ public String getPasswordHash() { return passwordHash; }
+ public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
+ public String getSystemRole() { return systemRole; }
+ public void setSystemRole(String systemRole) { this.systemRole = systemRole; }
+ public boolean isActive() { return isActive; }
+ public void setActive(boolean active) { isActive = active; }
+ public Instant getCreatedAt() { return createdAt; }
+ public Instant getUpdatedAt() { return updatedAt; }
+}
diff --git a/backend/src/main/java/com/recipeapp/household/.gitkeep b/backend/src/main/java/com/recipeapp/household/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java
new file mode 100644
index 0000000..772980e
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java
@@ -0,0 +1,11 @@
+package com.recipeapp.household;
+
+import com.recipeapp.household.entity.HouseholdMember;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface HouseholdMemberRepository extends JpaRepository {
+ Optional findByUserEmailIgnoreCase(String email);
+}
diff --git a/backend/src/main/java/com/recipeapp/household/entity/Household.java b/backend/src/main/java/com/recipeapp/household/entity/Household.java
new file mode 100644
index 0000000..6add9b3
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/household/entity/Household.java
@@ -0,0 +1,43 @@
+package com.recipeapp.household.entity;
+
+import com.recipeapp.auth.entity.UserAccount;
+import jakarta.persistence.*;
+import java.time.Instant;
+import java.util.UUID;
+
+@Entity
+@Table(name = "household")
+public class Household {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @Column(nullable = false, length = 100)
+ private String name;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "created_by", nullable = false)
+ private UserAccount createdBy;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private Instant createdAt;
+
+ protected Household() {}
+
+ public Household(String name, UserAccount createdBy) {
+ this.name = name;
+ this.createdBy = createdBy;
+ }
+
+ @PrePersist
+ void onCreate() {
+ createdAt = Instant.now();
+ }
+
+ public UUID getId() { return id; }
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public UserAccount getCreatedBy() { return createdBy; }
+ public Instant getCreatedAt() { return createdAt; }
+}
diff --git a/backend/src/main/java/com/recipeapp/household/entity/HouseholdMember.java b/backend/src/main/java/com/recipeapp/household/entity/HouseholdMember.java
new file mode 100644
index 0000000..b407045
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/household/entity/HouseholdMember.java
@@ -0,0 +1,49 @@
+package com.recipeapp.household.entity;
+
+import com.recipeapp.auth.entity.UserAccount;
+import jakarta.persistence.*;
+import java.time.Instant;
+import java.util.UUID;
+
+@Entity
+@Table(name = "household_member")
+public class HouseholdMember {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "household_id", nullable = false)
+ private Household household;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false, unique = true)
+ private UserAccount user;
+
+ @Column(nullable = false, length = 10)
+ private String role;
+
+ @Column(name = "joined_at", nullable = false, updatable = false)
+ private Instant joinedAt;
+
+ protected HouseholdMember() {}
+
+ public HouseholdMember(Household household, UserAccount user, String role) {
+ this.household = household;
+ this.user = user;
+ this.role = role;
+ }
+
+ @PrePersist
+ void onCreate() {
+ joinedAt = Instant.now();
+ }
+
+ public UUID getId() { return id; }
+ public Household getHousehold() { return household; }
+ public UserAccount getUser() { return user; }
+ public String getRole() { return role; }
+ public void setRole(String role) { this.role = role; }
+ public Instant getJoinedAt() { return joinedAt; }
+}
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index c7b56af..3e1d1f9 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -10,7 +10,7 @@ spring:
jpa:
open-in-view: false
hibernate:
- ddl-auto: validate
+ ddl-auto: none
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
diff --git a/backend/src/test/java/com/recipeapp/AbstractIntegrationTest.java b/backend/src/test/java/com/recipeapp/AbstractIntegrationTest.java
index 04553b4..ca427a7 100644
--- a/backend/src/test/java/com/recipeapp/AbstractIntegrationTest.java
+++ b/backend/src/test/java/com/recipeapp/AbstractIntegrationTest.java
@@ -5,20 +5,20 @@ 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")
+ static final PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("mealprep")
.withUsername("mealprep")
.withPassword("mealprep");
+ static {
+ postgres.start();
+ }
+
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
diff --git a/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java b/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java
new file mode 100644
index 0000000..d42f40e
--- /dev/null
+++ b/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java
@@ -0,0 +1,136 @@
+package com.recipeapp.auth;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.recipeapp.auth.dto.*;
+import com.recipeapp.common.GlobalExceptionHandler;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.UUID;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@ExtendWith(MockitoExtension.class)
+class AuthControllerTest {
+
+ private MockMvc mockMvc;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Mock
+ private AuthService authService;
+
+ @InjectMocks
+ private AuthController authController;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.standaloneSetup(authController)
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .build();
+ }
+
+ @Test
+ void signupShouldReturn201WithUserResponse() throws Exception {
+ var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
+ var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
+
+ when(authService.signup(any(SignupRequest.class))).thenReturn(response);
+
+ mockMvc.perform(post("/v1/auth/signup")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.status").value("success"))
+ .andExpect(jsonPath("$.data.email").value("sarah@example.com"))
+ .andExpect(jsonPath("$.data.displayName").value("Sarah"));
+ }
+
+ @Test
+ void signupShouldReturn400WhenEmailInvalid() throws Exception {
+ var request = new SignupRequest("not-an-email", "s3cure!Pass", "Sarah");
+
+ mockMvc.perform(post("/v1/auth/signup")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void signupShouldReturn400WhenPasswordTooShort() throws Exception {
+ var request = new SignupRequest("sarah@example.com", "short", "Sarah");
+
+ mockMvc.perform(post("/v1/auth/signup")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void loginShouldReturn200WithUserResponse() throws Exception {
+ var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
+ var response = UserResponse.withHousehold(
+ UUID.randomUUID(), "sarah@example.com", "Sarah",
+ UUID.randomUUID(), "Smith family", "planner", "user");
+
+ when(authService.login(any(LoginRequest.class))).thenReturn(response);
+
+ mockMvc.perform(post("/v1/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value("success"))
+ .andExpect(jsonPath("$.data.email").value("sarah@example.com"))
+ .andExpect(jsonPath("$.data.householdRole").value("planner"))
+ .andExpect(jsonPath("$.data.systemRole").value("user"));
+ }
+
+ @Test
+ void logoutShouldReturn204() throws Exception {
+ mockMvc.perform(post("/v1/auth/logout"))
+ .andExpect(status().isNoContent());
+ }
+
+ @Test
+ void getMeShouldReturn200WithUserResponse() throws Exception {
+ var response = UserResponse.withHousehold(
+ UUID.randomUUID(), "sarah@example.com", "Sarah",
+ UUID.randomUUID(), "Smith family", "planner", "user");
+
+ when(authService.getCurrentUser("sarah@example.com")).thenReturn(response);
+
+ mockMvc.perform(get("/v1/auth/me")
+ .principal(() -> "sarah@example.com"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value("success"))
+ .andExpect(jsonPath("$.data.email").value("sarah@example.com"))
+ .andExpect(jsonPath("$.data.householdName").value("Smith family"));
+ }
+
+ @Test
+ void updateProfileShouldReturn200() throws Exception {
+ var request = new UpdateProfileRequest("Sarah S.", null, null);
+ var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah S.");
+
+ when(authService.updateProfile(eq("sarah@example.com"), any(UpdateProfileRequest.class)))
+ .thenReturn(response);
+
+ mockMvc.perform(patch("/v1/auth/me")
+ .principal(() -> "sarah@example.com")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value("success"))
+ .andExpect(jsonPath("$.data.displayName").value("Sarah S."));
+ }
+}
diff --git a/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java b/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java
new file mode 100644
index 0000000..c6d3426
--- /dev/null
+++ b/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java
@@ -0,0 +1,183 @@
+package com.recipeapp.auth;
+
+import com.recipeapp.auth.dto.*;
+import com.recipeapp.auth.entity.UserAccount;
+import com.recipeapp.common.ConflictException;
+import com.recipeapp.common.ResourceNotFoundException;
+import com.recipeapp.common.ValidationException;
+import com.recipeapp.household.HouseholdMemberRepository;
+import com.recipeapp.household.entity.Household;
+import com.recipeapp.household.entity.HouseholdMember;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class AuthServiceTest {
+
+ @Mock
+ private UserAccountRepository userAccountRepository;
+
+ @Mock
+ private HouseholdMemberRepository householdMemberRepository;
+
+ @Mock
+ private PasswordEncoder passwordEncoder;
+
+ @InjectMocks
+ private AuthServiceImpl authService;
+
+ @Test
+ void signupShouldCreateUserAndReturnResponse() {
+ var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
+
+ when(userAccountRepository.existsByEmailIgnoreCase("sarah@example.com")).thenReturn(false);
+ when(passwordEncoder.encode("s3cure!Pass")).thenReturn("hashed");
+ when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> {
+ UserAccount saved = invocation.getArgument(0);
+ return saved;
+ });
+
+ UserResponse result = authService.signup(request);
+
+ assertThat(result.email()).isEqualTo("sarah@example.com");
+ assertThat(result.displayName()).isEqualTo("Sarah");
+ verify(userAccountRepository).save(any(UserAccount.class));
+ }
+
+ @Test
+ void signupShouldThrowConflictWhenEmailExists() {
+ var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
+
+ when(userAccountRepository.existsByEmailIgnoreCase("sarah@example.com")).thenReturn(true);
+
+ assertThatThrownBy(() -> authService.signup(request))
+ .isInstanceOf(ConflictException.class);
+ }
+
+ @Test
+ void loginShouldReturnUserWhenCredentialsValid() {
+ var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
+ var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
+
+ when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
+ when(passwordEncoder.matches("s3cure!Pass", "hashed")).thenReturn(true);
+ when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.empty());
+
+ UserResponse result = authService.login(request);
+
+ assertThat(result.email()).isEqualTo("sarah@example.com");
+ assertThat(result.displayName()).isEqualTo("Sarah");
+ }
+
+ @Test
+ void loginShouldThrowWhenEmailNotFound() {
+ var request = new LoginRequest("unknown@example.com", "password");
+
+ when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> authService.login(request))
+ .isInstanceOf(ResourceNotFoundException.class);
+ }
+
+ @Test
+ void loginShouldThrowWhenPasswordInvalid() {
+ var request = new LoginRequest("sarah@example.com", "wrongpass");
+ var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
+
+ when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
+ when(passwordEncoder.matches("wrongpass", "hashed")).thenReturn(false);
+
+ assertThatThrownBy(() -> authService.login(request))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void loginShouldThrowWhenAccountInactive() {
+ var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
+ var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
+ user.setActive(false);
+
+ when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
+
+ assertThatThrownBy(() -> authService.login(request))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void getCurrentUserShouldReturnUserWithHouseholdInfo() {
+ var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
+ var household = new Household("Smith family", user);
+ var member = new HouseholdMember(household, user, "planner");
+
+ when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
+ when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
+
+ UserResponse result = authService.getCurrentUser("sarah@example.com");
+
+ assertThat(result.email()).isEqualTo("sarah@example.com");
+ assertThat(result.householdName()).isEqualTo("Smith family");
+ assertThat(result.householdRole()).isEqualTo("planner");
+ }
+
+ @Test
+ void updateProfileShouldUpdateDisplayName() {
+ var request = new UpdateProfileRequest("Sarah S.", null, null);
+ var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
+
+ when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
+ when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0));
+
+ UserResponse result = authService.updateProfile("sarah@example.com", request);
+
+ assertThat(result.displayName()).isEqualTo("Sarah S.");
+ }
+
+ @Test
+ void updateProfileShouldChangePasswordWhenCurrentPasswordValid() {
+ var request = new UpdateProfileRequest(null, "oldpass", "newpassword");
+ var user = new UserAccount("sarah@example.com", "Sarah", "hashed_old");
+
+ when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
+ when(passwordEncoder.matches("oldpass", "hashed_old")).thenReturn(true);
+ when(passwordEncoder.encode("newpassword")).thenReturn("hashed_new");
+ when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0));
+
+ authService.updateProfile("sarah@example.com", request);
+
+ verify(passwordEncoder).encode("newpassword");
+ }
+
+ @Test
+ void updateProfileShouldThrowWhenCurrentPasswordWrong() {
+ var request = new UpdateProfileRequest(null, "wrongpass", "newpassword");
+ var user = new UserAccount("sarah@example.com", "Sarah", "hashed_old");
+
+ when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
+ when(passwordEncoder.matches("wrongpass", "hashed_old")).thenReturn(false);
+
+ assertThatThrownBy(() -> authService.updateProfile("sarah@example.com", request))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void updateProfileShouldThrowWhenNewPasswordWithoutCurrentPassword() {
+ var request = new UpdateProfileRequest(null, null, "newpassword");
+ var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
+
+ when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
+
+ assertThatThrownBy(() -> authService.updateProfile("sarah@example.com", request))
+ .isInstanceOf(ValidationException.class);
+ }
+}
diff --git a/backend/src/test/java/com/recipeapp/auth/UserAccountRepositoryTest.java b/backend/src/test/java/com/recipeapp/auth/UserAccountRepositoryTest.java
new file mode 100644
index 0000000..9902a2e
--- /dev/null
+++ b/backend/src/test/java/com/recipeapp/auth/UserAccountRepositoryTest.java
@@ -0,0 +1,53 @@
+package com.recipeapp.auth;
+
+import com.recipeapp.AbstractIntegrationTest;
+import com.recipeapp.auth.entity.UserAccount;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import static org.assertj.core.api.Assertions.*;
+
+class UserAccountRepositoryTest extends AbstractIntegrationTest {
+
+ @Autowired
+ private UserAccountRepository userAccountRepository;
+
+ @Test
+ void shouldSaveAndFindByEmail() {
+ var user = new UserAccount("test@example.com", "Test User", "hashed_pw");
+ userAccountRepository.save(user);
+
+ var found = userAccountRepository.findByEmailIgnoreCase("test@example.com");
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getDisplayName()).isEqualTo("Test User");
+ assertThat(found.get().getSystemRole()).isEqualTo("user");
+ assertThat(found.get().isActive()).isTrue();
+ assertThat(found.get().getCreatedAt()).isNotNull();
+ }
+
+ @Test
+ void shouldReturnEmptyWhenEmailNotFound() {
+ var found = userAccountRepository.findByEmailIgnoreCase("nonexistent@example.com");
+ assertThat(found).isEmpty();
+ }
+
+ @Test
+ void existsByEmailIgnoreCaseShouldReturnTrueWhenExists() {
+ var user = new UserAccount("exists@example.com", "Exists", "hashed");
+ userAccountRepository.save(user);
+
+ assertThat(userAccountRepository.existsByEmailIgnoreCase("exists@example.com")).isTrue();
+ assertThat(userAccountRepository.existsByEmailIgnoreCase("nope@example.com")).isFalse();
+ }
+
+ @Test
+ void shouldHandleCaseInsensitiveEmail() {
+ var user = new UserAccount("Sarah@Example.COM", "Sarah", "hashed");
+ userAccountRepository.save(user);
+
+ var found = userAccountRepository.findByEmailIgnoreCase("sarah@example.com");
+ assertThat(found).isPresent();
+ assertThat(found.get().getDisplayName()).isEqualTo("Sarah");
+ }
+}
diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml
index dfab6f2..356b69b 100644
--- a/backend/src/test/resources/application-test.yml
+++ b/backend/src/test/resources/application-test.yml
@@ -1,12 +1,8 @@
spring:
- datasource:
- url: jdbc:tc:postgresql:16-alpine:///mealprep
- username: mealprep
- password: mealprep
jpa:
open-in-view: false
hibernate:
- ddl-auto: validate
+ ddl-auto: none
flyway:
enabled: true
locations: classpath:db/migration