Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/dev-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ jobs:
run: chmod +x gradlew

- name: Build with Gradle # 실제 application build
run: ./gradlew build -PactiveProfiles=local -x test
run: ./gradlew build -PactiveProfiles=local
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public enum ErrorStatus {
INVALID_HEALTH_DATA_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 건강 데이터입니다"),
INVALID_DATE_RANGE_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 날짜 범위입니다"),
EXCEED_HEART_RATE_SAMPLES_EXCEPTION(HttpStatus.BAD_REQUEST, "심박수 샘플은 최대 5000건까지 허용됩니다"),
VALIDATION_DEPARTURE_ADDRESS_EXCEPTION(HttpStatus.BAD_REQUEST, "출발지 주소 형식이 올바르지 않습니다."),

/**
* 401 UNAUTHORIZED
Expand All @@ -53,7 +54,9 @@ public enum ErrorStatus {
* 403 FORBIDDEN
*/
PERMISSION_DENIED_PUBLIC_COURSE_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "퍼블릭 코스를 삭제할 권한이 존재하지 않습니다."),
PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION(HttpStatus.FORBIDDEN, "퍼블릭 코스를 수정할 권한이 존재하지 않습니다."),
PERMISSION_DENIED_RECORD_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 삭제할 권한이 존재하지 않습니다."),
PERMISSION_DENIED_RECORD_UPDATE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 수정할 권한이 존재하지 않습니다."),
PERMISSION_DENIED_HEALTH_DATA_EXCEPTION(HttpStatus.FORBIDDEN, "건강 데이터에 대한 접근 권한이 없습니다"),

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package org.runnect.server.common.module.convert;

import org.runnect.server.common.constant.ErrorStatus;
import org.runnect.server.common.dto.DepartureResponse;
import org.runnect.server.common.exception.BadRequestException;

import java.util.Arrays;

