Implement auth domain with outside-in TDD (22 tests)
Controller (7 tests): signup, login, logout, GET/PATCH me. Standalone MockMvc setup (Boot 4 removed @WebMvcTest). Service (11 tests): signup with conflict check, login with password/active validation, getCurrentUser with household info, updateProfile with password change flow. Repository (4 tests): save/find, case-insensitive email via IgnoreCase queries (citext + Hibernate needs explicit IgnoreCase), existsByEmail. Also includes: - SecurityConfig: session auth, CSRF, role-based authorization - CustomUserDetailsService: loads UserAccount for Spring Security - UserAccount, Household, HouseholdMember JPA entities - spring-boot-flyway dependency (Boot 4 requires explicit module) - ddl-auto=none (Flyway owns schema, validate fails on citext) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
136
backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java
Normal file
136
backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java
Normal file
@@ -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."));
|
||||
}
|
||||
}
|
||||
183
backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java
Normal file
183
backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user