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