public class DepartureConverter {
public static DepartureResponse requestConvertDeparture(String departureAddress, String departureName) {
String[] departures = departureAddress.split(" ");
if (departures.length < 3) {
return null;
throw new BadRequestException(ErrorStatus.VALIDATION_DEPARTURE_ADDRESS_EXCEPTION,
ErrorStatus.VALIDATION_DEPARTURE_ADDRESS_EXCEPTION.getMessage());
} else if (departures.length == 3) {
return new DepartureResponse(
departures[0],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ public GetCourseDetailResponseDto getCourseDetail(Long courseId, Long userId) {

@Transactional
public UpdateCourseResponseDto updateCourse(Long userId, Long courseId, String title) {
Course course = courseRepository.findById(courseId)
Course course = courseRepository.findByCourseIdAndUserId(courseId, userId)
.orElseThrow(()->new NotFoundException(NOT_FOUND_COURSE_EXCEPTION, NOT_FOUND_COURSE_EXCEPTION.getMessage()));

course.updateCourse(title);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,23 @@ public void updatePublicCourse(String title, String description) {
public void updateDeletedAt() {
throw new RuntimeException("Course를 제외한 테이블은 정상적으로 삭제됩니다.");
}

// RunnectUser와 동일한 이유(참조 동일성 의존 방지)로 id 기준 equals/hashCode를 둔다.
// scrap 목록과 publicCourse 목록을 서로 다른 쿼리로 가져와 비교하는 곳(isScrap 매칭)이 많아서 영향이 크다.
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof PublicCourse)) {
return false;
}
PublicCourse that = (PublicCourse) o;
return id != null && id.equals(that.id);
}

@Override
public int hashCode() {
return getClass().hashCode();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import lombok.RequiredArgsConstructor;
import org.runnect.server.common.constant.ErrorStatus;
import org.runnect.server.common.constant.SortStatus;
import org.runnect.server.common.exception.BadRequestException;
import org.runnect.server.common.exception.ConflictException;
import org.runnect.server.common.exception.NotFoundException;
import org.runnect.server.common.exception.PermissionDeniedException;
Expand Down Expand Up @@ -182,6 +183,9 @@ public RecommendPublicCourseResponseDto recommendPublicCourse(Long userId, Integ
publicCourses = publicCourseRepository.findAll(
PageRequest.of(pageNo - 1, PAGE_SIZE,
Sort.by(Sort.Direction.DESC, SortStatus.DATE_DESC.getProperty())));
} else {
throw new BadRequestException(ErrorStatus.INVALID_SORT_PARAMETER_EXCEPTION,
ErrorStatus.INVALID_SORT_PARAMETER_EXCEPTION.getMessage());
}

publicCourses.forEach(publicCourse -> {
Expand Down Expand Up @@ -264,8 +268,8 @@ public GetPublicCourseDetailResponseDto getPublicCourseDetail(final Long userId,
Course course = publicCourse.getCourse();

//2. 이미 삭제된 코스인지
if (course.getDeletedAt() == null) {
new NotFoundException(ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION,
if (course.getDeletedAt() != null) {
throw new NotFoundException(ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION,
ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION.getMessage());
}

Expand Down Expand Up @@ -401,6 +405,12 @@ public UpdatePublicCourseResponseDto updatePublicCourse(Long userId, Long public
PublicCourse publicCourse = publicCourseRepository.findById(publicCourseId)
.orElseThrow(() -> new NotFoundException(ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION, ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION.getMessage()));

boolean isAdmin = userId.equals(ADMIN_USER_ID);
if (!isAdmin && !publicCourse.getCourse().getRunnectUser().getId().equals(userId)) {
throw new PermissionDeniedException(ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION,
ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION.getMessage());
}

publicCourse.updatePublicCourse(title, description);

return UpdatePublicCourseResponseDto.of(publicCourse);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ public UpdateRecordResponseDto updateRecord(Long userId, Long recordId, UpdateRe
Record record = recordRepository.findById(recordId)
.orElseThrow(()->new NotFoundException(ErrorStatus.NOT_FOUND_RECORD_EXCEPTION, ErrorStatus.NOT_FOUND_RECORD_EXCEPTION.getMessage()));

if (!record.getRunnectUser().getId().equals(userId)) {
throw new PermissionDeniedException(ErrorStatus.PERMISSION_DENIED_RECORD_UPDATE_EXCEPTION,
ErrorStatus.PERMISSION_DENIED_RECORD_UPDATE_EXCEPTION.getMessage());
}

record.updateRecord(request.getTitle());

return UpdateRecordResponseDto.of(UpdateRecordResponse.of(record.getId(), request.getTitle()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateA
}
}
// 스크랩 삭제
else {
else if (scrap != null) {
// 스크랩한 적이 없는 코스를 취소 요청하면 할 게 없으니 그냥 무시한다 (NPE 방지)
scrap.updateScrapTF(false);
}

Expand Down
20 changes: 20 additions & 0 deletions src/main/java/org/runnect/server/user/entity/RunnectUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,24 @@ public void updateCreatedPublicCourse() {
public void updateDeletedAt() {
throw new RuntimeException("Course를 제외한 테이블은 정상적으로 삭제됩니다.");
}

// 기본 equals(참조 비교)에 의존하면, 같은 유저를 서로 다른 조회 경로로 가져왔을 때
// (예: 로그인 유저 vs 코스에 매핑된 유저) 같은 사람인데도 다르다고 판정될 수 있다.
// id 기준으로 비교하고, hashCode는 영속화 전후로 값이 바뀌지 않도록 상수로 고정한다.
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof RunnectUser)) {
return false;
}
RunnectUser that = (RunnectUser) o;
return id != null && id.equals(that.id);
}

@Override
public int hashCode() {
return getClass().hashCode();
}
}
13 changes: 8 additions & 5 deletions src/main/java/org/runnect/server/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,18 @@ public MyPageResponseDto getMyPage(Long userId) {
public UpdateUserNicknameResponseDto updateUserNickname(
Long userId, UpdateUserNicknameRequestDto updateUserNicknameRequestDto
) {
if (userRepository.existsByNickname(updateUserNicknameRequestDto.getNickname())) {
throw new DuplicateNicknameException(ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION, ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION.getMessage());
}

RunnectUser user = userRepository.findUserByIdWithUserStamps(userId)
.orElseThrow(() -> new NotFoundUserException(ErrorStatus.NOT_FOUND_USER_EXCEPTION,
ErrorStatus.NOT_FOUND_USER_EXCEPTION.getMessage()));

user.updateUserNickname(updateUserNicknameRequestDto.getNickname());
String newNickname = updateUserNicknameRequestDto.getNickname();
// 기존 닉네임과 동일한 값으로 "변경"하는 경우까지 중복으로 처리하면
// 본인의 현재 닉네임을 다시 저장할 수 없게 되므로 그 경우는 제외한다.
if (!user.getNickname().equals(newNickname) && userRepository.existsByNickname(newNickname)) {
throw new DuplicateNicknameException(ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION, ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION.getMessage());
}

user.updateUserNickname(newNickname);

return UpdateUserNicknameResponseDto.of(user, calculateUserLevelPercent(user));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package org.runnect.server.common.resolver.userId;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import javax.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.runnect.server.common.constant.TokenStatus;
import org.runnect.server.config.jwt.JwtService;
import org.runnect.server.user.exception.authException.InvalidAccessTokenException;
import org.runnect.server.user.exception.authException.NullAccessTokenException;
import org.runnect.server.user.exception.authException.TimeExpiredAccessTokenException;
import org.runnect.server.user.exception.userException.NotFoundUserException;
import org.springframework.core.MethodParameter;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.context.request.NativeWebRequest;

class UserIdResolverTest {

private static final Long VISITOR_ID = 0L;

private JwtService jwtService;
private UserIdResolver userIdResolver;
private MethodParameter methodParameter;

@BeforeEach
void setUp() {
jwtService = mock(JwtService.class);
userIdResolver = new UserIdResolver(jwtService);
ReflectionTestUtils.setField(userIdResolver, "VISITOR_ID", VISITOR_ID);
ReflectionTestUtils.invokeMethod(userIdResolver, "setVISITOR_POSSIBLE_URLS", "/api/public-course");
methodParameter = mock(MethodParameter.class);
}

private NativeWebRequest webRequestWith(String accessToken, String refreshToken, String method, String uri) {
HttpServletRequest servletRequest = mock(HttpServletRequest.class);
when(servletRequest.getHeader("accessToken")).thenReturn(accessToken);
when(servletRequest.getHeader("refreshToken")).thenReturn(refreshToken);
when(servletRequest.getMethod()).thenReturn(method);
when(servletRequest.getRequestURI()).thenReturn(uri);

NativeWebRequest webRequest = mock(NativeWebRequest.class);
when(webRequest.getNativeRequest()).thenReturn(servletRequest);
return webRequest;
}

@Test
void accessToken이_없으면_예외를_던진다() {
NativeWebRequest webRequest = webRequestWith(null, "refresh", "GET", "/api/user");

assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null))
.isInstanceOf(NullAccessTokenException.class);
}

@Test
void refreshToken이_없으면_예외를_던진다() {
NativeWebRequest webRequest = webRequestWith("access", null, "GET", "/api/user");

assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null))
.isInstanceOf(NullAccessTokenException.class);
}

@Test
void 방문자_모드_허용_URL이면_VISITOR_ID를_반환한다() {
NativeWebRequest webRequest = webRequestWith("visitor", "visitor", "GET", "/api/public-course/123");

Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null);

assertThat(result).isEqualTo(VISITOR_ID);
}

@Test
void 만료된_토큰이면_예외를_던진다() {
when(jwtService.verifyToken("expired")).thenReturn(TokenStatus.TOKEN_EXPIRED);
NativeWebRequest webRequest = webRequestWith("expired", "refresh", "GET", "/api/user");

assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null))
.isInstanceOf(TimeExpiredAccessTokenException.class);
}

@Test
void 유효하지_않은_토큰이면_예외를_던진다() {
when(jwtService.verifyToken("invalid")).thenReturn(TokenStatus.TOKEN_INVALID);
NativeWebRequest webRequest = webRequestWith("invalid", "refresh", "GET", "/api/user");

assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null))
.isInstanceOf(InvalidAccessTokenException.class);
}

@Test
void 유효한_토큰이면_userId를_반환한다() {
when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID);
when(jwtService.getJwtContents("valid")).thenReturn("42");
NativeWebRequest webRequest = webRequestWith("valid", "refresh", "GET", "/api/user");

Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null);

assertThat(result).isEqualTo(42L);
}

@Test
void 토큰의_userId_클레임이_숫자가_아니면_예외를_던진다() {
when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID);
when(jwtService.getJwtContents("valid")).thenReturn("not-a-number");
NativeWebRequest webRequest = webRequestWith("valid", "refresh", "GET", "/api/user");

assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null))
.isInstanceOf(NotFoundUserException.class);
}

