diff --git a/build.gradle b/build.gradle index bcfe5ce5..37766c0f 100644 --- a/build.gradle +++ b/build.gradle @@ -29,8 +29,6 @@ subprojects { } dependencies { - // Spring Security - //Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' @@ -43,6 +41,10 @@ subprojects { // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.mockito:mockito-core:5.9.0' + + // Sentry + implementation 'io.sentry:sentry-spring-boot-starter-jakarta:6.28.0' + implementation 'io.sentry:sentry-logback:6.28.0' } tasks.named('test') { diff --git a/moonshot-api/src/main/java/org/moonshot/config/AsyncConfig.java b/moonshot-api/src/main/java/org/moonshot/config/AsyncConfig.java new file mode 100644 index 00000000..35ae7d11 --- /dev/null +++ b/moonshot-api/src/main/java/org/moonshot/config/AsyncConfig.java @@ -0,0 +1,27 @@ +package org.moonshot.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@EnableAsync +@Configuration +public class AsyncConfig implements AsyncConfigurer { + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(4); + executor.setKeepAliveSeconds(60); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); + executor.setThreadNamePrefix("async-executor-"); + executor.initialize(); + return executor; + } + +} diff --git a/moonshot-api/src/main/java/org/moonshot/keyresult/service/KeyResultService.java b/moonshot-api/src/main/java/org/moonshot/keyresult/service/KeyResultService.java index bf3550d8..465f18b0 100644 --- a/moonshot-api/src/main/java/org/moonshot/keyresult/service/KeyResultService.java +++ b/moonshot-api/src/main/java/org/moonshot/keyresult/service/KeyResultService.java @@ -162,7 +162,7 @@ public Optional modifyKeyResult(final KeyResultModifyRequest throw new BadRequestException(REQUIRED_KEY_RESULT_VALUE); } - Log updateLog = logService.createUpdateLog(request, keyResult.getId()); + Log updateLog = logService.createUpdateLog(request, keyResult); validateLogNum(request.krTarget(), updateLog.getKeyResult().getTarget()); Optional prevLog = logRepository.findLatestLogByKeyResultId(LogState.RECORD, request.keyResultId()); diff --git a/moonshot-api/src/main/java/org/moonshot/log/service/LogService.java b/moonshot-api/src/main/java/org/moonshot/log/service/LogService.java index a2a4ce14..675502f4 100644 --- a/moonshot-api/src/main/java/org/moonshot/log/service/LogService.java +++ b/moonshot-api/src/main/java/org/moonshot/log/service/LogService.java @@ -62,9 +62,7 @@ public Optional createRecordLog(final Long userId, final Log return Optional.empty(); } - public Log createUpdateLog(final KeyResultModifyRequestDto request, final Long keyResultId) { - KeyResult keyResult = keyResultRepository.findById(keyResultId) - .orElseThrow(() -> new NotFoundException(NOT_FOUND_KEY_RESULT)); + public Log createUpdateLog(final KeyResultModifyRequestDto request, final KeyResult keyResult) { return logRepository.save(Log.builder() .date(LocalDateTime.now()) .state(LogState.UPDATE) diff --git a/moonshot-api/src/main/java/org/moonshot/objective/dto/response/HistoryResponseDto.java b/moonshot-api/src/main/java/org/moonshot/objective/dto/response/HistoryResponseDto.java index fb94fbdc..b064f89b 100644 --- a/moonshot-api/src/main/java/org/moonshot/objective/dto/response/HistoryResponseDto.java +++ b/moonshot-api/src/main/java/org/moonshot/objective/dto/response/HistoryResponseDto.java @@ -1,20 +1,12 @@ package org.moonshot.objective.dto.response; import java.util.List; -import java.util.Map; -import org.moonshot.objective.model.Criteria; public record HistoryResponseDto( List groups, - List years, List categories ) { - public static HistoryResponseDto of(List groups, Map years, - List categories, Criteria criteria) { - return new HistoryResponseDto( - groups, - YearDto.of(years), - categories.stream().distinct().toList() - ); + public static HistoryResponseDto of(List groups, List categories) { + return new HistoryResponseDto(groups, categories.stream().distinct().toList()); } } diff --git a/moonshot-api/src/main/java/org/moonshot/objective/service/ObjectiveService.java b/moonshot-api/src/main/java/org/moonshot/objective/service/ObjectiveService.java index 4fd97249..e4750a2e 100644 --- a/moonshot-api/src/main/java/org/moonshot/objective/service/ObjectiveService.java +++ b/moonshot-api/src/main/java/org/moonshot/objective/service/ObjectiveService.java @@ -14,7 +14,6 @@ import java.util.Comparator; import java.util.List; import java.util.Map; -import java.util.Map.Entry; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.moonshot.common.model.Period; @@ -47,7 +46,7 @@ public class ObjectiveService implements IndexService { private final ObjectiveRepository objectiveRepository; public void createObjective(final Long userId, final OKRCreateRequestDto request) { - User user = userRepository.findById(userId) + User user = userRepository.findByIdWithCache(userId) .orElseThrow(() -> new NotFoundException(NOT_FOUND_USER)); List objectives = objectiveRepository.findAllByUserId(userId); @@ -120,11 +119,6 @@ public HistoryResponseDto getObjectiveHistory(final Long userId, final Integer y List objectives = objectiveRepository.findObjectives(userId, year, category, criteria); Map> groups = objectives.stream() .collect(Collectors.groupingBy(objective -> objective.getPeriod().getStartAt().getYear())); - Map years = groups.entrySet().stream() - .collect(Collectors.toMap( - Entry::getKey, - entry -> entry.getValue().size() - )); List categories = objectives.stream().map(objective -> objective.getCategory().getValue()).toList(); List groupList = groups.entrySet().stream() @@ -142,7 +136,7 @@ public HistoryResponseDto getObjectiveHistory(final Long userId, final Integer y .sorted(Comparator.comparingInt(ObjectiveGroupByYearDto::year).reversed()).toList(); } - return HistoryResponseDto.of(groupsSortedByCriteria, years, categories, criteria); + return HistoryResponseDto.of(groupsSortedByCriteria, categories); } @Override diff --git a/moonshot-api/src/main/java/org/moonshot/task/service/validator/TaskValidator.java b/moonshot-api/src/main/java/org/moonshot/task/service/validator/TaskValidator.java index 4ea68005..216f766b 100644 --- a/moonshot-api/src/main/java/org/moonshot/task/service/validator/TaskValidator.java +++ b/moonshot-api/src/main/java/org/moonshot/task/service/validator/TaskValidator.java @@ -22,7 +22,7 @@ public static void validateActiveTaskSizeExceeded(final int taskListSize) { } public static void validateIndexUnderMaximum(final int requestIndex, final int totalTaskListSize) { - if (requestIndex > totalTaskListSize) { + if (requestIndex > totalTaskListSize || requestIndex < 0) { throw new BadRequestException(INVALID_TASK_INDEX); } } diff --git a/moonshot-api/src/main/java/org/moonshot/user/controller/UserController.java b/moonshot-api/src/main/java/org/moonshot/user/controller/UserController.java index 0f7586bc..8b1105f7 100644 --- a/moonshot-api/src/main/java/org/moonshot/user/controller/UserController.java +++ b/moonshot-api/src/main/java/org/moonshot/user/controller/UserController.java @@ -1,9 +1,6 @@ package org.moonshot.user.controller; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; -import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.moonshot.jwt.TokenResponse; @@ -16,7 +13,6 @@ import org.moonshot.user.dto.response.UserInfoResponse; import org.moonshot.user.model.LoginUser; import org.moonshot.user.service.UserService; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -38,7 +34,7 @@ public class UserController implements UserApi { @PostMapping("/login") @Logging(item = "User", action = "Post") public ResponseEntity> login(@RequestHeader("Authorization") final String authorization, - @RequestBody final SocialLoginRequest socialLoginRequest) throws IOException { + @RequestBody final SocialLoginRequest socialLoginRequest) { return ResponseEntity.ok(MoonshotResponse.success(SuccessType.POST_LOGIN_SUCCESS, userService.login(SocialLoginRequest.of(socialLoginRequest.socialPlatform(), authorization)))); } diff --git a/moonshot-api/src/main/java/org/moonshot/user/service/UserService.java b/moonshot-api/src/main/java/org/moonshot/user/service/UserService.java index 6aa18eda..0db33b60 100644 --- a/moonshot-api/src/main/java/org/moonshot/user/service/UserService.java +++ b/moonshot-api/src/main/java/org/moonshot/user/service/UserService.java @@ -1,40 +1,27 @@ package org.moonshot.user.service; import static org.moonshot.response.ErrorType.NOT_FOUND_USER; +import static org.moonshot.response.ErrorType.NOT_SUPPORTED_LOGIN_PLATFORM; import static org.moonshot.user.service.validator.UserValidator.hasChange; -import static org.moonshot.user.service.validator.UserValidator.isNewUser; import static org.moonshot.user.service.validator.UserValidator.validateUserAuthorization; -import static org.moonshot.util.MDCUtil.USER_REQUEST_ORIGIN; -import static org.moonshot.util.MDCUtil.get; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.moonshot.discord.SignUpEvent; +import org.moonshot.exception.BadRequestException; import org.moonshot.exception.NotFoundException; import org.moonshot.jwt.JwtTokenProvider; import org.moonshot.jwt.TokenResponse; import org.moonshot.objective.service.ObjectiveService; -import org.moonshot.openfeign.dto.response.google.GoogleInfoResponse; -import org.moonshot.openfeign.dto.response.google.GoogleTokenResponse; -import org.moonshot.openfeign.dto.response.kakao.KakaoTokenResponse; -import org.moonshot.openfeign.dto.response.kakao.KakaoUserResponse; -import org.moonshot.openfeign.google.GoogleApiClient; -import org.moonshot.openfeign.google.GoogleAuthApiClient; -import org.moonshot.openfeign.kakao.KakaoApiClient; -import org.moonshot.openfeign.kakao.KakaoAuthApiClient; import org.moonshot.user.dto.request.SocialLoginRequest; import org.moonshot.user.dto.request.UserInfoRequest; import org.moonshot.user.dto.response.SocialLoginResponse; import org.moonshot.user.dto.response.UserInfoResponse; import org.moonshot.user.model.User; import org.moonshot.user.repository.UserRepository; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.ApplicationEventPublisher; +import org.moonshot.user.service.social.SocialLoginContext; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -43,95 +30,16 @@ @RequiredArgsConstructor public class UserService { - @Value("${google.client-id}") - private String googleClientId; - - @Value("${google.client-secret}") - private String googleClientSecret; - - @Value("${google.redirect-url}") - private String googleRedirectUrl; - - @Value("${kakao.client-id}") - private String kakaoClientId; - - @Value("${kakao.redirect-uri}") - private String kakaoRedirectUri; - private final UserRepository userRepository; private final ObjectiveService objectiveService; - private final ApplicationEventPublisher eventPublisher; - - private final GoogleAuthApiClient googleAuthApiClient; - private final GoogleApiClient googleApiClient; - private final KakaoAuthApiClient kakaoAuthApiClient; - private final KakaoApiClient kakaoApiClient; private final JwtTokenProvider jwtTokenProvider; + private final SocialLoginContext socialLoginContext; public SocialLoginResponse login(final SocialLoginRequest request) { - return switch (request.socialPlatform().getValue()) { - case "google" -> googleLogin(request); - case "kakao" -> kakaoLogin(request); - default -> null; - }; - } - - public SocialLoginResponse googleLogin(final SocialLoginRequest request) { - GoogleTokenResponse tokenResponse = googleAuthApiClient.googleAuth( - request.code(), - googleClientId, - googleClientSecret, - googleRedirectUrl, - "authorization_code" - ); - GoogleInfoResponse userResponse = googleApiClient.googleInfo("Bearer " + tokenResponse.accessToken()); - Optional findUser = userRepository.findUserBySocialId(userResponse.sub()); - User user; - if (isNewUser(findUser)) { - User newUser = userRepository.save(User.builderWithSignIn() - .socialId(userResponse.sub()) - .socialPlatform(request.socialPlatform()) - .name(userResponse.name()) - .imageUrl(userResponse.picture()) - .email(userResponse.email()) - .build()); - user = newUser; - publishSignUpEvent(newUser); - } else { - user = findUser.get(); - user.resetDeleteAt(); - } - TokenResponse token = new TokenResponse(jwtTokenProvider.generateAccessToken(user.getId()), jwtTokenProvider.generateRefreshToken(user.getId())); - return SocialLoginResponse.of(user.getId(), user.getName(), token); - } - - public SocialLoginResponse kakaoLogin(final SocialLoginRequest request) { - KakaoTokenResponse tokenResponse = kakaoAuthApiClient.getOAuth2AccessToken( - "authorization_code", - kakaoClientId, - (String)get(USER_REQUEST_ORIGIN) + kakaoRedirectUri, - request.code() - ); - KakaoUserResponse userResponse = kakaoApiClient.getUserInformation( - "Bearer " + tokenResponse.accessToken()); - Optional findUser = userRepository.findUserBySocialId(userResponse.id()); - User user; - if (isNewUser(findUser)) { - User newUser = userRepository.save(User.builderWithSignIn() - .socialId(userResponse.id()) - .socialPlatform(request.socialPlatform()) - .name(userResponse.kakaoAccount().profile().nickname()) - .imageUrl(userResponse.kakaoAccount().profile().profileImageUrl()) - .email(null) - .build()); - user = newUser; - publishSignUpEvent(newUser); - } else { - user = findUser.get(); - user.resetDeleteAt(); + if (socialLoginContext.support(request.socialPlatform())) { + return socialLoginContext.doLogin(request); } - TokenResponse token = new TokenResponse(jwtTokenProvider.generateAccessToken(user.getId()), jwtTokenProvider.generateRefreshToken(user.getId())); - return SocialLoginResponse.of(user.getId(), user.getName(), token); + throw new BadRequestException(NOT_SUPPORTED_LOGIN_PLATFORM); } public TokenResponse reissue(final String refreshToken) { @@ -181,17 +89,6 @@ public void updateUserProfileImage(final Long userId, final String imageUrl) { user.modifyProfileImage(imageUrl); } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void publishSignUpEvent(final User user) { - eventPublisher.publishEvent(SignUpEvent.of( - user.getName(), - user.getEmail() == null ? "" : user.getEmail(), - user.getSocialPlatform().toString(), - LocalDateTime.now(), - user.getImageUrl() - )); - } - public void softDeleteUser(LocalDateTime currentDate) { List expiredUserList = userRepository.findIdByDeletedAtBefore(currentDate); if(!expiredUserList.isEmpty()) { diff --git a/moonshot-api/src/main/java/org/moonshot/user/service/UserSignUpService.java b/moonshot-api/src/main/java/org/moonshot/user/service/UserSignUpService.java new file mode 100644 index 00000000..c280801d --- /dev/null +++ b/moonshot-api/src/main/java/org/moonshot/user/service/UserSignUpService.java @@ -0,0 +1,35 @@ +package org.moonshot.user.service; + +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.moonshot.discord.SignUpEvent; +import org.moonshot.user.model.User; +import org.moonshot.user.repository.UserRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserSignUpService { + + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void publishSignUpEvent(User user) { + Long totalUserCount = userRepository.count(); + eventPublisher.publishEvent(SignUpEvent.of( + totalUserCount, + user.getName(), + user.getEmail() == null ? "" : user.getEmail(), + user.getSocialPlatform().toString(), + LocalDateTime.now(), + user.getImageUrl() + )); + } + +} diff --git a/moonshot-api/src/main/java/org/moonshot/user/service/social/GoogleLoginStrategy.java b/moonshot-api/src/main/java/org/moonshot/user/service/social/GoogleLoginStrategy.java new file mode 100644 index 00000000..d34e7c3d --- /dev/null +++ b/moonshot-api/src/main/java/org/moonshot/user/service/social/GoogleLoginStrategy.java @@ -0,0 +1,76 @@ +package org.moonshot.user.service.social; + +import static org.moonshot.user.service.validator.UserValidator.isNewUser; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.moonshot.jwt.JwtTokenProvider; +import org.moonshot.jwt.TokenResponse; +import org.moonshot.openfeign.dto.response.google.GoogleInfoResponse; +import org.moonshot.openfeign.dto.response.google.GoogleTokenResponse; +import org.moonshot.openfeign.google.GoogleApiClient; +import org.moonshot.openfeign.google.GoogleAuthApiClient; +import org.moonshot.user.dto.request.SocialLoginRequest; +import org.moonshot.user.dto.response.SocialLoginResponse; +import org.moonshot.user.model.User; +import org.moonshot.user.repository.UserRepository; +import org.moonshot.user.service.UserSignUpService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GoogleLoginStrategy implements SocialLoginStrategy { + + @Value("${google.client-id}") + private String googleClientId; + @Value("${google.client-secret}") + private String googleClientSecret; + @Value("${google.redirect-url}") + private String googleRedirectUrl; + + private final GoogleAuthApiClient googleAuthApiClient; + private final GoogleApiClient googleApiClient; + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final UserSignUpService userSignUpService; + + @Override + @Transactional + public SocialLoginResponse login(SocialLoginRequest request) { + GoogleTokenResponse tokenResponse = googleAuthApiClient.googleAuth( + request.code(), + googleClientId, + googleClientSecret, + googleRedirectUrl, + "authorization_code" + ); + GoogleInfoResponse userResponse = googleApiClient.googleInfo("Bearer " + tokenResponse.accessToken()); + Optional findUser = userRepository.findUserBySocialId(userResponse.sub()); + User user; + if (isNewUser(findUser)) { + User newUser = userRepository.save(User.builderWithSignIn() + .socialId(userResponse.sub()) + .socialPlatform(request.socialPlatform()) + .name(userResponse.name()) + .imageUrl(userResponse.picture()) + .email(userResponse.email()) + .build()); + user = newUser; + userSignUpService.publishSignUpEvent(newUser); + } else { + user = findUser.get(); + user.resetDeleteAt(); + } + TokenResponse token = new TokenResponse(jwtTokenProvider.generateAccessToken(user.getId()), jwtTokenProvider.generateRefreshToken(user.getId())); + return SocialLoginResponse.of(user.getId(), user.getName(), token); + } + + @Override + public boolean support(String provider) { + return provider.equals("GOOGLE"); + } + +} diff --git a/moonshot-api/src/main/java/org/moonshot/user/service/social/KakaoLoginStrategy.java b/moonshot-api/src/main/java/org/moonshot/user/service/social/KakaoLoginStrategy.java new file mode 100644 index 00000000..5e9ba829 --- /dev/null +++ b/moonshot-api/src/main/java/org/moonshot/user/service/social/KakaoLoginStrategy.java @@ -0,0 +1,76 @@ +package org.moonshot.user.service.social; + +import static org.moonshot.user.service.validator.UserValidator.isNewUser; +import static org.moonshot.util.MDCUtil.USER_REQUEST_ORIGIN; +import static org.moonshot.util.MDCUtil.get; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.moonshot.jwt.JwtTokenProvider; +import org.moonshot.jwt.TokenResponse; +import org.moonshot.openfeign.dto.response.kakao.KakaoTokenResponse; +import org.moonshot.openfeign.dto.response.kakao.KakaoUserResponse; +import org.moonshot.openfeign.kakao.KakaoApiClient; +import org.moonshot.openfeign.kakao.KakaoAuthApiClient; +import org.moonshot.user.dto.request.SocialLoginRequest; +import org.moonshot.user.dto.response.SocialLoginResponse; +import org.moonshot.user.model.User; +import org.moonshot.user.repository.UserRepository; +import org.moonshot.user.service.UserSignUpService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class KakaoLoginStrategy implements SocialLoginStrategy { + + @Value("${kakao.client-id}") + private String kakaoClientId; + @Value("${kakao.redirect-uri}") + private String kakaoRedirectUri; + + private final KakaoAuthApiClient kakaoAuthApiClient; + private final KakaoApiClient kakaoApiClient; + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final UserSignUpService userSignUpService; + + @Override + @Transactional + public SocialLoginResponse login(SocialLoginRequest request) { + KakaoTokenResponse tokenResponse = kakaoAuthApiClient.getOAuth2AccessToken( + "authorization_code", + kakaoClientId, + (String)get(USER_REQUEST_ORIGIN) + kakaoRedirectUri, + request.code() + ); + KakaoUserResponse userResponse = kakaoApiClient.getUserInformation( + "Bearer " + tokenResponse.accessToken()); + Optional findUser = userRepository.findUserBySocialId(userResponse.id()); + User user; + if (isNewUser(findUser)) { + User newUser = userRepository.save(User.builderWithSignIn() + .socialId(userResponse.id()) + .socialPlatform(request.socialPlatform()) + .name(userResponse.kakaoAccount().profile().nickname()) + .imageUrl(userResponse.kakaoAccount().profile().profileImageUrl()) + .email(null) + .build()); + user = newUser; + userSignUpService.publishSignUpEvent(newUser); + } else { + user = findUser.get(); + user.resetDeleteAt(); + } + TokenResponse token = new TokenResponse(jwtTokenProvider.generateAccessToken(user.getId()), jwtTokenProvider.generateRefreshToken(user.getId())); + return SocialLoginResponse.of(user.getId(), user.getName(), token); + } + + @Override + public boolean support(String provider) { + return provider.equals("KAKAO"); + } + +} diff --git a/moonshot-api/src/main/java/org/moonshot/user/service/social/SocialLoginContext.java b/moonshot-api/src/main/java/org/moonshot/user/service/social/SocialLoginContext.java new file mode 100644 index 00000000..8c3d8521 --- /dev/null +++ b/moonshot-api/src/main/java/org/moonshot/user/service/social/SocialLoginContext.java @@ -0,0 +1,47 @@ +package org.moonshot.user.service.social; + +import static org.moonshot.response.ErrorType.NOT_SUPPORTED_LOGIN_PLATFORM; + +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.moonshot.exception.BadRequestException; +import org.moonshot.user.dto.request.SocialLoginRequest; +import org.moonshot.user.dto.response.SocialLoginResponse; +import org.moonshot.user.model.SocialPlatform; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SocialLoginContext { + + private final GoogleLoginStrategy googleLoginStrategy; + private final KakaoLoginStrategy kakaoLoginStrategy; + private final List socialLoginStrategies = new ArrayList<>(); + + @PostConstruct + void initSocialLoginContext() { + socialLoginStrategies.add(googleLoginStrategy); + socialLoginStrategies.add(kakaoLoginStrategy); + } + + public boolean support(SocialPlatform platform) { + for (SocialLoginStrategy strategy : socialLoginStrategies) { + if (strategy.support(platform.toString())) { + return true; + } + } + return false; + } + + public SocialLoginResponse doLogin(final SocialLoginRequest request) { + for (SocialLoginStrategy strategy : socialLoginStrategies) { + if (strategy.support(request.socialPlatform().toString())) { + return strategy.login(request); + } + } + throw new BadRequestException(NOT_SUPPORTED_LOGIN_PLATFORM); + } + +} diff --git a/moonshot-api/src/main/java/org/moonshot/user/service/social/SocialLoginStrategy.java b/moonshot-api/src/main/java/org/moonshot/user/service/social/SocialLoginStrategy.java new file mode 100644 index 00000000..fbdf5819 --- /dev/null +++ b/moonshot-api/src/main/java/org/moonshot/user/service/social/SocialLoginStrategy.java @@ -0,0 +1,11 @@ +package org.moonshot.user.service.social; + +import org.moonshot.user.dto.request.SocialLoginRequest; +import org.moonshot.user.dto.response.SocialLoginResponse; + +public interface SocialLoginStrategy { + + SocialLoginResponse login(final SocialLoginRequest request); + boolean support(String provider); + +} diff --git a/moonshot-api/src/main/resources/application.yml b/moonshot-api/src/main/resources/application.yml index bfcdf025..be10bbcc 100644 --- a/moonshot-api/src/main/resources/application.yml +++ b/moonshot-api/src/main/resources/application.yml @@ -84,3 +84,7 @@ springdoc: mybatis: mapper-locations: mappers/*.xml + +sentry: + dsn: ${SENTRY_DSN} + traces-sample-rate: 1.0 diff --git a/moonshot-api/src/main/resources/logback-prod.xml b/moonshot-api/src/main/resources/logback-prod.xml index 9efd20da..8b6b0260 100644 --- a/moonshot-api/src/main/resources/logback-prod.xml +++ b/moonshot-api/src/main/resources/logback-prod.xml @@ -5,9 +5,11 @@ + - + + \ No newline at end of file diff --git a/moonshot-api/src/main/resources/sentry-appender.xml b/moonshot-api/src/main/resources/sentry-appender.xml new file mode 100644 index 00000000..d1233dac --- /dev/null +++ b/moonshot-api/src/main/resources/sentry-appender.xml @@ -0,0 +1,10 @@ + + + + ERROR + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + \ No newline at end of file diff --git a/moonshot-api/src/test/java/org/moonshot/keyresult/service/KeyResultServiceTest.java b/moonshot-api/src/test/java/org/moonshot/keyresult/service/KeyResultServiceTest.java new file mode 100644 index 00000000..43cb7d57 --- /dev/null +++ b/moonshot-api/src/test/java/org/moonshot/keyresult/service/KeyResultServiceTest.java @@ -0,0 +1,318 @@ +package org.moonshot.keyresult.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeAll; +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.moonshot.common.model.Period; +import org.moonshot.exception.BadRequestException; +import org.moonshot.keyresult.dto.request.KeyResultCreateRequestDto; +import org.moonshot.keyresult.dto.request.KeyResultCreateRequestInfoDto; +import org.moonshot.keyresult.dto.request.KeyResultModifyRequestDto; +import org.moonshot.keyresult.dto.response.KRDetailResponseDto; +import org.moonshot.keyresult.model.KRState; +import org.moonshot.keyresult.model.KeyResult; +import org.moonshot.keyresult.repository.KeyResultRepository; +import org.moonshot.log.dto.response.AchieveResponseDto; +import org.moonshot.log.model.Log; +import org.moonshot.log.model.LogState; +import org.moonshot.log.repository.LogRepository; +import org.moonshot.log.service.LogService; +import org.moonshot.objective.model.Category; +import org.moonshot.objective.model.Objective; +import org.moonshot.objective.repository.ObjectiveRepository; +import org.moonshot.task.dto.request.TaskCreateRequestDto; +import org.moonshot.task.repository.TaskRepository; +import org.moonshot.task.service.TaskService; +import org.moonshot.user.model.User; + +@ExtendWith(MockitoExtension.class) +class KeyResultServiceTest { + + @Mock + private ObjectiveRepository objectiveRepository; + @Mock + private KeyResultRepository keyResultRepository; + @Mock + private TaskRepository taskRepository; + @Mock + private TaskService taskService; + @Mock + private LogService logService; + @Mock + private LogRepository logRepository; + @InjectMocks + private KeyResultService keyResultService; + + private static User fakeUser; + + @BeforeAll + static void setUp() { + Long fakeUserId = 1L; + String fakeUserNickname = "tester"; + fakeUser = User.buildWithId().id(fakeUserId).nickname(fakeUserNickname).build(); + } + + @Test + @DisplayName("Objective와 함께 생성하는 KeyResult를 생성합니다") + void Objective와_함께_생성하는_KeyResult를_생성합니다() { + //given + Objective testObjective = Objective.builder() + .title("testObjective") + .category(Category.ECONOMY) + .content("testObjective Content") + .period(Period.of(LocalDate.now(), LocalDate.from(LocalDate.now().plusDays(30)))) + .user(fakeUser) + .build(); + KeyResult testKeyResult = mock(KeyResult.class); + TaskCreateRequestDto firstTaskDto = new TaskCreateRequestDto("test Task 0", 0); + TaskCreateRequestDto secondTaskDto = new TaskCreateRequestDto("test Task 1", 1); + TaskCreateRequestDto thirdTaskDto = new TaskCreateRequestDto("test Task 2", 2); + List taskList = List.of(firstTaskDto, secondTaskDto, thirdTaskDto); + KeyResultCreateRequestInfoDto firstKeyResultDto = new KeyResultCreateRequestInfoDto( + "test KR", LocalDate.now(), LocalDate.from(LocalDate.now().plusDays(30)), + 0, 500000L, "건", taskList + ); + KeyResultCreateRequestInfoDto secondKeyResultDto = new KeyResultCreateRequestInfoDto( + "test KR", LocalDate.now(), LocalDate.from(LocalDate.now().plusDays(30)), + 1, 500000L, "건", taskList + ); + List testRequests = new java.util.ArrayList<>( + List.of(firstKeyResultDto, secondKeyResultDto)); + testRequests.add(null); + given(keyResultRepository.save(any(KeyResult.class))).willReturn(testKeyResult); + given(testKeyResult.getId()).willReturn(1L); + + //when + keyResultService.createInitKRWithObjective(testObjective, testRequests); + + //then + verify(keyResultRepository, times(2)).save(any(KeyResult.class)); + verify(logService, times(2)).createKRLog(any(KeyResultCreateRequestInfoDto.class), any(Long.class)); + verify(taskService, times(6)).saveTask(any(KeyResult.class), any(TaskCreateRequestDto.class)); + } + + @Test + @DisplayName("단일 KeyResult를 생성합니다") + void 단일_KeyResult를_생성합니다() { + // given + Objective testObjective = mock(Objective.class); + User testUser = mock(User.class); + KeyResult testKeyResult = mock(KeyResult.class); + KeyResultCreateRequestDto request = new KeyResultCreateRequestDto( + 1L, "test KR", LocalDate.now(), + LocalDate.from(LocalDate.now().plusDays(30)), + 0, 500000L, "건"); + + given(objectiveRepository.findObjectiveAndUserById(1L)).willReturn(Optional.of(testObjective)); + given(keyResultRepository.findAllByObjective(testObjective)).willReturn(List.of(testKeyResult)); + given(testObjective.getId()).willReturn(1L); + given(testObjective.getUser()).willReturn(testUser); + given(testUser.getId()).willReturn(1L); + given(keyResultRepository.save(any(KeyResult.class))).willReturn(testKeyResult); + given(testKeyResult.getId()).willReturn(2L); + + // when + keyResultService.createKeyResult(request, fakeUser.getId()); + + // then + verify(keyResultRepository, times(1)).bulkUpdateIdxIncrease(any(Integer.class), any(Integer.class), eq(1L), eq(-1L)); + verify(logService, times(1)).createKRLog(request, 2L); + } + + @Test + @DisplayName("KeyResult 생성 시 최대 보유 갯수를 넘어 예외가 발생합니다") + void KeyResult_생성_시_최대_보유_갯수를_넘어_예외가_발생합니다() { + // given + Objective testObjective = mock(Objective.class); + User testUser = mock(User.class); + List krList = mock(List.class); + KeyResultCreateRequestDto request = new KeyResultCreateRequestDto( + 1L, "test KR", LocalDate.now(), + LocalDate.from(LocalDate.now().plusDays(30)), + 0, 500000L, "건"); + + given(objectiveRepository.findObjectiveAndUserById(1L)).willReturn(Optional.of(testObjective)); + given(keyResultRepository.findAllByObjective(testObjective)).willReturn(krList); + given(krList.size()).willReturn(3); + given(testObjective.getUser()).willReturn(testUser); + given(testUser.getId()).willReturn(1L); + + // when, then + assertThatThrownBy(() -> keyResultService.createKeyResult(request, fakeUser.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage("허용된 Key Result 개수를 초과하였습니다"); + } + + @Test + @DisplayName("KeyResult 생성 시 인덱스가 0 ~ ListSize(최대 개수의 범위)를 벗어나 요청된 경우 예외가 발생합니다") + void KeyResult_생성_시_인덱스가_0_ListSize를_벗어나_요청된_경우_예외가_발생합니다() { + // given + Objective testObjective = mock(Objective.class); + User testUser = mock(User.class); + List krList = mock(List.class); + KeyResultCreateRequestDto request = new KeyResultCreateRequestDto( + 1L, "test KR", LocalDate.now(), + LocalDate.from(LocalDate.now().plusDays(30)), + 2, 500000L, "건"); + + given(objectiveRepository.findObjectiveAndUserById(1L)).willReturn(Optional.of(testObjective)); + given(keyResultRepository.findAllByObjective(testObjective)).willReturn(krList); + given(krList.size()).willReturn(1); + given(testObjective.getUser()).willReturn(testUser); + given(testUser.getId()).willReturn(1L); + + // when, then + assertThatThrownBy(() -> keyResultService.createKeyResult(request, fakeUser.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage("정상적이지 않은 KeyResult 위치입니다."); + } + + @Test + @DisplayName("KeyResult의 데이터를 조회합니다") + void KeyResult의_데이터를_조회합니다() { + // given + Long testKeyResultId = 99L; + Objective testObjective = Objective.builder() + .title("testObjective") + .user(fakeUser).build(); + KeyResult testKeyResult = KeyResult.builder() + .title("testKeyResult") + .objective(testObjective) + .target(1L) + .metric("건") + .idx(0) + .state(KRState.PROGRESS) + .period(Period.of(LocalDate.now(), LocalDate.from(LocalDate.now().plusDays(30)))) + .build(); + Log testLog = Log.builder().keyResult(testKeyResult).state(LogState.RECORD).currNum(10000L).build(); + List logList = List.of(testLog); + given(keyResultRepository.findKeyResultAndObjective(testKeyResultId)).willReturn(Optional.of(testKeyResult)); + given(logService.getLogList(testKeyResult)).willReturn(logList); + given(logService.calculateKRProgressBar(testLog, testKeyResult.getTarget())).willReturn((short) 100); + + // when + KRDetailResponseDto krDetails = keyResultService.getKRDetails(fakeUser.getId(), testKeyResultId); + + // then + assertThat(krDetails.target()).isEqualTo(1L); + assertThat(krDetails.metric()).isEqualTo("건"); + assertThat(krDetails.progressBar()).isEqualTo((short) 100); + assertThat(krDetails.krState()).isEqualTo(KRState.PROGRESS.getValue()); + assertThat(krDetails.title()).isEqualTo("testKeyResult : "); + } + + @Test + @DisplayName("KeyResult의 제목 및 날짜 데이터를 수정합니다") + void KeyResult의_제목_및_날짜_데이터를_수정합니다() { + // given + Long testKeyResultId = 99L; + KeyResult testKeyResult = mock(KeyResult.class); + Objective testObjective = mock(Objective.class); + KeyResultModifyRequestDto request = new KeyResultModifyRequestDto( + testKeyResultId, "modified KeyResult Title", LocalDate.of(2024, 4, 1), + LocalDate.of(2024, 5, 1), null, null, null); + given(keyResultRepository.findKeyResultAndObjective(testKeyResultId)).willReturn(Optional.of(testKeyResult)); + given(testKeyResult.getObjective()).willReturn(testObjective); + given(testKeyResult.getPeriod()).willReturn(Period.of(LocalDate.of(2023, 4, 1), LocalDate.of(2023, 5, 1))); + given(testObjective.getPeriod()).willReturn(Period.of(LocalDate.of(2023, 4, 1), LocalDate.of(2025, 5, 1))); + given(testObjective.getUser()).willReturn(fakeUser); + + // when + keyResultService.modifyKeyResult(request, fakeUser.getId()); + + // then + verify(testKeyResult, times(1)).modifyTitle(request.krTitle()); + verify(testKeyResult, times(1)).modifyPeriod(any(Period.class)); + } + + @Test + @DisplayName("KeyResult의 상태 데이터를 수정합니다") + void KeyResult의_상태_데이터를_수정합니다() { + // given + Long testKeyResultId = 99L; + KeyResult testKeyResult = mock(KeyResult.class); + Objective testObjective = mock(Objective.class); + KeyResultModifyRequestDto request = new KeyResultModifyRequestDto( + testKeyResultId, null, null, null, null, KRState.DONE, null); + given(keyResultRepository.findKeyResultAndObjective(testKeyResultId)).willReturn(Optional.of(testKeyResult)); + given(testKeyResult.getObjective()).willReturn(testObjective); + given(testObjective.getUser()).willReturn(fakeUser); + + // when + Optional response = keyResultService.modifyKeyResult(request, fakeUser.getId()); + + // then + verify(testKeyResult, times(1)).modifyState(KRState.DONE); + assertThat(response.isEmpty()).isEqualTo(true); + } + + @Test + @DisplayName("KeyResult 수정 시 목표치와 체크인 내용이 둘다 없을 경우 예외가 발생합니다") + void KeyResult_수정_시_목표치와_체크인_내용이_둘다_없을_경우_예외가_발생합니다() { + // given + Long testKeyResultId = 99L; + KeyResult testKeyResult = mock(KeyResult.class); + Objective testObjective = mock(Objective.class); + KeyResultModifyRequestDto request = new KeyResultModifyRequestDto( + testKeyResultId, null, null, null, null, null, null); + given(keyResultRepository.findKeyResultAndObjective(testKeyResultId)).willReturn(Optional.of(testKeyResult)); + given(testKeyResult.getObjective()).willReturn(testObjective); + given(testObjective.getUser()).willReturn(fakeUser); + + // when, then + assertThatThrownBy(() -> keyResultService.modifyKeyResult(request, fakeUser.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage("KR 수정시 목표값과 체크인 로그는 필수 입력값입니다."); + } + + @Test + @DisplayName("KeyResult의 목표치를 수정하고 체크인 로그를 추가합니다") + void KeyResult의_목표치를_수정하고_체크인_로그를_추가합니다() { + // given + Long testKeyResultId = 99L; + KeyResult testKeyResult = mock(KeyResult.class); + Objective testObjective = mock(Objective.class); + Log testLog = mock(Log.class); + Log prevTestLog = mock(Log.class); + KeyResultModifyRequestDto request = new KeyResultModifyRequestDto( + testKeyResultId, null, null, null, 100000L, null, "new check-in"); + given(keyResultRepository.findKeyResultAndObjective(testKeyResultId)).willReturn(Optional.of(testKeyResult)); + given(testKeyResult.getObjective()).willReturn(testObjective); + given(testKeyResult.getTarget()).willReturn(1L); + doNothing().when(testKeyResult).modifyTarget(request.krTarget()); + + given(testObjective.getUser()).willReturn(fakeUser); + given(testLog.getKeyResult()).willReturn(testKeyResult); + given(logService.createUpdateLog(request, testKeyResult)).willReturn(testLog); + given(logRepository.findLatestLogByKeyResultId(LogState.RECORD, testKeyResultId)).willReturn(Optional.of(prevTestLog)); + + /* isKeyResultAchieved */ + given(testObjective.getProgress()).willReturn((short) 1); + + // when + Optional response = keyResultService.modifyKeyResult(request, fakeUser.getId()); + + // then + verify(testKeyResult, times(1)).modifyProgress(any(Short.class)); + verify(testObjective, times(1)).modifyProgress(any(Short.class)); + assertThat(response.isEmpty()).isEqualTo(true); + } + +} \ No newline at end of file diff --git a/moonshot-api/src/test/java/org/moonshot/log/service/LogServiceTest.java b/moonshot-api/src/test/java/org/moonshot/log/service/LogServiceTest.java new file mode 100644 index 00000000..7a0d06dd --- /dev/null +++ b/moonshot-api/src/test/java/org/moonshot/log/service/LogServiceTest.java @@ -0,0 +1,185 @@ +package org.moonshot.log.service; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.moonshot.exception.BadRequestException; +import org.moonshot.keyresult.dto.request.KeyResultCreateRequestDto; +import org.moonshot.keyresult.dto.request.KeyResultCreateRequestInfoDto; +import org.moonshot.keyresult.dto.request.KeyResultModifyRequestDto; +import org.moonshot.keyresult.model.KeyResult; +import org.moonshot.keyresult.repository.KeyResultRepository; +import org.moonshot.log.dto.request.LogCreateRequestDto; +import org.moonshot.log.dto.response.AchieveResponseDto; +import org.moonshot.log.model.Log; +import org.moonshot.log.model.LogState; +import org.moonshot.log.repository.LogRepository; +import org.moonshot.objective.model.Objective; +import org.moonshot.user.model.User; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +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.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +public class LogServiceTest { + + @Mock + private KeyResultRepository keyResultRepository; + @InjectMocks + private LogService logService; + @Mock + private LogRepository logRepository; + + private static User fakeUser; + + + @BeforeAll + static void setUp() { + Long fakeUserId = 1L; + String fakeUserNickname = "tester"; + fakeUser = User.buildWithId().id(fakeUserId).nickname(fakeUserNickname).build(); + } + + @ParameterizedTest + @DisplayName("KeyResult의 진척 상황을 기록하고 체크인 로그를 추가합니다.") + @CsvSource(value = {"1, true", "100, false"}) + void KeyResult의_진척상황을_기록하고_체크인_로그를_추가합니다(short progress, boolean expected) { + // given + Objective testObjective = mock(Objective.class); + KeyResult testKeyResult = mock(KeyResult.class); + Log testLog = mock(Log.class); + Log testPrevLog = mock(Log.class); + LogCreateRequestDto request = new LogCreateRequestDto(1L, 1000L, "new log content"); + + given(keyResultRepository.findKeyResultAndObjective(request.keyResultId())).willReturn(Optional.of(testKeyResult)); + given(testKeyResult.getObjective()).willReturn(testObjective); + given(testObjective.getUser()).willReturn(fakeUser); + + given(logRepository.findLatestLogByKeyResultId(eq(LogState.RECORD), anyLong())).willReturn(Optional.of(testPrevLog)); + given(logRepository.save(any(Log.class))).willReturn(testLog); + given(testObjective.getKeyResultList()).willReturn(List.of(testKeyResult)); + + given(testObjective.getProgress()).willReturn((short)progress); + + //when + Optional response = logService.createRecordLog(fakeUser.getId(), request); + + //then + verify(testKeyResult, times(1)).modifyProgress(anyShort()); + verify(testObjective, times(1)).modifyProgress(anyShort()); + assertThat(response.isEmpty()).isEqualTo(expected); + } + + @Test + @DisplayName("KeyResult의 진척 상황의 값이 이전 진척 상황 값과 동일하여 예외가 발생합니다.") + void KeyResult의_진척상황_값이_이전_진척상황_값과_동일하여_예외가_발생합니다() { + //given + Objective testObjective = mock(Objective.class); + KeyResult testKeyResult = mock(KeyResult.class); + Log testPrevLog = mock(Log.class); + LogCreateRequestDto request = new LogCreateRequestDto(1L, 1000L, "new log content"); + + given(keyResultRepository.findKeyResultAndObjective(request.keyResultId())).willReturn(Optional.of(testKeyResult)); + given(testKeyResult.getObjective()).willReturn(testObjective); + given(testObjective.getUser()).willReturn(fakeUser); + + given(logRepository.findLatestLogByKeyResultId(eq(LogState.RECORD), anyLong())).willReturn(Optional.of(testPrevLog)); + given(testPrevLog.getCurrNum()).willReturn(1000L); + + //when, then + assertThatThrownBy(() -> logService.createRecordLog(fakeUser.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Log 입력값은 이전 값과 동일할 수 없습니다."); + } + + @Test + @DisplayName("KeyResult가 수정되면 체크인 로그를 추가합니다.") + void KeyResult가_수정되면_체크인_로그를_추가합니다() { + //given + KeyResult testKeyResult = mock(KeyResult.class); + KeyResultModifyRequestDto request = new KeyResultModifyRequestDto( + 1L, null, null, null, 100000L, null, "new check-in"); + //when + logService.createUpdateLog(request, testKeyResult); + + //then + verify(logRepository, times(1)).save(any(Log.class)); + } + + @Test + @DisplayName("Objective와 함께 KeyResult가 생성되면 체크인 로그를 추가합니다.") + void Objective와_함께_KeyResult가_생성되면_체크인_로그를_추가합니다() { + //given + KeyResult testKeyResult = mock(KeyResult.class); + Long testKeyResultId = 1L; + KeyResultCreateRequestInfoDto request = new KeyResultCreateRequestInfoDto("test KR", LocalDate.now(), LocalDate.now(), 0, 2000L, "건", null); + + given(keyResultRepository.findById(testKeyResultId)).willReturn(Optional.of(testKeyResult)); + + //when + logService.createKRLog(request, testKeyResultId); + + //then + verify(logRepository, times(1)).save(any(Log.class)); + } + + @Test + @DisplayName("KeyResult가 추가적으로 생성되면 체크인 로그를 추가합니다.") + void KeyResult가_추가적으로_생성되면_체크인_로그를_추가합니다() { + //given + KeyResult testKeyResult = mock(KeyResult.class); + Long testKeyResultId = 1L; + KeyResultCreateRequestDto request = new KeyResultCreateRequestDto(1L, "test KR", LocalDate.now(), LocalDate.now(), 0, 2000L, "건"); + + given(keyResultRepository.findById(testKeyResultId)).willReturn(Optional.of(testKeyResult)); + + //when + logService.createKRLog(request, testKeyResultId); + + //then + verify(logRepository, times(1)).save(any(Log.class)); + } + + @Test + @DisplayName("진척 상황값이 입력되면 KR 달성률을 계산합니다.") + void 진척상황_값이_입력되면_KR_달성률을_계산합니다() { + //given + KeyResult testKeyResult = mock(KeyResult.class); + Log testLog = mock(Log.class); + + given(testKeyResult.getTarget()).willReturn(1000L); + given(testLog.getCurrNum()).willReturn(100L); + + //when, then + assertThat(logService.calculateKRProgressBar(testLog, testKeyResult.getTarget())).isEqualTo((short)10); + } + + @Test + @DisplayName("KeyResult에 대한 Log를 조회합니다.") + void KeyResult에_대한_Log를_조회합니다() { + //given + KeyResult testKeyResult = mock(KeyResult.class); + List testLogList = mock(List.class); + + given(testLogList.size()).willReturn(2); + given(logRepository.findAllByKeyResultOrderByIdDesc(testKeyResult)).willReturn(testLogList); + + //when,then + assertThat(logService.getLogList(testKeyResult).size()).isEqualTo(2); + } + +} \ No newline at end of file diff --git a/moonshot-api/src/test/java/org/moonshot/objective/service/ObjectiveServiceTest.java b/moonshot-api/src/test/java/org/moonshot/objective/service/ObjectiveServiceTest.java index 52a40ca5..b931a496 100644 --- a/moonshot-api/src/test/java/org/moonshot/objective/service/ObjectiveServiceTest.java +++ b/moonshot-api/src/test/java/org/moonshot/objective/service/ObjectiveServiceTest.java @@ -160,7 +160,7 @@ static void setUp() { @Test @DisplayName("Objective의 히스토리를 조회합니다") void Objective의_히스토리를_조회합니다() { - //given + // given Objective objective1 = Objective.builder() .user(fakeUser) .title("Objective 1") diff --git a/moonshot-api/src/test/java/org/moonshot/task/service/TaskServiceTest.java b/moonshot-api/src/test/java/org/moonshot/task/service/TaskServiceTest.java new file mode 100644 index 00000000..5836ae31 --- /dev/null +++ b/moonshot-api/src/test/java/org/moonshot/task/service/TaskServiceTest.java @@ -0,0 +1,113 @@ +package org.moonshot.task.service; + +import org.junit.jupiter.api.BeforeAll; +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.moonshot.exception.BadRequestException; +import org.moonshot.keyresult.model.KeyResult; +import org.moonshot.keyresult.repository.KeyResultRepository; +import org.moonshot.objective.model.Objective; +import org.moonshot.task.dto.request.TaskSingleCreateRequestDto; +import org.moonshot.task.repository.TaskRepository; +import org.moonshot.user.model.User; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class TaskServiceTest { + + @Mock + private KeyResultRepository keyResultRepository; + + @Mock + private TaskRepository taskRepository; + + @InjectMocks + private TaskService taskService; + + private static User fakeUser; + + @BeforeAll + static void setUp() { + Long fakeUserId = 1L; + String fakeUserNickname = "tester"; + fakeUser = User.buildWithId().id(fakeUserId).nickname(fakeUserNickname).build(); + } + + @Test + @DisplayName("단일 Task를 생성합니다") + void 단일_Task를_생성합니다() { + // given + Objective testObjective = mock(Objective.class); + KeyResult testKeyResult = mock(KeyResult.class); + TaskSingleCreateRequestDto request = new TaskSingleCreateRequestDto( + 1L, "test task", 0); + + given(keyResultRepository.findKeyResultAndObjective(request.keyResultId())).willReturn(Optional.of(testKeyResult)); + given(testKeyResult.getObjective()).willReturn(testObjective); + given(testObjective.getUser()).willReturn(fakeUser); + given(testKeyResult.getId()).willReturn(1L); + + // when + taskService.createTask(request, fakeUser.getId()); + + // then + verify(taskRepository, times(1)).bulkUpdateTaskIdxIncrease(any(Integer.class), any(Integer.class), eq(1L), eq(-1L)); + } + + @Test + @DisplayName("Task 생성 시 최대 보유 갯수를 넘어 예외가 발생합니다") + void Task_생성_시_최대_보유_갯수를_넘어_예외가_발생합니다() { + // given + Objective testObjective = mock(Objective.class); + KeyResult testKeyResult = mock(KeyResult.class); + List taskList = mock(List.class); + TaskSingleCreateRequestDto request = new TaskSingleCreateRequestDto( + 1L, "test task", 0); + + given(keyResultRepository.findKeyResultAndObjective(request.keyResultId())).willReturn(Optional.of(testKeyResult)); + given(testKeyResult.getObjective()).willReturn(testObjective); + given(testObjective.getUser()).willReturn(fakeUser); + given(taskList.size()).willReturn(3); + given(taskRepository.findAllByKeyResultOrderByIdx(testKeyResult)).willReturn(taskList); + + // when, then + assertThatThrownBy(() -> taskService.createTask(request, fakeUser.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage("허용된 Task 개수를 초과하였습니다."); + } + + @Test + @DisplayName("Task 생성 시 인덱스가 0 ~ ListSize(최대 개수의 범위)를 벗어나 요청된 경우 예외가 발생합니다") + void Task_생성_시_인덱스가_0_ListSize를_벗어나_요청된_경우_예외가_발생합니다() { + // given + Objective testObjective = mock(Objective.class); + KeyResult testKeyResult = mock(KeyResult.class); + List taskList = mock(List.class); + TaskSingleCreateRequestDto request = new TaskSingleCreateRequestDto( + 1L, "test task", -1); + + given(keyResultRepository.findKeyResultAndObjective(request.keyResultId())).willReturn(Optional.of(testKeyResult)); + given(testKeyResult.getObjective()).willReturn(testObjective); + given(testObjective.getUser()).willReturn(fakeUser); + given(taskList.size()).willReturn(2); + given(taskRepository.findAllByKeyResultOrderByIdx(testKeyResult)).willReturn(taskList); + + // when, then + assertThatThrownBy(() -> taskService.createTask(request, fakeUser.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage("정상적이지 않은 Task 위치입니다."); + } + +} diff --git a/moonshot-auth/src/main/java/org/moonshot/config/CacheConfig.java b/moonshot-auth/src/main/java/org/moonshot/config/CacheConfig.java new file mode 100644 index 00000000..e6047516 --- /dev/null +++ b/moonshot-auth/src/main/java/org/moonshot/config/CacheConfig.java @@ -0,0 +1,35 @@ +package org.moonshot.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableCaching +@RequiredArgsConstructor +public class CacheConfig { + + private final RedisConnectionFactory redisConnectionFactory; + private final ObjectMapper objectMapper; + + @Bean + public CacheManager redisCacheManager() { + RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) + .entryTtl(Duration.ofSeconds(60 * 60)); + + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory).cacheDefaults(redisCacheConfiguration).build(); + } + +} diff --git a/moonshot-auth/src/main/java/org/moonshot/config/RedisConfig.java b/moonshot-auth/src/main/java/org/moonshot/config/RedisConfig.java index 7cf704b9..1537aef2 100644 --- a/moonshot-auth/src/main/java/org/moonshot/config/RedisConfig.java +++ b/moonshot-auth/src/main/java/org/moonshot/config/RedisConfig.java @@ -1,5 +1,7 @@ package org.moonshot.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -8,10 +10,12 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @EnableRedisRepositories +@RequiredArgsConstructor public class RedisConfig { @Value("${redis.host}") @@ -20,6 +24,8 @@ public class RedisConfig { @Value("${redis.port}") private int port; + private final ObjectMapper objectMapper; + @Bean public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(host, port); @@ -30,7 +36,7 @@ public RedisConnectionFactory redisConnectionFactory() { public RedisTemplate redisTemplate() { RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)); redisTemplate.setConnectionFactory(redisConnectionFactory()); return redisTemplate; } diff --git a/moonshot-auth/src/main/java/org/moonshot/security/service/UserPrincipalDetailsService.java b/moonshot-auth/src/main/java/org/moonshot/security/service/UserPrincipalDetailsService.java index 027d9d3a..ae4bc332 100644 --- a/moonshot-auth/src/main/java/org/moonshot/security/service/UserPrincipalDetailsService.java +++ b/moonshot-auth/src/main/java/org/moonshot/security/service/UserPrincipalDetailsService.java @@ -17,7 +17,7 @@ public class UserPrincipalDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - return userRepository.findById(Long.parseLong(username)) + return userRepository.findByIdWithCache(Long.parseLong(username)) .map(UserPrincipal::new) .orElseThrow(UnauthorizedException::new); } diff --git a/moonshot-common/src/main/java/org/moonshot/constants/UserConstants.java b/moonshot-common/src/main/java/org/moonshot/constants/UserConstants.java new file mode 100644 index 00000000..34fe372b --- /dev/null +++ b/moonshot-common/src/main/java/org/moonshot/constants/UserConstants.java @@ -0,0 +1,7 @@ +package org.moonshot.constants; + +public class UserConstants { + + public static final Long USER_RETENTION_PERIOD = 14L; + +} diff --git a/moonshot-common/src/main/java/org/moonshot/response/ErrorType.java b/moonshot-common/src/main/java/org/moonshot/response/ErrorType.java index 3944cba9..dbe63cc0 100644 --- a/moonshot-common/src/main/java/org/moonshot/response/ErrorType.java +++ b/moonshot-common/src/main/java/org/moonshot/response/ErrorType.java @@ -30,6 +30,7 @@ public enum ErrorType { INVALID_KEY_RESULT_PERIOD(HttpStatus.BAD_REQUEST, 4016, "KeyResult 기간 설정이 올바르지 않습니다."), REQUIRED_EXPIRE_AT(HttpStatus.BAD_REQUEST, 4017, "기간 연장시 목표 종료 기간은 필수 입력값입니다."), REQUIRED_KEY_RESULT_VALUE(HttpStatus.BAD_REQUEST, 4018, "KR 수정시 목표값과 체크인 로그는 필수 입력값입니다."), + NOT_SUPPORTED_LOGIN_PLATFORM(HttpStatus.BAD_REQUEST, 4019, "지원하지 않는 소셜 로그인 플랫폼입니다."), /** * 401 UNAUTHROZIED (4100 ~ 4199) diff --git a/moonshot-domain/src/main/java/org/moonshot/user/model/User.java b/moonshot-domain/src/main/java/org/moonshot/user/model/User.java index f35516be..f2733f76 100644 --- a/moonshot-domain/src/main/java/org/moonshot/user/model/User.java +++ b/moonshot-domain/src/main/java/org/moonshot/user/model/User.java @@ -1,5 +1,7 @@ package org.moonshot.user.model; +import static org.moonshot.constants.UserConstants.USER_RETENTION_PERIOD; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -21,9 +23,6 @@ @Builder(builderMethodName = "buildWithId") public class User { - // TODO 기획 측 약관 확정 이후 수정 필요 - private static final Long USER_RETENTION_PERIOD = 14L; - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") diff --git a/moonshot-domain/src/main/java/org/moonshot/user/repository/UserCustomRepository.java b/moonshot-domain/src/main/java/org/moonshot/user/repository/UserCustomRepository.java new file mode 100644 index 00000000..9c877853 --- /dev/null +++ b/moonshot-domain/src/main/java/org/moonshot/user/repository/UserCustomRepository.java @@ -0,0 +1,10 @@ +package org.moonshot.user.repository; + +import java.util.Optional; +import org.moonshot.user.model.User; + +public interface UserCustomRepository { + + Optional findByIdWithCache(Long id); + +} diff --git a/moonshot-domain/src/main/java/org/moonshot/user/repository/UserCustomRepositoryImpl.java b/moonshot-domain/src/main/java/org/moonshot/user/repository/UserCustomRepositoryImpl.java new file mode 100644 index 00000000..73c236ea --- /dev/null +++ b/moonshot-domain/src/main/java/org/moonshot/user/repository/UserCustomRepositoryImpl.java @@ -0,0 +1,23 @@ +package org.moonshot.user.repository; + +import static org.moonshot.user.model.QUser.user; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.moonshot.user.model.User; +import org.springframework.cache.annotation.Cacheable; + +@RequiredArgsConstructor +public class UserCustomRepositoryImpl implements UserCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + @Cacheable(value = "user", cacheManager = "redisCacheManager") + public Optional findByIdWithCache(final Long id) { + return Optional.ofNullable(queryFactory.selectFrom(user) + .where(user.id.eq(id)).fetchOne()); + } + +} diff --git a/moonshot-domain/src/main/java/org/moonshot/user/repository/UserJpaRepository.java b/moonshot-domain/src/main/java/org/moonshot/user/repository/UserJpaRepository.java new file mode 100644 index 00000000..8ddcbb19 --- /dev/null +++ b/moonshot-domain/src/main/java/org/moonshot/user/repository/UserJpaRepository.java @@ -0,0 +1,18 @@ +package org.moonshot.user.repository; + + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.moonshot.user.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface UserJpaRepository extends JpaRepository { + + Optional findUserBySocialId(String socialId); + @Query("SELECT u FROM User u WHERE u.deleteAt < :currentDate") + List findIdByDeletedAtBefore(LocalDateTime currentDate); + +} + diff --git a/moonshot-domain/src/main/java/org/moonshot/user/repository/UserRepository.java b/moonshot-domain/src/main/java/org/moonshot/user/repository/UserRepository.java index 2527f08d..3b463772 100644 --- a/moonshot-domain/src/main/java/org/moonshot/user/repository/UserRepository.java +++ b/moonshot-domain/src/main/java/org/moonshot/user/repository/UserRepository.java @@ -1,19 +1,4 @@ package org.moonshot.user.repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import org.moonshot.user.model.User; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -public interface UserRepository extends JpaRepository { - - Optional findUserBySocialId(String socialId); - Optional findUserByNickname(String nickname); - @Query("SELECT u FROM User u WHERE u.deleteAt < :currentDate") - List findIdByDeletedAtBefore(LocalDateTime currentDate); - +public interface UserRepository extends UserJpaRepository, UserCustomRepository { } - diff --git a/moonshot-external/src/main/java/org/moonshot/discord/DiscordAppender.java b/moonshot-external/src/main/java/org/moonshot/discord/DiscordAppender.java index 3d8ccf7e..3901b145 100644 --- a/moonshot-external/src/main/java/org/moonshot/discord/DiscordAppender.java +++ b/moonshot-external/src/main/java/org/moonshot/discord/DiscordAppender.java @@ -118,11 +118,11 @@ protected void append(ILoggingEvent eventObject) { } } - public void signInAppend(String name, String email, String socialPlatform, LocalDateTime createdAt, String imgUrl){ + public void signInAppend(Long totalUserCount, String name, String email, String socialPlatform, LocalDateTime createdAt, String imgUrl){ DiscordWebHook discordWebhook = new DiscordWebHook(DiscordConstants.signInWebhookUrl, username, avatarUrl, false); discordWebhook.addEmbed(new EmbedObject() - .setTitle("🚀[회원 가입] 새로운 유저가 가입하였습니다.🚀") + .setTitle("🚀[회원 가입] " + totalUserCount + "번쨰 유저가 가입하였습니다.🚀") .setColor(Color.CYAN) .setDescription("moonshot에 새로운 유저가 가입하였습니다.") .setThumbnail(imgUrl) diff --git a/moonshot-external/src/main/java/org/moonshot/discord/SignUpEvent.java b/moonshot-external/src/main/java/org/moonshot/discord/SignUpEvent.java index 672d9669..b4ec45f4 100644 --- a/moonshot-external/src/main/java/org/moonshot/discord/SignUpEvent.java +++ b/moonshot-external/src/main/java/org/moonshot/discord/SignUpEvent.java @@ -3,11 +3,11 @@ import java.time.LocalDateTime; -public record SignUpEvent(String name, String email, String socialPlatform, LocalDateTime createdAt, String imageUrl) { +public record SignUpEvent(Long totalUserCount, String name, String email, String socialPlatform, LocalDateTime createdAt, String imageUrl) { - public static SignUpEvent of(String name, String email, String socialPlatform, LocalDateTime createdAt, + public static SignUpEvent of(Long totalUserCount, String name, String email, String socialPlatform, LocalDateTime createdAt, String imageUrl) { - return new SignUpEvent(name, email, socialPlatform, createdAt, imageUrl); + return new SignUpEvent(totalUserCount, name, email, socialPlatform, createdAt, imageUrl); } } diff --git a/moonshot-external/src/main/java/org/moonshot/discord/SignUpEventListener.java b/moonshot-external/src/main/java/org/moonshot/discord/SignUpEventListener.java index 7683e237..d78bc54b 100644 --- a/moonshot-external/src/main/java/org/moonshot/discord/SignUpEventListener.java +++ b/moonshot-external/src/main/java/org/moonshot/discord/SignUpEventListener.java @@ -15,6 +15,7 @@ public class SignUpEventListener { @EventListener public void handleSignUpEvent(SignUpEvent event) { discordAppender.signInAppend( + event.totalUserCount(), event.name(), event.email(), event.socialPlatform(),