Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<UserDetailResponse>> getMe(@LoginUser Long userId) {
return ApiResponse.success(UserSuccessCode.USER_FOUND, UserDetailResponse.from(userQueryService.getDetail(userId)));
}

}
Original file line number Diff line number Diff line change
@@ -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;

}
Original file line number Diff line number Diff line change
@@ -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()
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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));
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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"));
}

}
4 changes: 4 additions & 0 deletions src/test/java/com/sniptext/fixture/UserFixture.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading