From 391224c961fe01b86814185d1721d801a79473db Mon Sep 17 00:00:00 2001 From: JIMIN JUNG Date: Fri, 6 Nov 2026 02:50:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 30 ++++++ .../user/controller/UserSuccessCode.java | 20 ++++ .../controller/dto/UserDetailResponse.java | 25 +++++ .../domain/user/service/UserQueryService.java | 10 ++ .../service/dto/result/UserDetailResult.java | 18 ++++ .../user/controller/UserControllerTest.java | 101 ++++++++++++++++++ .../com/sniptext/fixture/UserFixture.java | 4 + 7 files changed, 208 insertions(+) create mode 100644 src/main/java/com/sniptext/domain/user/controller/UserController.java create mode 100644 src/main/java/com/sniptext/domain/user/controller/UserSuccessCode.java create mode 100644 src/main/java/com/sniptext/domain/user/controller/dto/UserDetailResponse.java create mode 100644 src/main/java/com/sniptext/domain/user/service/dto/result/UserDetailResult.java create mode 100644 src/test/java/com/sniptext/domain/user/controller/UserControllerTest.java diff --git a/src/main/java/com/sniptext/domain/user/controller/UserController.java b/src/main/java/com/sniptext/domain/user/controller/UserController.java new file mode 100644 index 0000000..9771d24 --- /dev/null +++ b/src/main/java/com/sniptext/domain/user/controller/UserController.java @@ -0,0 +1,30 @@ +package com.sniptext.domain.user.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sniptext.domain.user.controller.dto.UserDetailResponse; +import com.sniptext.domain.user.service.UserQueryService; +import com.sniptext.global.response.ApiResponse; +import com.sniptext.global.security.LoginUser; + +import lombok.RequiredArgsConstructor; + +/** + * 회원 (로그인 필요, 캐시 금지). + */ +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController { + + private final UserQueryService userQueryService; + + @GetMapping("/me") + public ResponseEntity> getMe(@LoginUser Long userId) { + return ApiResponse.success(UserSuccessCode.USER_FOUND, UserDetailResponse.from(userQueryService.getDetail(userId))); + } + +} diff --git a/src/main/java/com/sniptext/domain/user/controller/UserSuccessCode.java b/src/main/java/com/sniptext/domain/user/controller/UserSuccessCode.java new file mode 100644 index 0000000..e7dd120 --- /dev/null +++ b/src/main/java/com/sniptext/domain/user/controller/UserSuccessCode.java @@ -0,0 +1,20 @@ +package com.sniptext.domain.user.controller; + +import org.springframework.http.HttpStatus; + +import com.sniptext.global.response.ResultCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserSuccessCode implements ResultCode { + + USER_FOUND(HttpStatus.OK, "USER200_1", "내 정보 조회에 성공했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/sniptext/domain/user/controller/dto/UserDetailResponse.java b/src/main/java/com/sniptext/domain/user/controller/dto/UserDetailResponse.java new file mode 100644 index 0000000..a1c851e --- /dev/null +++ b/src/main/java/com/sniptext/domain/user/controller/dto/UserDetailResponse.java @@ -0,0 +1,25 @@ +package com.sniptext.domain.user.controller.dto; + +import com.sniptext.domain.auth.entity.SocialProvider; +import com.sniptext.domain.user.service.dto.result.UserDetailResult; + +/** 내 정보 (명세 5번). */ +public record UserDetailResponse( + Long userId, + String nickname, + String email, + String profileImageUrl, + SocialProvider loginType +) { + + public static UserDetailResponse from(UserDetailResult result) { + return new UserDetailResponse( + result.userId(), + result.nickname(), + result.email(), + result.profileImageUrl(), + result.loginType() + ); + } + +} diff --git a/src/main/java/com/sniptext/domain/user/service/UserQueryService.java b/src/main/java/com/sniptext/domain/user/service/UserQueryService.java index 4c8855b..d7b4286 100644 --- a/src/main/java/com/sniptext/domain/user/service/UserQueryService.java +++ b/src/main/java/com/sniptext/domain/user/service/UserQueryService.java @@ -3,7 +3,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.sniptext.domain.user.exception.UserErrorCode; import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.domain.user.service.dto.result.UserDetailResult; +import com.sniptext.global.exception.BusinessException; import lombok.RequiredArgsConstructor; @@ -15,4 +18,11 @@ public class UserQueryService { private final UserRepository userRepository; + /** 내 정보. 없거나 탈퇴한 회원이면 USER404_1. */ + public UserDetailResult getDetail(Long userId) { + return userRepository.findByIdAndDeletedAtIsNull(userId) + .map(UserDetailResult::from) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + } + } diff --git a/src/main/java/com/sniptext/domain/user/service/dto/result/UserDetailResult.java b/src/main/java/com/sniptext/domain/user/service/dto/result/UserDetailResult.java new file mode 100644 index 0000000..6b356ca --- /dev/null +++ b/src/main/java/com/sniptext/domain/user/service/dto/result/UserDetailResult.java @@ -0,0 +1,18 @@ +package com.sniptext.domain.user.service.dto.result; + +import com.sniptext.domain.auth.entity.SocialProvider; +import com.sniptext.domain.user.entity.User; + +/** + * 내 정보. 프로필 이미지가 없으면 profileImageUrl은 null이다. + * S1 로그인은 구글뿐이라 loginType은 GOOGLE로 고정한다. 제공자가 늘면 소셜 계정에서 읽도록 바꾼다. + */ +public record UserDetailResult(Long userId, String nickname, String email, String profileImageUrl, + SocialProvider loginType) { + + public static UserDetailResult from(User user) { + return new UserDetailResult(user.getId(), user.getNickname(), user.getEmail(), user.getProfileImageUrl(), + SocialProvider.GOOGLE); + } + +} diff --git a/src/test/java/com/sniptext/domain/user/controller/UserControllerTest.java b/src/test/java/com/sniptext/domain/user/controller/UserControllerTest.java new file mode 100644 index 0000000..eccbe5e --- /dev/null +++ b/src/test/java/com/sniptext/domain/user/controller/UserControllerTest.java @@ -0,0 +1,101 @@ +package com.sniptext.domain.user.controller; + +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; + +/** 명세 5번. 실제 토큰 필터와 MySQL까지 거쳐 확인한다. */ +@Transactional +class UserControllerTest extends IntegrationTestSupport { + + private static final String PATH = "/api/v1/users/me"; + private static final String EMAIL = "me@gmail.com"; + + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("내 정보를 명세의 필드로 내려주고 캐시하지 않는다") + void getMe() throws Exception { + Long userId = userRepository.save(UserFixture.user(EMAIL)).getId(); + + mockMvc.perform(get(PATH).header(HttpHeaders.AUTHORIZATION, bearer(userId))) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("USER200_1")) + .andExpect(jsonPath("$.message").value("내 정보 조회에 성공했습니다.")) + .andExpect(jsonPath("$.result.userId").value(userId)) + .andExpect(jsonPath("$.result.nickname").value(UserFixture.NICKNAME)) + .andExpect(jsonPath("$.result.email").value(EMAIL)) + .andExpect(jsonPath("$.result.profileImageUrl").value(UserFixture.PROFILE_IMAGE_URL)) + .andExpect(jsonPath("$.result.loginType").value("GOOGLE")); + } + + @Test + @DisplayName("프로필 이미지가 없으면 profileImageUrl은 null이다") + void withoutProfileImage() throws Exception { + Long userId = userRepository.save(UserFixture.userWithoutProfileImage(EMAIL)).getId(); + + mockMvc.perform(get(PATH).header(HttpHeaders.AUTHORIZATION, bearer(userId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.profileImageUrl").value(nullValue())); + } + + @Test + @DisplayName("?userId로 다른 회원을 지정해도 토큰의 회원 정보만 내려준다") + void ignoresUserIdParameter() throws Exception { + Long me = userRepository.save(UserFixture.user(EMAIL)).getId(); + Long other = userRepository.save(UserFixture.user("other@gmail.com")).getId(); + + mockMvc.perform(get(PATH).param("userId", String.valueOf(other)) + .header(HttpHeaders.AUTHORIZATION, bearer(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.userId").value(me)) + .andExpect(jsonPath("$.result.email").value(EMAIL)); + } + + @Test + @DisplayName("탈퇴한 회원의 토큰은 USER404_1이다") + void deletedUser() throws Exception { + Long userId = userRepository.save(UserFixture.deletedUser(EMAIL, LocalDateTime.of(2026, 11, 1, 10, 0))).getId(); + + mockMvc.perform(get(PATH).header(HttpHeaders.AUTHORIZATION, bearer(userId))) + .andExpect(status().isNotFound()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.code").value("USER404_1")) + .andExpect(jsonPath("$.message").value("회원 정보를 찾을 수 없어요.")) + .andExpect(jsonPath("$.result").value(nullValue())); + } + + @Test + @DisplayName("없는 회원의 토큰은 USER404_1이다") + void unknownUser() throws Exception { + mockMvc.perform(get(PATH).header(HttpHeaders.AUTHORIZATION, bearer(Long.MAX_VALUE))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER404_1")); + } + + @Test + @DisplayName("토큰이 없으면 COMMON401_1이다") + void withoutToken() throws Exception { + mockMvc.perform(get(PATH)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("COMMON401_1")); + } + +} diff --git a/src/test/java/com/sniptext/fixture/UserFixture.java b/src/test/java/com/sniptext/fixture/UserFixture.java index 164a308..f1cfdc3 100644 --- a/src/test/java/com/sniptext/fixture/UserFixture.java +++ b/src/test/java/com/sniptext/fixture/UserFixture.java @@ -27,6 +27,10 @@ public static User user(String email) { return User.create(email, NICKNAME, PROFILE_IMAGE_URL); } + public static User userWithoutProfileImage(String email) { + return User.create(email, NICKNAME, null); + } + public static User deletedUser(String email, LocalDateTime deletedAt) { User user = user(email); ReflectionTestUtils.setField(user, "deletedAt", deletedAt);