From fd29a9fd78187f84020cf9c897ab8915c8263bc1 Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 29 Dec 2025 13:47:31 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=8D=94?= =?UTF-8?q?=EB=AF=B8=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-test.yml | 50 +++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 82fc749d..807e238d 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -1,13 +1,57 @@ spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true data: redis: host: localhost port: 6379 +spotify: + client-id: test-id + client-secret: test-secret + +jwt: + secret: c2VjcmV0LWtleS1mb3ItdGVzdGluZy1wdXJwb3Nlcy1vbmx5LW5vdC1mb3ItcHJvZHVjdGlvbg== + access-token-expiration: 3600 + refresh-token-expiration: 3600 + +mailgun: + api-key: test + domain: test + from: test@test.com + +tmap: + api-key: test +kakao: + restapi-key: test +kopis: + api-key: test + +oauth: + kakao: + client-id: test + client-secret: test + redirect-uri: test + google: + client-id: test + client-secret: test + redirect-uri: test + cloud: aws: s3: bucket: test-bucket - Credentials: - accessKey: test - secretKey: test \ No newline at end of file + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + stack: + auto: false \ No newline at end of file From 512c3d5e83689fc3120a655c568df8b906983299 Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 29 Dec 2025 13:48:41 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EC=9C=A0=EC=A0=80=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EB=84=98=EB=B2=84=EB=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/error/code/UserErrorCode.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java index c5fb3878..2255dd80 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java @@ -8,7 +8,7 @@ @AllArgsConstructor public enum UserErrorCode implements ErrorCode { - // 1xx - User 상태 / 중복 + // 10x - User 상태 / 중복 NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "U-100", "이미 사용 중인 닉네임입니다."), USER_DELETED(HttpStatus.FORBIDDEN, "U-101", "탈퇴한 사용자입니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-102", "사용자를 찾을 수 없습니다."), @@ -16,8 +16,8 @@ public enum UserErrorCode implements ErrorCode { USER_NOT_DELETED(HttpStatus.BAD_REQUEST, "U-104", "탈퇴 상태의 계정만 복구할 수 있습니다."), INVALID_RESTORE_TOKEN(HttpStatus.BAD_REQUEST, "U-105", "유효하지 않거나 만료된 복구 링크입니다."), - // 2xx - 인증 / 비밀번호 - INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-120", "현재 비밀번호가 일치하지 않습니다."); + // 11x - 비밀번호 + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-110", "현재 비밀번호가 일치하지 않습니다."); private final HttpStatus status; private final String code; From 0b1875d755e75d91818c2853e148cbe5dc5cdfb0 Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 29 Dec 2025 13:48:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20DTO=EC=97=90=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/users/dto/request/UserUpdateNicknameRequest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateNicknameRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateNicknameRequest.java index 140b98e7..497db40c 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateNicknameRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateNicknameRequest.java @@ -2,9 +2,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor +@AllArgsConstructor public class UserUpdateNicknameRequest { @NotBlank(message = "닉네임은 필수입니다.") From 7720a634336dbd6aca385a11147d1051dc3728ea Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 29 Dec 2025 13:49:29 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EC=9C=A0=EC=A0=80=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/controller/UserControllerTest.java | 231 ++++++++++++++++++ .../domain/users/controller/dummy.txt | 5 - .../domain/users/service/UserServiceTest.java | 182 ++++++++++++++ .../domain/users/service/dummy.txt | 5 - 4 files changed, 413 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/back/web7_9_codecrete_be/domain/users/controller/UserControllerTest.java delete mode 100644 src/test/java/com/back/web7_9_codecrete_be/domain/users/controller/dummy.txt create mode 100644 src/test/java/com/back/web7_9_codecrete_be/domain/users/service/UserServiceTest.java delete mode 100644 src/test/java/com/back/web7_9_codecrete_be/domain/users/service/dummy.txt diff --git a/src/test/java/com/back/web7_9_codecrete_be/domain/users/controller/UserControllerTest.java b/src/test/java/com/back/web7_9_codecrete_be/domain/users/controller/UserControllerTest.java new file mode 100644 index 00000000..74c88073 --- /dev/null +++ b/src/test/java/com/back/web7_9_codecrete_be/domain/users/controller/UserControllerTest.java @@ -0,0 +1,231 @@ +package com.back.web7_9_codecrete_be.domain.users.controller; + +import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; +import com.back.web7_9_codecrete_be.domain.users.dto.response.UserResponse; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.service.UserService; +import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode; +import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; +import com.back.web7_9_codecrete_be.global.rq.Rq; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private UserService userService; + + @MockBean + private TokenService tokenService; + + @MockBean + private Rq rq; + + private User mockUser; + private UserResponse mockUserResponse; + + @BeforeEach + void setUp() { + mockUser = User.builder() + .email("test@example.com") + .nickname("testUser") + .build(); + + mockUserResponse = UserResponse.builder() + .email("test@example.com") + .nickname("testUser") + .profileImageUrl("https://example.com/profile.jpg") + .build(); + + // Rq가 사용될 때 가짜 유저 반환 설정 + lenient().when(rq.getUser()).thenReturn(mockUser); + } + + // 성공 테스트 시나리오 + + @Test + @WithMockUser + @DisplayName("내 정보 조회 성공") + void getMyInfo_Success() throws Exception { + given(userService.getMyInfo(any())).willReturn(mockUserResponse); + + mockMvc.perform(get("/api/v1/users/me")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("OK")) + .andExpect(jsonPath("$.msg").value("사용자 정보 조회 성공")) + .andExpect(jsonPath("$.data.email").value("test@example.com")); + } + + @Test + @WithMockUser + @DisplayName("닉네임 수정 성공") + void updateNickname_Success() throws Exception { + String jsonRequest = "{\"nickname\": \"newNickname\"}"; + UserResponse updatedResponse = UserResponse.builder().nickname("newNickname").build(); + + given(userService.updateNickname(any(), any())).willReturn(updatedResponse); + + mockMvc.perform(patch("/api/v1/users/nickname") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("OK")) + .andExpect(jsonPath("$.data.nickname").value("newNickname")); + } + + @Test + @WithMockUser + @DisplayName("프로필 이미지 수정 성공") + void updateProfileImage_Success() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "profile.jpg", MediaType.IMAGE_JPEG_VALUE, "content".getBytes()); + + given(userService.updateProfileImage(any(), any())).willReturn("https://new-url.com"); + + mockMvc.perform(multipart("/api/v1/users/profile-image") + .file(file) + .with(csrf()) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.msg").value("프로필 이미지가 변경되었습니다.")); + } + + @Test + @WithMockUser + @DisplayName("비밀번호 변경 성공") + void updatePassword_Success() throws Exception { + + String jsonRequest = """ + + { + + "currentPassword": "oldPassword1!", + + "newPassword": "newPassword1!" + + } + + """; + + mockMvc.perform(patch("/api/v1/users/password") + + .with(csrf()) + + .contentType(MediaType.APPLICATION_JSON) + + .content(jsonRequest)) + + .andExpect(status().isOk()); + + verify(userService).updatePassword(any(), any()); + + } + + @Test + @WithMockUser + @DisplayName("회원 탈퇴 성공") + void deleteMyAccount_Success() throws Exception { + mockMvc.perform(delete("/api/v1/users/me") + .with(csrf())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("OK")) + .andExpect(jsonPath("$.msg").value("정상적으로 처리되었습니다.")) + .andExpect(jsonPath("$.data").value("회원 탈퇴가 완료되었습니다.")); + } + + // 실패 테스트 시나리오 + + @Test + @WithMockUser + @DisplayName("닉네임 수정 실패 - 중복된 닉네임 (U-100)") + void updateNickname_Fail_Duplicated() throws Exception { + doThrow(new BusinessException(UserErrorCode.NICKNAME_DUPLICATED)) + .when(userService).updateNickname(any(), any()); + + String jsonRequest = "{\"nickname\": \"existingNick\"}"; + + mockMvc.perform(patch("/api/v1/users/nickname") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.resultCode").value("U-100")) + .andExpect(jsonPath("$.msg").value("이미 사용 중인 닉네임입니다.")); + } + + @Test + @WithMockUser + @DisplayName("비밀번호 변경 실패 - 현재 비밀번호 불일치 (U-110)") + void updatePassword_Fail_InvalidPassword() throws Exception { + String jsonRequest = """ + { + "currentPassword": "wrongPassword1!", + "newPassword": "newPassword123!" + } + """; + + doThrow(new BusinessException(UserErrorCode.INVALID_PASSWORD)) + .when(userService).updatePassword(any(), any()); + + mockMvc.perform(patch("/api/v1/users/password") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.resultCode").value("U-110")); + } + + @Test + @WithMockUser + @DisplayName("비밀번호 변경 실패 - 유효성 검사 에러 (VALIDATION_ERROR)") + void updatePassword_Fail_ValidationError() throws Exception { + String jsonRequest = "{\"currentPassword\": \"short\", \"newPassword\": \"short\"}"; + + mockMvc.perform(patch("/api/v1/users/password") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.resultCode").value("VALIDATION_ERROR")); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/web7_9_codecrete_be/domain/users/controller/dummy.txt b/src/test/java/com/back/web7_9_codecrete_be/domain/users/controller/dummy.txt deleted file mode 100644 index bbe54a13..00000000 --- a/src/test/java/com/back/web7_9_codecrete_be/domain/users/controller/dummy.txt +++ /dev/null @@ -1,5 +0,0 @@ -controller -dto -entity -repository -service \ No newline at end of file diff --git a/src/test/java/com/back/web7_9_codecrete_be/domain/users/service/UserServiceTest.java b/src/test/java/com/back/web7_9_codecrete_be/domain/users/service/UserServiceTest.java new file mode 100644 index 00000000..5190372a --- /dev/null +++ b/src/test/java/com/back/web7_9_codecrete_be/domain/users/service/UserServiceTest.java @@ -0,0 +1,182 @@ +package com.back.web7_9_codecrete_be.domain.users.service; + +import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; +import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdateNicknameRequest; +import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdatePasswordRequest; +import com.back.web7_9_codecrete_be.domain.users.dto.response.UserResponse; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; +import com.back.web7_9_codecrete_be.global.error.code.FileErrorCode; +import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode; +import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; +import com.back.web7_9_codecrete_be.global.storage.FileStorageService; +import com.back.web7_9_codecrete_be.global.storage.ImageFileValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @InjectMocks + private UserService userService; + + @Mock private UserRepository userRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private TokenService tokenService; + @Mock private FileStorageService fileStorageService; + @Mock private ImageFileValidator imageFileValidator; + + private User user; + + // 테스트 유저 주입 + @BeforeEach + void setUp() { + user = User.builder() + .email("test@example.com") + .nickname("oldNick") + .password("encodedPassword") + .build(); + } + + @Test + @DisplayName("내 정보 조회 성공") + void getMyInfo_success() { + UserResponse response = userService.getMyInfo(user); + + assertThat(response.getEmail()).isEqualTo("test@example.com"); + assertThat(response.getNickname()).isEqualTo("oldNick"); + } + + @Test + @DisplayName("내 정보 조회 실패 - 삭제된 유저") + void getMyInfo_fail_deletedUser() { + user.softDelete(); + + assertThatThrownBy(() -> + userService.getMyInfo(user)) + .isInstanceOf(BusinessException.class) + .hasMessage(UserErrorCode.USER_DELETED.getMessage()); + } + + @Test + @DisplayName("닉네임 수정 성공") + void updateNickname_success() { + UserUpdateNicknameRequest req = + new UserUpdateNicknameRequest("newNick"); + + given(userRepository.existsByNickname("newNick")) + .willReturn(false); + + UserResponse response = userService.updateNickname(user, req); + + assertThat(response.getNickname()).isEqualTo("newNick"); + verify(userRepository).save(user); + } + + @Test + @DisplayName("닉네임 수정 실패 - 중복 닉네임") + void updateNickname_fail_duplicated() { + UserUpdateNicknameRequest req = + new UserUpdateNicknameRequest("dupNick"); + + given(userRepository.existsByNickname("dupNick")) + .willReturn(true); + + assertThatThrownBy(() -> + userService.updateNickname(user, req)) + .isInstanceOf(BusinessException.class) + .hasMessage(UserErrorCode.NICKNAME_DUPLICATED.getMessage()); + } + + @Test + @DisplayName("비밀번호 변경 성공") + void updatePassword_success() { + UserUpdatePasswordRequest req = new UserUpdatePasswordRequest(); + ReflectionTestUtils.setField(req, "currentPassword", "OldPassword1!"); + ReflectionTestUtils.setField(req, "newPassword", "NewPassword1!"); + + given(passwordEncoder.matches("OldPassword1!", "encodedPassword")) + .willReturn(true); + given(passwordEncoder.encode("NewPassword1!")) + .willReturn("newEncodedPw"); + + userService.updatePassword(user, req); + + assertThat(user.getPassword()).isEqualTo("newEncodedPw"); + verify(userRepository).save(user); + verify(tokenService).removeTokens(user); + } + + @Test + @DisplayName("비밀번호 변경 실패 - 현재 비밀번호 불일치") + void updatePassword_fail_invalidPassword() { + UserUpdatePasswordRequest req = new UserUpdatePasswordRequest(); + ReflectionTestUtils.setField(req, "currentPassword", "WrongPassword1!"); + ReflectionTestUtils.setField(req, "newPassword", "NewPassword1!"); + + given(passwordEncoder.matches(any(), any())) + .willReturn(false); + + assertThatThrownBy(() -> + userService.updatePassword(user, req)) + .isInstanceOf(BusinessException.class) + .hasMessage(UserErrorCode.INVALID_PASSWORD.getMessage()); + + verify(userRepository, never()).save(any()); + verify(tokenService, never()).removeTokens(any()); + } + + @Test + @DisplayName("회원 탈퇴 성공") + void deleteMyAccount_success() { + userService.deleteMyAccount(user); + + assertThat(user.getIsDeleted()).isTrue(); + verify(userRepository).save(user); + verify(tokenService).removeTokens(user); + } + + @Test + @DisplayName("프로필 이미지 수정 성공") + void updateProfileImage_success() { + MultipartFile file = mock(MultipartFile.class); + + given(fileStorageService.upload(file, "users/profile")) + .willReturn("new-image-url"); + + String result = userService.updateProfileImage(user, file); + + assertThat(result).isEqualTo("new-image-url"); + assertThat(user.getProfileImage()).isEqualTo("new-image-url"); + verify(userRepository).save(user); + } + + @Test + @DisplayName("프로필 이미지 수정 실패 - 이미지 검증 실패") + void updateProfileImage_fail_invalidImage() { + MultipartFile file = mock(MultipartFile.class); + + doThrow(new BusinessException(FileErrorCode.INVALID_IMAGE_TYPE)) + .when(imageFileValidator) + .validateImageFile(file); + + assertThatThrownBy(() -> + userService.updateProfileImage(user, file)) + .isInstanceOf(BusinessException.class) + .hasMessage(FileErrorCode.INVALID_IMAGE_TYPE.getMessage()); + + verify(fileStorageService, never()).upload(any(), any()); + } +} diff --git a/src/test/java/com/back/web7_9_codecrete_be/domain/users/service/dummy.txt b/src/test/java/com/back/web7_9_codecrete_be/domain/users/service/dummy.txt deleted file mode 100644 index bbe54a13..00000000 --- a/src/test/java/com/back/web7_9_codecrete_be/domain/users/service/dummy.txt +++ /dev/null @@ -1,5 +0,0 @@ -controller -dto -entity -repository -service \ No newline at end of file