@Test
void UserId_애노테이션과_Long_타입일_때만_지원한다() {
when(methodParameter.hasParameterAnnotation(UserId.class)).thenReturn(true);
when(methodParameter.getParameterType()).thenReturn((Class) Long.class);

assertThat(userIdResolver.supportsParameter(methodParameter)).isTrue();
}
}
57 changes: 57 additions & 0 deletions src/test/java/org/runnect/server/config/jwt/JwtServiceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.runnect.server.config.jwt;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.runnect.server.common.constant.TokenStatus;
import org.runnect.server.config.redis.RedisService;
import org.springframework.test.util.ReflectionTestUtils;

class JwtServiceTest {

private JwtService jwtService;

@BeforeEach
void setUp() {
RedisService redisService = mock(RedisService.class);
jwtService = new JwtService(redisService);
ReflectionTestUtils.setField(jwtService, "jwtSecret", "test-secret-key-for-jwt-service-unit-test-only");
ReflectionTestUtils.invokeMethod(jwtService, "init");
}

@Test
void 발급한_액세스_토큰은_검증에_성공한다() {
String accessToken = jwtService.issuedAccessToken(1L);

long status = jwtService.verifyToken(accessToken);

assertThat(status).isEqualTo(TokenStatus.TOKEN_VALID);
}

@Test
void 발급한_토큰에서_userId_클레임을_그대로_추출한다() {
String accessToken = jwtService.issuedAccessToken(42L);

String userId = jwtService.getJwtContents(accessToken);

assertThat(userId).isEqualTo("42");
}

@Test
void 형식이_깨진_토큰은_INVALID로_판정한다() {
long status = jwtService.verifyToken("not-a-real-jwt");

assertThat(status).isEqualTo(TokenStatus.TOKEN_INVALID);
}

@Test
void 이미_만료된_토큰은_EXPIRED로_판정한다() {
String expiredToken = jwtService.issuedToken("access_token", -1000L, "1");

long status = jwtService.verifyToken(expiredToken);

assertThat(status).isEqualTo(TokenStatus.TOKEN_EXPIRED);
}
}
Loading
Loading