diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 738fe7c..7a8dedf 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -70,7 +70,8 @@ jobs: envs: GITHUB_SHA script: | cd dnd-12th-7-backend - sudo docker compose down - sudo docker compose rm -f + sudo docker compose stop app + sudo docker compose rm -f app sudo docker rmi ${{env.DOCKER_IMAGE_NAME}}:latest - sudo docker compose up -d + sudo docker compose pull app + sudo docker compose up -d app diff --git a/src/docs/asciidoc/auth.adoc b/src/docs/asciidoc/auth.adoc index 46ce15f..2e91169 100644 --- a/src/docs/asciidoc/auth.adoc +++ b/src/docs/asciidoc/auth.adoc @@ -50,4 +50,29 @@ include::{snippets}/auth-controller-test/reissue-access-token/http-response.adoc ==== 응답 -include::{snippets}/auth-controller-test/reissue-access-token/response-body.adoc[] \ No newline at end of file +include::{snippets}/auth-controller-test/reissue-access-token/response-body.adoc[] + +== 카카오톡 소셜 로그인 + +사용자가 카카오 소셜 로그인을 완료하면, 인가 코드를 통해 카카오 Access Token을 발급받고, 이를 이용해 카카오 사용자 정보를 조회합니다. +조회된 사용자 정보로 서비스의 Access Token을 생성한 후, 해당 토큰은 쿠키를 통해 클라이언트에 전달됩니다. + +=== Example + +include::{snippets}/auth-controller-test/kakao-login-callback/curl-request.adoc[] + +=== HTTP + +==== 요청 + +include::{snippets}/auth-controller-test/kakao-login-callback/http-request.adoc[] + +==== 응답 + +include::{snippets}/auth-controller-test/kakao-login-callback/http-response.adoc[] + +=== Body + +==== 응답 + +include::{snippets}/auth-controller-test/kakao-login-callback/response-body.adoc[] \ No newline at end of file diff --git a/src/main/java/com/dnd/moddo/ModdoApplication.java b/src/main/java/com/dnd/moddo/ModdoApplication.java index 25ebbc2..3923d8d 100644 --- a/src/main/java/com/dnd/moddo/ModdoApplication.java +++ b/src/main/java/com/dnd/moddo/ModdoApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication(exclude = SecurityAutoConfiguration.class) +@ConfigurationPropertiesScan public class ModdoApplication { public static void main(String[] args) { diff --git a/src/main/java/com/dnd/moddo/domain/auth/controller/AuthController.java b/src/main/java/com/dnd/moddo/domain/auth/controller/AuthController.java index 0246aa1..4aed35f 100644 --- a/src/main/java/com/dnd/moddo/domain/auth/controller/AuthController.java +++ b/src/main/java/com/dnd/moddo/domain/auth/controller/AuthController.java @@ -1,29 +1,97 @@ package com.dnd.moddo.domain.auth.controller; +import java.util.Collections; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + import com.dnd.moddo.domain.auth.service.AuthService; import com.dnd.moddo.domain.auth.service.RefreshTokenService; +import com.dnd.moddo.global.config.CookieProperties; import com.dnd.moddo.global.jwt.dto.RefreshResponse; import com.dnd.moddo.global.jwt.dto.TokenResponse; +import com.dnd.moddo.global.jwt.service.JwtService; + import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RestController -@RequestMapping("/api/v1/user") +@Validated +@RequestMapping("/api/v1") public class AuthController { - private final AuthService authService; - private final RefreshTokenService refreshTokenService; + private final AuthService authService; + private final RefreshTokenService refreshTokenService; + private final CookieProperties cookieProperties; + private final JwtService jwtService; + + @GetMapping("/user/guest/token") + public ResponseEntity getGuestToken() { + TokenResponse tokenResponse = authService.loginWithGuest(); + + String cookie = createCookie("accessToken", tokenResponse.accessToken()).toString(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie) + .body(tokenResponse); + } + + @PutMapping("/user/reissue/token") + public RefreshResponse reissueAccessToken(@RequestHeader(value = "Authorization") @NotBlank String refreshToken) { + return refreshTokenService.execute(refreshToken); + } + + @GetMapping("/login/oauth2/callback") + public ResponseEntity kakaoLoginCallback(@RequestParam @NotBlank String code) { + + TokenResponse tokenResponse = authService.loginOrRegisterWithKakao(code); + + String cookie = createCookie("accessToken", tokenResponse.accessToken()).toString(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie) + .build(); + } + + @GetMapping("/logout") + public ResponseEntity kakaoLogout(@CookieValue(value = "accessToken") String token) { + String cookie = expireCookie("accessToken").toString(); + Long userId = jwtService.getUserId(token); + authService.logout(userId); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie) + .body(Collections.singletonMap("message", "Logout successful")); + } + + private ResponseCookie createCookie(String name, String key) { + return ResponseCookie.from(name, key) + .httpOnly(cookieProperties.httpOnly()) + .secure(cookieProperties.secure()) + .path(cookieProperties.path()) + .sameSite(cookieProperties.sameSite()) + .maxAge(cookieProperties.maxAge()) + .build(); + + } - @GetMapping("/guest/token") - public ResponseEntity getGuestToken() { - return ResponseEntity.ok(authService.createGuestUser()); - } + private ResponseCookie expireCookie(String name) { + return ResponseCookie.from(name, null) + .httpOnly(cookieProperties.httpOnly()) + .secure(cookieProperties.secure()) + .path(cookieProperties.path()) + .sameSite(cookieProperties.sameSite()) + .maxAge(0L) + .build(); + } - @PutMapping("/reissue/token") - public RefreshResponse reissueAccessToken(@RequestHeader(value = "Authorization") @NotBlank String refreshToken) { - return refreshTokenService.execute(refreshToken); - } } \ No newline at end of file diff --git a/src/main/java/com/dnd/moddo/domain/auth/dto/KakaoLogoutResponse.java b/src/main/java/com/dnd/moddo/domain/auth/dto/KakaoLogoutResponse.java new file mode 100644 index 0000000..14fa575 --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/auth/dto/KakaoLogoutResponse.java @@ -0,0 +1,4 @@ +package com.dnd.moddo.domain.auth.dto; + +public record KakaoLogoutResponse(Long id) { +} diff --git a/src/main/java/com/dnd/moddo/domain/auth/dto/KakaoProfile.java b/src/main/java/com/dnd/moddo/domain/auth/dto/KakaoProfile.java new file mode 100644 index 0000000..2cf1866 --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/auth/dto/KakaoProfile.java @@ -0,0 +1,25 @@ +package com.dnd.moddo.domain.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoProfile( + Long id, + @JsonProperty("kakao_account") KakaoAccount kakaoAccount, + Properties properties +) { + public record Properties( + String nickname + ) { + } + + public record KakaoAccount( + String email, + Profile profile + ) { + } + + public record Profile( + String nickname + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/dnd/moddo/domain/auth/dto/KakaoTokenResponse.java b/src/main/java/com/dnd/moddo/domain/auth/dto/KakaoTokenResponse.java new file mode 100644 index 0000000..7e19764 --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/auth/dto/KakaoTokenResponse.java @@ -0,0 +1,9 @@ +package com.dnd.moddo.domain.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoTokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") int expiresIn +) { +} \ No newline at end of file diff --git a/src/main/java/com/dnd/moddo/domain/auth/service/AuthService.java b/src/main/java/com/dnd/moddo/domain/auth/service/AuthService.java index 3dc8936..8aba9c9 100644 --- a/src/main/java/com/dnd/moddo/domain/auth/service/AuthService.java +++ b/src/main/java/com/dnd/moddo/domain/auth/service/AuthService.java @@ -1,43 +1,77 @@ package com.dnd.moddo.domain.auth.service; -import java.time.LocalDateTime; import java.util.UUID; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.dnd.moddo.domain.auth.dto.KakaoLogoutResponse; +import com.dnd.moddo.domain.auth.dto.KakaoProfile; +import com.dnd.moddo.domain.auth.dto.KakaoTokenResponse; +import com.dnd.moddo.domain.user.dto.request.GuestUserSaveRequest; +import com.dnd.moddo.domain.user.dto.request.UserSaveRequest; import com.dnd.moddo.domain.user.entity.User; -import com.dnd.moddo.domain.user.entity.type.Authority; -import com.dnd.moddo.domain.user.repository.UserRepository; +import com.dnd.moddo.domain.user.service.CommandUserService; +import com.dnd.moddo.domain.user.service.QueryUserService; +import com.dnd.moddo.global.exception.ModdoException; import com.dnd.moddo.global.jwt.dto.TokenResponse; import com.dnd.moddo.global.jwt.utill.JwtProvider; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor @Service +@Slf4j public class AuthService { - private final UserRepository userRepository; + private final CommandUserService commandUserService; + private final QueryUserService queryUserService; private final JwtProvider jwtProvider; + private final KakaoClient kakaoClient; @Transactional - public TokenResponse createGuestUser() { + public TokenResponse loginWithGuest() { String guestEmail = "guest-" + UUID.randomUUID() + "@guest.com"; + GuestUserSaveRequest request = new GuestUserSaveRequest(guestEmail, "Guest"); - User guestUser = User.builder() - .email(guestEmail) - .name("Guest") - .profile(null) - .createdAt(LocalDateTime.now()) - .expiredAt(LocalDateTime.now().plusMonths(1)) - .authority(Authority.USER) - .isMember(false) - .build(); + User user = commandUserService.createGuestUser(request); - userRepository.save(guestUser); + return jwtProvider.generateToken(user); + } + + @Transactional + public TokenResponse loginOrRegisterWithKakao(String code) { + KakaoTokenResponse tokenResponse = kakaoClient.join(code); + KakaoProfile kakaoProfile = kakaoClient.getKakaoProfile(tokenResponse.accessToken()); + + String email = kakaoProfile.kakaoAccount().email(); + String nickname = kakaoProfile.properties().nickname(); + Long kakaoId = kakaoProfile.id(); + + if (email == null || nickname == null || kakaoId == null) { + throw new ModdoException(HttpStatus.BAD_REQUEST, "카카오 프로필 정보가 누락되었습니다."); + } + + UserSaveRequest request = new UserSaveRequest(email, nickname, kakaoId); + User user = commandUserService.getOrCreateUser(request); - return jwtProvider.generateToken(guestUser.getId(), guestUser.getEmail(), guestUser.getAuthority().toString(), - guestUser.getIsMember()); + log.info("[USER_LOGIN] 로그인 성공 : code = {}, kakaoId = {}, nickname = {}", code, kakaoId, nickname); + + return jwtProvider.generateToken(user); + } + + public void logout(Long userId) { + queryUserService.findKakaoIdById(userId).ifPresent(kakaoId -> { + KakaoLogoutResponse logoutResponse = kakaoClient.logout(kakaoId); + + if (!kakaoId.equals(logoutResponse.id())) { + throw new ModdoException(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 로그아웃 실패: id 불일치"); + } + + log.info("[USER_LOGOUT] 카카오 로그아웃 성공: userId={}, kakaoId={}", userId, kakaoId); + }); } + } \ No newline at end of file diff --git a/src/main/java/com/dnd/moddo/domain/auth/service/KakaoClient.java b/src/main/java/com/dnd/moddo/domain/auth/service/KakaoClient.java new file mode 100644 index 0000000..f846bd6 --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/auth/service/KakaoClient.java @@ -0,0 +1,100 @@ +package com.dnd.moddo.domain.auth.service; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import com.dnd.moddo.domain.auth.dto.KakaoLogoutResponse; +import com.dnd.moddo.domain.auth.dto.KakaoProfile; +import com.dnd.moddo.domain.auth.dto.KakaoTokenResponse; +import com.dnd.moddo.global.config.KakaoProperties; +import com.dnd.moddo.global.exception.ModdoException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +@Component +public class KakaoClient { + private final KakaoProperties kakaoProperties; + private final RestClient.Builder builder; + + public KakaoTokenResponse join(String code) { + RestClient restClient = builder.build(); + + String uri = kakaoProperties.tokenRequestUri(); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", kakaoProperties.clientId()); + params.add("redirect_uri", kakaoProperties.redirectUri()); + params.add("code", code); + + try { + return restClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(params) + .retrieve() + .body(KakaoTokenResponse.class); + + } catch (RestClientResponseException e) { + log.error("[KAKAO_API][GET_TOKEN][HTTP_ERROR] HTTP 에러 발생: status={}, body={}", e.getStatusCode(), + e.getResponseBodyAsString()); + throw new ModdoException(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 API HTTP 에러"); + } catch (Exception e) { + log.info("[USER_LOGIN_FAIL] 로그인 실패 : code = {}", code); + throw new IllegalArgumentException(e.getMessage()); + } + } + + public KakaoProfile getKakaoProfile(String token) { + RestClient restClient = builder.build(); + + String uri = kakaoProperties.profileRequestUri(); + + try { + return restClient.get() + .uri(uri) + .header("Authorization", "Bearer " + token) + .retrieve() + .body(KakaoProfile.class); + + } catch (RestClientResponseException e) { + log.error("[KAKAO_API][GET_PROFILE][HTTP_ERROR] HTTP 에러 발생: status={}, body={}", e.getStatusCode(), + e.getResponseBodyAsString()); + throw new ModdoException(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 API HTTP 에러"); + } catch (Exception e) { + log.error("[KAKAO_CALLBACK_ERROR] 카카오 콜백 처리 실패", e); + throw new ModdoException(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 콜백 처리 실패"); + } + } + + public KakaoLogoutResponse logout(Long kakaoId) { + RestClient restClient = builder.build(); + String uri = kakaoProperties.logoutRequestUri(); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("target_id_type", "user_id"); + params.add("target_id", kakaoId.toString()); + + try { + return restClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoProperties.adminKey()) + .body(params) + .retrieve() + .body(KakaoLogoutResponse.class); + } catch (Exception e) { + log.error("[KAKAO_CALLBACK_ERROR] 카카오 콜백 처리 실패", e.getMessage()); + throw new ModdoException(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 콜백 처리 실패"); + } + } +} diff --git a/src/main/java/com/dnd/moddo/domain/auth/service/RefreshTokenService.java b/src/main/java/com/dnd/moddo/domain/auth/service/RefreshTokenService.java index 47b2504..458e6ef 100644 --- a/src/main/java/com/dnd/moddo/domain/auth/service/RefreshTokenService.java +++ b/src/main/java/com/dnd/moddo/domain/auth/service/RefreshTokenService.java @@ -1,5 +1,7 @@ package com.dnd.moddo.domain.auth.service; +import org.springframework.stereotype.Service; + import com.dnd.moddo.domain.auth.exception.TokenInvalidException; import com.dnd.moddo.domain.user.entity.User; import com.dnd.moddo.domain.user.repository.UserRepository; @@ -7,32 +9,32 @@ import com.dnd.moddo.global.jwt.properties.JwtConstants; import com.dnd.moddo.global.jwt.utill.JwtProvider; import com.dnd.moddo.global.jwt.utill.JwtUtil; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; @RequiredArgsConstructor @Service public class RefreshTokenService { - private final JwtUtil jwtUtil; - private final UserRepository userRepository; - private final JwtProvider jwtProvider; + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final JwtProvider jwtProvider; - public RefreshResponse execute(String token) { + public RefreshResponse execute(String token) { - String email; + String email; - try { - email = jwtUtil.getJwt(jwtUtil.parseToken(token)).getBody().get(JwtConstants.EMAIL.message).toString(); - } catch (Exception e) { - throw new TokenInvalidException(); - } + try { + email = jwtUtil.getJwt(jwtUtil.parseToken(token)).getBody().get(JwtConstants.EMAIL.message).toString(); + } catch (Exception e) { + throw new TokenInvalidException(); + } - User user = userRepository.getByEmail(email); - String newAccessToken = jwtProvider.generateAccessToken(user.getId(), user.getEmail(), user.getAuthority().toString()); + User user = userRepository.getByEmail(email); + String newAccessToken = jwtProvider.generateAccessToken(user.getId(), user.getAuthority().toString()); - return RefreshResponse.builder() - .accessToken(newAccessToken) - .build(); - } + return RefreshResponse.builder() + .accessToken(newAccessToken) + .build(); + } } diff --git a/src/main/java/com/dnd/moddo/domain/user/dto/request/GuestUserSaveRequest.java b/src/main/java/com/dnd/moddo/domain/user/dto/request/GuestUserSaveRequest.java new file mode 100644 index 0000000..a5f4b67 --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/user/dto/request/GuestUserSaveRequest.java @@ -0,0 +1,21 @@ +package com.dnd.moddo.domain.user.dto.request; + +import java.time.LocalDateTime; + +import com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.entity.type.Authority; + +public record GuestUserSaveRequest(String email, String name) { + public User toEntity() { + return User.builder() + .email(email) + .name(name) + .kakaoId(null) + .isMember(false) + .authority(Authority.USER) + .profile(null) + .createdAt(LocalDateTime.now()) + .expiredAt(LocalDateTime.now().plusMonths(1)) + .build(); + } +} diff --git a/src/main/java/com/dnd/moddo/domain/user/dto/request/UserSaveRequest.java b/src/main/java/com/dnd/moddo/domain/user/dto/request/UserSaveRequest.java new file mode 100644 index 0000000..0b91093 --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/user/dto/request/UserSaveRequest.java @@ -0,0 +1,25 @@ +package com.dnd.moddo.domain.user.dto.request; + +import java.time.LocalDateTime; + +import com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.entity.type.Authority; + +public record UserSaveRequest( + String email, + String name, + Long kakaoId +) { + public User toEntity() { + return User.builder() + .email(email) + .name(name) + .kakaoId(kakaoId) + .isMember(true) + .authority(Authority.USER) + .profile(null) + .createdAt(LocalDateTime.now()) + .expiredAt(LocalDateTime.now().plusMonths(1)) + .build(); + } +} diff --git a/src/main/java/com/dnd/moddo/domain/user/entity/User.java b/src/main/java/com/dnd/moddo/domain/user/entity/User.java index 8005d6d..88aa99c 100644 --- a/src/main/java/com/dnd/moddo/domain/user/entity/User.java +++ b/src/main/java/com/dnd/moddo/domain/user/entity/User.java @@ -1,45 +1,60 @@ package com.dnd.moddo.domain.user.entity; +import java.time.LocalDateTime; + import com.dnd.moddo.domain.user.entity.type.Authority; -import jakarta.persistence.*; -import lombok.*; -import java.time.LocalDateTime; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "users") -public class User{ +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + private String name; - private String name; + private String email; - private String email; + private String profile; - private String profile; + private Boolean isMember; - private Boolean isMember; + private Long kakaoId; - private LocalDateTime createdAt; + private LocalDateTime createdAt; - private LocalDateTime expiredAt; + private LocalDateTime expiredAt; - @Enumerated(EnumType.STRING) - private Authority authority; + @Enumerated(EnumType.STRING) + private Authority authority; - @Builder - public User(String name, String email, String profile, Boolean isMember, Authority authority, LocalDateTime createdAt, LocalDateTime expiredAt) { - this.name = name; - this.email = email; - this.profile = profile; - this.isMember = isMember; - this.createdAt = createdAt; - this.expiredAt = expiredAt; - this.authority = authority; - } + @Builder + public User(String name, String email, String profile, Boolean isMember, Authority authority, Long kakaoId, + LocalDateTime createdAt, LocalDateTime expiredAt) { + this.name = name; + this.email = email; + this.profile = profile; + this.isMember = isMember; + this.kakaoId = kakaoId; + this.createdAt = createdAt; + this.expiredAt = expiredAt; + this.authority = authority; + } } diff --git a/src/main/java/com/dnd/moddo/domain/user/repository/UserRepository.java b/src/main/java/com/dnd/moddo/domain/user/repository/UserRepository.java index d2788cb..65afb20 100644 --- a/src/main/java/com/dnd/moddo/domain/user/repository/UserRepository.java +++ b/src/main/java/com/dnd/moddo/domain/user/repository/UserRepository.java @@ -1,26 +1,31 @@ package com.dnd.moddo.domain.user.repository; +import java.util.Optional; -import com.dnd.moddo.domain.user.entity.User; -import com.dnd.moddo.domain.user.exception.UserNotFoundException; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; -import java.util.Optional; +import com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.exception.UserNotFoundException; @Repository public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); + Optional findByEmail(String email); + + Optional findByKakaoId(Long kakaoId); - default User getByEmail(String email) { - return findByEmail(email) - .orElseThrow(() -> new UserNotFoundException(email)); - } + @Query("SELECT u.kakaoId FROM User u WHERE u.id = :userId") + Optional findKakaoIdById(Long userId); + default User getByEmail(String email) { + return findByEmail(email) + .orElseThrow(() -> new UserNotFoundException(email)); + } - default User getById(Long id) { - return findById(id) - .orElseThrow(() -> new UserNotFoundException(id)); - } + default User getById(Long id) { + return findById(id) + .orElseThrow(() -> new UserNotFoundException(id)); + } } diff --git a/src/main/java/com/dnd/moddo/domain/user/service/CommandUserService.java b/src/main/java/com/dnd/moddo/domain/user/service/CommandUserService.java new file mode 100644 index 0000000..bcafbb0 --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/user/service/CommandUserService.java @@ -0,0 +1,35 @@ +package com.dnd.moddo.domain.user.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.domain.user.dto.request.GuestUserSaveRequest; +import com.dnd.moddo.domain.user.dto.request.UserSaveRequest; +import com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.service.implementation.UserCreator; +import com.dnd.moddo.domain.user.service.implementation.UserReader; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class CommandUserService { + private final UserCreator userCreator; + private final UserReader userReader; + + @Transactional + public User createGuestUser(GuestUserSaveRequest request) { + return userCreator.createUser(request.toEntity()); + } + + @Transactional + public User createKakaoUser(UserSaveRequest request) { + return userCreator.createUser(request.toEntity()); + } + + @Transactional + public User getOrCreateUser(UserSaveRequest request) { + return userReader.findByKakaoId(request.kakaoId()) + .orElseGet(() -> createKakaoUser(request)); + } +} diff --git a/src/main/java/com/dnd/moddo/domain/user/service/QueryUserService.java b/src/main/java/com/dnd/moddo/domain/user/service/QueryUserService.java new file mode 100644 index 0000000..d127d6d --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/user/service/QueryUserService.java @@ -0,0 +1,20 @@ +package com.dnd.moddo.domain.user.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.dnd.moddo.domain.user.service.implementation.UserReader; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class QueryUserService { + private final UserReader userReader; + + public Optional findKakaoIdById(Long userId) { + return userReader.findKakaoIdById(userId); + } +} + diff --git a/src/main/java/com/dnd/moddo/domain/user/service/implementation/UserCreator.java b/src/main/java/com/dnd/moddo/domain/user/service/implementation/UserCreator.java new file mode 100644 index 0000000..feb9fda --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/user/service/implementation/UserCreator.java @@ -0,0 +1,21 @@ +package com.dnd.moddo.domain.user.service.implementation; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +@Transactional +public class UserCreator { + + private final UserRepository userRepository; + + public User createUser(User user) { + return userRepository.save(user); + } +} diff --git a/src/main/java/com/dnd/moddo/domain/user/service/implementation/UserReader.java b/src/main/java/com/dnd/moddo/domain/user/service/implementation/UserReader.java new file mode 100644 index 0000000..3a5327d --- /dev/null +++ b/src/main/java/com/dnd/moddo/domain/user/service/implementation/UserReader.java @@ -0,0 +1,26 @@ +package com.dnd.moddo.domain.user.service.implementation; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserReader { + private final UserRepository userRepository; + + public Optional findByKakaoId(Long kakaoId) { + return userRepository.findByKakaoId(kakaoId); + } + + public Optional findKakaoIdById(Long userId) { + return userRepository.findKakaoIdById(userId); + } +} diff --git a/src/main/java/com/dnd/moddo/global/config/CookieProperties.java b/src/main/java/com/dnd/moddo/global/config/CookieProperties.java new file mode 100644 index 0000000..53ae9f6 --- /dev/null +++ b/src/main/java/com/dnd/moddo/global/config/CookieProperties.java @@ -0,0 +1,15 @@ +package com.dnd.moddo.global.config; + +import java.time.Duration; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "cookie") +public record CookieProperties( + boolean httpOnly, + boolean secure, + String path, + String sameSite, + Duration maxAge +) { +} diff --git a/src/main/java/com/dnd/moddo/global/config/KakaoProperties.java b/src/main/java/com/dnd/moddo/global/config/KakaoProperties.java new file mode 100644 index 0000000..cfe47d4 --- /dev/null +++ b/src/main/java/com/dnd/moddo/global/config/KakaoProperties.java @@ -0,0 +1,15 @@ +package com.dnd.moddo.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "kakao") +public record KakaoProperties( + String redirectUri, + String clientId, + String adminKey, + String tokenRequestUri, + String profileRequestUri, + String logoutRequestUri +) { + +} diff --git a/src/main/java/com/dnd/moddo/global/config/PropertiesConfig.java b/src/main/java/com/dnd/moddo/global/config/PropertiesConfig.java deleted file mode 100644 index 57019aa..0000000 --- a/src/main/java/com/dnd/moddo/global/config/PropertiesConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.dnd.moddo.global.config; - -import com.dnd.moddo.global.jwt.properties.JwtProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties({JwtProperties.class}) -public class PropertiesConfig { -} \ No newline at end of file diff --git a/src/main/java/com/dnd/moddo/global/jwt/properties/JwtProperties.java b/src/main/java/com/dnd/moddo/global/jwt/properties/JwtProperties.java index fbf5440..edc07f7 100644 --- a/src/main/java/com/dnd/moddo/global/jwt/properties/JwtProperties.java +++ b/src/main/java/com/dnd/moddo/global/jwt/properties/JwtProperties.java @@ -1,27 +1,29 @@ package com.dnd.moddo.global.jwt.properties; +import javax.crypto.SecretKey; + +import org.springframework.boot.context.properties.ConfigurationProperties; + import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import lombok.Getter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -import javax.crypto.SecretKey; @Getter @ConfigurationProperties(prefix = "jwt") public class JwtProperties { - private final String header; - private final String prefix; - private final SecretKey secretKey; - private final Long accessExpiration; - private final Long refreshExpiration; + private final String header; + private final String prefix; + private final SecretKey secretKey; + private final Long accessExpiration; + private final Long refreshExpiration; - public JwtProperties(String header, String prefix, String secretKey, Long accessExpiration, Long refreshExpiration) { - this.header = header; - this.prefix = prefix; - this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); - this.accessExpiration = accessExpiration; - this.refreshExpiration = refreshExpiration; - } + public JwtProperties(String header, String prefix, String secretKey, Long accessExpiration, + Long refreshExpiration) { + this.header = header; + this.prefix = prefix; + this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); + this.accessExpiration = accessExpiration; + this.refreshExpiration = refreshExpiration; + } } diff --git a/src/main/java/com/dnd/moddo/global/jwt/service/JwtService.java b/src/main/java/com/dnd/moddo/global/jwt/service/JwtService.java index d4d04b9..8ca40c4 100644 --- a/src/main/java/com/dnd/moddo/global/jwt/service/JwtService.java +++ b/src/main/java/com/dnd/moddo/global/jwt/service/JwtService.java @@ -1,25 +1,31 @@ package com.dnd.moddo.global.jwt.service; +import org.springframework.stereotype.Service; + import com.dnd.moddo.global.jwt.utill.JwtUtil; + import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class JwtService { - private final JwtUtil jwtUtil; + private final JwtUtil jwtUtil; + + public Long getId(HttpServletRequest request, String key) { + String token = jwtUtil.resolveToken(request); + return jwtUtil.getIdFromToken(token, key); + } - public Long getId(HttpServletRequest request, String key) { - String token = jwtUtil.resolveToken(request); - return jwtUtil.getIdFromToken(token, key); - } + public Long getUserId(HttpServletRequest request) { + return getId(request, "userId"); + } - public Long getUserId(HttpServletRequest request) { - return getId(request, "userId"); - } + public Long getUserId(String token) { + return jwtUtil.getIdFromToken(token, "userId"); + } - public Long getGroupId(String groupToken) { - return jwtUtil.getIdFromToken(groupToken, "groupId"); - } + public Long getGroupId(String groupToken) { + return jwtUtil.getIdFromToken(groupToken, "groupId"); + } } diff --git a/src/main/java/com/dnd/moddo/global/jwt/utill/JwtProvider.java b/src/main/java/com/dnd/moddo/global/jwt/utill/JwtProvider.java index 96a69c4..eea1801 100644 --- a/src/main/java/com/dnd/moddo/global/jwt/utill/JwtProvider.java +++ b/src/main/java/com/dnd/moddo/global/jwt/utill/JwtProvider.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component; +import com.dnd.moddo.domain.user.entity.User; import com.dnd.moddo.global.jwt.dto.TokenResponse; import com.dnd.moddo.global.jwt.properties.JwtProperties; @@ -22,14 +23,18 @@ public class JwtProvider { private final JwtProperties jwtProperties; - public String generateAccessToken(Long id, String email, String role) { - return generateToken(id, email, role, ACCESS_KEY.getMessage(), jwtProperties.getAccessExpiration()); + public String generateAccessToken(Long id, String role) { + return generateToken(id, role, ACCESS_KEY.getMessage(), jwtProperties.getAccessExpiration()); } - public TokenResponse generateToken(Long id, String email, String role, Boolean isMember) { - String accessToken = generateToken(id, email, role, ACCESS_KEY.getMessage(), + public TokenResponse generateToken(User user) { + return generateToken(user.getId(), user.getAuthority().toString(), user.getIsMember()); + } + + public TokenResponse generateToken(Long id, String role, Boolean isMember) { + String accessToken = generateToken(id, role, ACCESS_KEY.getMessage(), jwtProperties.getAccessExpiration()); - String refreshToken = generateToken(id, email, role, REFRESH_KEY.getMessage(), + String refreshToken = generateToken(id, role, REFRESH_KEY.getMessage(), jwtProperties.getRefreshExpiration()); return new TokenResponse(accessToken, refreshToken, getExpiredTime(), isMember); @@ -39,10 +44,9 @@ public String generateGroupToken(Long groupId) { return generateGroupToken(groupId, GROUP_KEY.getMessage()); } - private String generateToken(Long id, String email, String role, String type, Long exp) { + private String generateToken(Long id, String role, String type, Long exp) { return Jwts.builder() .claim(AUTH_ID.getMessage(), id) - .claim(EMAIL.getMessage(), email) .setHeaderParam(TYPE.message, type) .claim(ROLE.getMessage(), role) .signWith(jwtProperties.getSecretKey(), SignatureAlgorithm.HS256) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1e1c1cc..74d9a77 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,10 +25,18 @@ management: exposure: include: health,info,prometheus -#logging: -# level: -# org.springframework.web: DEBUG -# org.springframework.web.servlet.DispatcherServlet: DEBUG +kakao: + token-request-uri: https://kauth.kakao.com/oauth/token + profile-request-uri: https://kapi.kakao.com/v2/user/me + logout-request-uri: https://kapi.kakao.com/v1/user/logout + +cookie: + secure: true + http-only: false + path: / + same-site: none + max-age: 7D + --- spring: diff --git a/src/main/resources/config b/src/main/resources/config index f8016a7..6515d7b 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit f8016a7b230d3f2b1ba2ba0a995057576692fbf3 +Subproject commit 6515d7b8afc3d52cef1f35923ab9cf0429b9302e diff --git a/src/test/java/com/dnd/moddo/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/dnd/moddo/domain/auth/controller/AuthControllerTest.java index c985f5e..3a440ac 100644 --- a/src/test/java/com/dnd/moddo/domain/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/auth/controller/AuthControllerTest.java @@ -1,9 +1,11 @@ package com.dnd.moddo.domain.auth.controller; import static org.mockito.BDDMockito.*; +import static org.springframework.restdocs.cookies.CookieDocumentation.*; import static org.springframework.restdocs.headers.HeaderDocumentation.*; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.time.ZonedDateTime; @@ -12,10 +14,13 @@ import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; +import com.dnd.moddo.domain.auth.dto.KakaoTokenResponse; import com.dnd.moddo.global.jwt.dto.RefreshResponse; import com.dnd.moddo.global.jwt.dto.TokenResponse; import com.dnd.moddo.global.util.RestDocsTestSupport; +import jakarta.servlet.http.Cookie; + class AuthControllerTest extends RestDocsTestSupport { @Test @@ -28,7 +33,7 @@ void getGuestToken() throws Exception { ZonedDateTime.now().plusDays(30), false ); - given(authService.createGuestUser()).willReturn(response); + given(authService.loginWithGuest()).willReturn(response); // when & then mockMvc.perform(get("/api/v1/user/guest/token")) @@ -37,6 +42,9 @@ void getGuestToken() throws Exception { .andExpect(jsonPath("$.refreshToken").value("refresh-token")) .andExpect(jsonPath("$.isMember").value(false)) .andDo(restDocs.document( + responseHeaders( + headerWithName("Set-Cookie").description("엑세스 토큰") + ), responseFields( fieldWithPath("accessToken").type(JsonFieldType.STRING).description("액세스 토큰"), fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("리프레시 토큰"), @@ -69,4 +77,50 @@ void reissueAccessToken() throws Exception { ) )); } + + @Test + @DisplayName("카카오에서 인가코드를 통해 토큰을 발급받아 사용자 정보를 가져와 등록시킨 뒤 엑세스 토큰을 발급하여 쿠키로 전달한다.") + void kakaoLoginCallback() throws Exception { + //given + KakaoTokenResponse kakaoTokenResponse = new KakaoTokenResponse("kakao-access-token", 3600); + given(kakaoClient.join(anyString())).willReturn(kakaoTokenResponse); + + TokenResponse tokenResponse = new TokenResponse("access-token", "refresh-token", + ZonedDateTime.now().plusMonths(1), true); + given(authService.loginOrRegisterWithKakao(anyString())).willReturn(tokenResponse); + + //when & then + mockMvc.perform(get("/api/v1/login/oauth2/callback") + .param("code", "test code")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("code").description("카카오 인가 코드") + ), + responseHeaders( + headerWithName("Set-Cookie").description("엑세스 토큰") + ) + )); + } + + @Test + @DisplayName("액세스 토큰 쿠키를 통해 카카오 로그아웃을 성공적으로 수행한다.") + void kakaoLogout() throws Exception { + //given + given(jwtService.getUserId(anyString())).willReturn(1L); + doNothing().when(authService).logout(any()); + + //when & then + mockMvc.perform(get("/api/v1/logout") + .cookie(new Cookie("accessToken", "access-token"))) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("accessToken").description("액세스 토큰") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("로그아웃 성공 메시지") + ) + )); + } } diff --git a/src/test/java/com/dnd/moddo/domain/auth/service/AuthServiceTest.java b/src/test/java/com/dnd/moddo/domain/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..deb65e0 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,122 @@ +package com.dnd.moddo.domain.auth.service; + +import static com.dnd.moddo.global.support.UserTestFactory.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +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 com.dnd.moddo.domain.auth.dto.KakaoLogoutResponse; +import com.dnd.moddo.domain.auth.dto.KakaoProfile; +import com.dnd.moddo.domain.auth.dto.KakaoTokenResponse; +import com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.service.CommandUserService; +import com.dnd.moddo.domain.user.service.QueryUserService; +import com.dnd.moddo.global.jwt.dto.TokenResponse; +import com.dnd.moddo.global.jwt.utill.JwtProvider; + +@ExtendWith(MockitoExtension.class) +public class AuthServiceTest { + @Mock + private JwtProvider jwtProvider; + @Mock + private CommandUserService commandUserService; + @Mock + private QueryUserService queryUserService; + @Mock + private KakaoClient kakaoClient; + @InjectMocks + private AuthService authService; + + @DisplayName("게스트 회원을 생성하면 저장되고 토큰이 발급된다") + @Test + void whenCreateGuestUser_thenSaveAndIssueToken() { + //given + User user = createGuestDefault(); + when(commandUserService.createGuestUser(any())).thenReturn(user); + //when + TokenResponse response = authService.loginWithGuest(); + //then + verify(jwtProvider, times(1)).generateToken(any()); + verify(commandUserService, times(1)).createGuestUser(any()); + } + + @DisplayName("카카오 사용자가 로그인하면 토큰을 발급한다") + @Test + void whenKakaoUserExists_thenTokenIsIssued() { + //given + String token = "test_token"; + KakaoProfile kakaoProfile = new KakaoProfile( + 12345L, + new KakaoProfile.KakaoAccount( + "test@example.com", + new KakaoProfile.Profile( + "테스트 유저" + ) + ), + new KakaoProfile.Properties( + "테스트유저" + ) + ); + KakaoTokenResponse kakaoTokenResponse = new KakaoTokenResponse("access-token", 3600); + String email = kakaoProfile.kakaoAccount().email(); + User user = createWithEmail(email); + + when(kakaoClient.join(anyString())).thenReturn(kakaoTokenResponse); + when(kakaoClient.getKakaoProfile(anyString())).thenReturn(kakaoProfile); + when(commandUserService.getOrCreateUser(any())).thenReturn(user); + + //when + TokenResponse response = authService.loginOrRegisterWithKakao(token); + + //then + verify(jwtProvider, times(1)).generateToken(any()); + } + + @DisplayName("카카오ID와 응답ID가 같을 때 카카오 로그아웃 성공한다.") + @Test + void whenKakaoIdMatches_thenKakaoLogoutSuccess() { + //given + Long kakaoId = 123456L; + when(queryUserService.findKakaoIdById(any())).thenReturn(Optional.of(kakaoId)); + when(kakaoClient.logout(any())).thenReturn(new KakaoLogoutResponse(kakaoId)); + //when + authService.logout(1L); + //then + verify(queryUserService, times(1)).findKakaoIdById(1L); + verify(kakaoClient, times(1)).logout(kakaoId); + } + + @DisplayName("카카오ID가 null일 때 게스트 로그아웃 성공한다.") + @Test + void whenKakaoIdNull_thenNoAction() { + //given + when(queryUserService.findKakaoIdById(any())).thenReturn(Optional.empty()); + //when + authService.logout(1L); + //then + verify(queryUserService, times(1)).findKakaoIdById(1L); + verify(kakaoClient, times(0)).logout(any()); + } + + @DisplayName("카카오ID와 응답ID가 다를 때 예외 발생한다.") + @Test + void whenKakaoIdDiffers_thenThrowsException() { + //given + Long kakaoId = 123456L; + when(queryUserService.findKakaoIdById(any())).thenReturn(Optional.of(kakaoId)); + when(kakaoClient.logout(any())).thenReturn(new KakaoLogoutResponse(234567L)); + + //when & then + assertThatThrownBy(() -> authService.logout(1L)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("로그아웃 실패"); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/auth/service/KakaoClientTest.java b/src/test/java/com/dnd/moddo/domain/auth/service/KakaoClientTest.java new file mode 100644 index 0000000..52c8bd7 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/auth/service/KakaoClientTest.java @@ -0,0 +1,188 @@ +package com.dnd.moddo.domain.auth.service; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.dnd.moddo.domain.auth.dto.KakaoLogoutResponse; +import com.dnd.moddo.domain.auth.dto.KakaoProfile; +import com.dnd.moddo.domain.auth.dto.KakaoTokenResponse; +import com.dnd.moddo.global.config.KakaoProperties; +import com.dnd.moddo.global.exception.ModdoException; + +@ExtendWith(SpringExtension.class) +@RestClientTest(value = KakaoClient.class) +@EnableConfigurationProperties(KakaoProperties.class) +public class KakaoClientTest { + @Autowired + private KakaoClient kakaoClient; + + @Autowired + private MockRestServiceServer mockServer; + + @Autowired + private KakaoProperties kakaoProperties; + + @DisplayName("카카오 인가 코드로 토큰 요청하면 OauthToken을 반환한다") + @Test + void whenRequestKakaoAccessToken_thenReturnOauthToken() throws Exception { + // given + String code = "test_code"; + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", "test_code"); + params.add("grant_type", "authorization_code"); + params.add("client_id", kakaoProperties.clientId()); + params.add("redirect_uri", kakaoProperties.redirectUri()); + + String expectResponse = """ + { + "access_token": "test_token", + "expires_in": 3600 + } + """; + + mockServer.expect(requestTo(kakaoProperties.tokenRequestUri())) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")) + .andExpect(content().formData(params)) + .andRespond(withSuccess(expectResponse, MediaType.APPLICATION_JSON)); + // when + KakaoTokenResponse result = kakaoClient.join("test_code"); + + // then + assertThat(result).isNotNull(); + assertThat(result.accessToken()).isEqualTo("test_token"); + } + + @DisplayName("잘못된 인가 코드로 토큰 요청 시 IllegalArgumentException이 발생한다") + @Test + void whenRequestKakaoAccessTokenWithInvalidCode_thenThrowException() { + //given + String code = "invalid_code"; + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", kakaoProperties.clientId()); + params.add("redirect_uri", kakaoProperties.redirectUri()); + params.add("code", "invalid_code"); + + mockServer.expect(requestTo(kakaoProperties.tokenRequestUri())) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")) + .andExpect(content().formData(params)) + .andRespond(withStatus(HttpStatus.BAD_REQUEST)); + + //when & then + assertThatThrownBy(() -> kakaoClient.join(code)) + .isInstanceOf(ModdoException.class) + .hasMessageContaining("카카오 API HTTP 에러"); + } + + @DisplayName("정상 토큰으로 카카오 프로필 요청 시 KakaoProfile이 반환된다") + @Test + void whenGetKakaoProfile_thenReturnKakaoProfile() { + // given + String token = "test_token"; + + String expectResponse = """ + { + "id": 12345, + "properties": { + "nickname": "테스트유저" + }, + "kakao_account": { + "email": "test@example.com", + "profile": { + "nickname": "테스트 유저" + } + } + } + """; + + mockServer.expect(requestTo(kakaoProperties.profileRequestUri())) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("Authorization", "Bearer " + token)) + .andRespond(withSuccess(expectResponse, MediaType.APPLICATION_JSON)); + + // when + KakaoProfile profile = kakaoClient.getKakaoProfile(token); + + // then + assertThat(profile).isNotNull(); + assertThat(profile.id()).isEqualTo(12345L); + assertThat(profile.kakaoAccount().email()).isEqualTo("test@example.com"); + assertThat(profile.properties().nickname()).isEqualTo("테스트유저"); + } + + @DisplayName("카카오 API에서 에러가 발생하면 IllegalArgumentException이 발생한다") + @Test + void whenGetKakaoProfileWithHttpError_thenThrowException() { + // given + String token = "test_token"; + + mockServer.expect(requestTo(kakaoProperties.profileRequestUri())) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("Authorization", "Bearer " + token)) + .andRespond(withServerError()); + + // when & then + assertThatThrownBy(() -> kakaoClient.getKakaoProfile(token)) + .isInstanceOf(ModdoException.class); + } + + @DisplayName("카카오 로그아웃 API 호출 시 정상 응답을 반환한다") + @Test + void whenCallKakaoLogout_thenReturnValidResponse() { + //given + Long kakaoId = 123456L; + String expectResponse = """ + { + "id": 123456 + } + """; + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("target_id_type", "user_id"); + params.add("target_id", "123456"); + + mockServer.expect(requestTo(kakaoProperties.logoutRequestUri())) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Authorization", "KakaoAK " + kakaoProperties.adminKey())) + .andExpect(header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")) + .andRespond(withSuccess(expectResponse, MediaType.APPLICATION_JSON)); + //when + KakaoLogoutResponse response = kakaoClient.logout(123456L); + //then + assertThat(response.id()).isEqualTo(kakaoId); + } + + @DisplayName("카카오 로그아웃 API 호출 시 서버 오류가 발생하면 예외를 던진다") + @Test + void henCallKakaoLogout_withServerError_thenThrowException() { + //given + mockServer.expect(requestTo(kakaoProperties.logoutRequestUri())) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Authorization", "KakaoAK " + kakaoProperties.adminKey())) + .andExpect(header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")) + .andRespond(withServerError()); + //when & then + + assertThatThrownBy(() -> kakaoClient.logout(123456L)) + .hasMessageContaining("카카오 콜백 처리 실패"); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenServiceTest.java b/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenServiceTest.java index b40cff4..9d73188 100644 --- a/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenServiceTest.java @@ -1,11 +1,9 @@ package com.dnd.moddo.domain.auth.service; -import static org.assertj.core.api.BDDAssertions.then; -import static org.assertj.core.api.BDDAssertions.thenThrownBy; +import static com.dnd.moddo.global.support.UserTestFactory.*; +import static org.assertj.core.api.BDDAssertions.*; import static org.mockito.Mockito.*; -import java.time.LocalDateTime; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,7 +14,6 @@ import com.dnd.moddo.domain.auth.exception.TokenInvalidException; import com.dnd.moddo.domain.user.entity.User; -import com.dnd.moddo.domain.user.entity.type.Authority; import com.dnd.moddo.domain.user.repository.UserRepository; import com.dnd.moddo.global.jwt.dto.RefreshResponse; import com.dnd.moddo.global.jwt.properties.JwtConstants; @@ -29,61 +26,61 @@ @ExtendWith(MockitoExtension.class) public class RefreshTokenServiceTest { - @Mock - private JwtUtil jwtUtil; - - @Mock - private UserRepository userRepository; - - @Mock - private JwtProvider jwtProvider; - - @InjectMocks - private RefreshTokenService refreshTokenService; - - @BeforeEach - void setUp() { - } - - @Test - public void reissueAccessToken() { - // given - String validToken = "validToken"; - String email = "test@example.com"; - Long userId = 1L; - String role = "USER"; - String newAccessToken = "newAccessToken"; - - Jws mockJws = mock(Jws.class); - Claims mockClaims = mock(Claims.class); - - when(jwtUtil.getJwt(any())).thenReturn(mockJws); - when(mockJws.getBody()).thenReturn(mockClaims); - when(mockClaims.get(JwtConstants.EMAIL.message)).thenReturn(email); - - User user = new User("name", email, role, true, Authority.USER, LocalDateTime.now(), LocalDateTime.now().plusDays(1)); - ReflectionTestUtils.setField(user, "id", userId); - - when(userRepository.getByEmail(email)).thenReturn(user); - when(jwtProvider.generateAccessToken(userId, email, role)).thenReturn(newAccessToken); - - // when - RefreshResponse response = refreshTokenService.execute(validToken); - - // then - then(response.getAccessToken()).isEqualTo(newAccessToken); - verify(userRepository, times(1)).getByEmail(email); - verify(jwtProvider, times(1)).generateAccessToken(userId, email, role); - } - - @Test - public void shouldThrowOnInvalidToken() { - // given - String invalidToken = "invalidToken"; - when(jwtUtil.getJwt(any())).thenThrow(new JwtException("Invalid token")); - - // when & then - thenThrownBy(() -> refreshTokenService.execute(invalidToken)) - .isInstanceOf(TokenInvalidException.class); - } + @Mock + private JwtUtil jwtUtil; + + @Mock + private UserRepository userRepository; + + @Mock + private JwtProvider jwtProvider; + + @InjectMocks + private RefreshTokenService refreshTokenService; + + @BeforeEach + void setUp() { + } + + @Test + public void reissueAccessToken() { + // given + String validToken = "validToken"; + String email = "test@example.com"; + Long userId = 1L; + String role = "USER"; + String newAccessToken = "newAccessToken"; + + Jws mockJws = mock(Jws.class); + Claims mockClaims = mock(Claims.class); + + when(jwtUtil.getJwt(any())).thenReturn(mockJws); + when(mockJws.getBody()).thenReturn(mockClaims); + when(mockClaims.get(JwtConstants.EMAIL.message)).thenReturn(email); + + User user = createGuestDefault(); + ReflectionTestUtils.setField(user, "id", userId); + + when(userRepository.getByEmail(email)).thenReturn(user); + when(jwtProvider.generateAccessToken(userId, role)).thenReturn(newAccessToken); + + // when + RefreshResponse response = refreshTokenService.execute(validToken); + + // then + then(response.getAccessToken()).isEqualTo(newAccessToken); + verify(userRepository, times(1)).getByEmail(email); + verify(jwtProvider, times(1)).generateAccessToken(userId, role); + } + + @Test + public void shouldThrowOnInvalidToken() { + // given + String invalidToken = "invalidToken"; + when(jwtUtil.getJwt(any())).thenThrow(new JwtException("Invalid token")); + + // when & then + thenThrownBy(() -> refreshTokenService.execute(invalidToken)) + .isInstanceOf(TokenInvalidException.class); + } } diff --git a/src/test/java/com/dnd/moddo/domain/group/controller/GroupControllerTest.java b/src/test/java/com/dnd/moddo/domain/group/controller/GroupControllerTest.java index e326ed6..21c6be0 100644 --- a/src/test/java/com/dnd/moddo/domain/group/controller/GroupControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/group/controller/GroupControllerTest.java @@ -23,6 +23,8 @@ import com.dnd.moddo.domain.groupMember.dto.response.GroupMemberResponse; import com.dnd.moddo.global.util.RestDocsTestSupport; +import jakarta.servlet.http.HttpServletRequest; + public class GroupControllerTest extends RestDocsTestSupport { @Test @@ -34,7 +36,7 @@ void saveGroup() throws Exception { 1L, MANAGER, "김모또", "https://moddo-s3.s3.amazonaws.com/profile/MODDO.png", true, LocalDateTime.now() )); - given(jwtService.getUserId(any())).willReturn(1L); + given(jwtService.getUserId(any(HttpServletRequest.class))).willReturn(1L); given(commandGroupService.createGroup(any(), eq(1L))).willReturn(response); // when & then @@ -55,7 +57,7 @@ void updateAccount() throws Exception { LocalDateTime.now().plusDays(1) ); - given(jwtService.getUserId(any())).willReturn(1L); + given(jwtService.getUserId(any(HttpServletRequest.class))).willReturn(1L); given(queryGroupService.findIdByCode(anyString())).willReturn(100L); given(commandGroupService.updateAccount(any(), eq(1L), eq(100L))).willReturn(response); @@ -76,7 +78,7 @@ void getGroup() throws Exception { LocalDateTime.now()) )); - given(jwtService.getUserId(any())).willReturn(1L); + given(jwtService.getUserId(any(HttpServletRequest.class))).willReturn(1L); given(queryGroupService.findIdByCode(anyString())).willReturn(100L); given(queryGroupService.findOne(100L, 1L)).willReturn(response); @@ -93,7 +95,7 @@ void isPasswordMatch() throws Exception { GroupPasswordRequest request = new GroupPasswordRequest("1234"); GroupPasswordResponse response = GroupPasswordResponse.from("확인되었습니다."); - given(jwtService.getUserId(any())).willReturn(1L); + given(jwtService.getUserId(any(HttpServletRequest.class))).willReturn(1L); given(queryGroupService.findIdByCode(anyString())).willReturn(100L); given(commandGroupService.isPasswordMatch(100L, 1L, request)).willReturn(response); diff --git a/src/test/java/com/dnd/moddo/domain/group/service/implementation/GroupCreatorTest.java b/src/test/java/com/dnd/moddo/domain/group/service/implementation/GroupCreatorTest.java index 1c72ad1..dd27754 100644 --- a/src/test/java/com/dnd/moddo/domain/group/service/implementation/GroupCreatorTest.java +++ b/src/test/java/com/dnd/moddo/domain/group/service/implementation/GroupCreatorTest.java @@ -1,5 +1,6 @@ package com.dnd.moddo.domain.group.service.implementation; +import static com.dnd.moddo.global.support.UserTestFactory.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -22,7 +23,6 @@ import com.dnd.moddo.domain.image.dto.CharacterResponse; import com.dnd.moddo.domain.image.service.implementation.ImageReader; import com.dnd.moddo.domain.user.entity.User; -import com.dnd.moddo.domain.user.entity.type.Authority; import com.dnd.moddo.domain.user.repository.UserRepository; @ExtendWith(MockitoExtension.class) @@ -57,8 +57,7 @@ void setUp() { userId = 1L; request = new GroupRequest("groupName", "password"); - mockUser = new User(userId, "test@example.com", "닉네임", "프로필", false, LocalDateTime.now(), - LocalDateTime.now().plusDays(1), Authority.USER); + mockUser = createGuestDefault(); encodedPassword = "encryptedPassword"; diff --git a/src/test/java/com/dnd/moddo/domain/user/entity/UserTest.java b/src/test/java/com/dnd/moddo/domain/user/entity/UserTest.java index 36e14f7..f1f5638 100644 --- a/src/test/java/com/dnd/moddo/domain/user/entity/UserTest.java +++ b/src/test/java/com/dnd/moddo/domain/user/entity/UserTest.java @@ -1,57 +1,53 @@ package com.dnd.moddo.domain.user.entity; -import com.dnd.moddo.ModdoApplication; -import com.dnd.moddo.domain.user.exception.UserNotFoundException; -import com.dnd.moddo.domain.user.repository.UserRepository; +import static com.dnd.moddo.global.support.UserTestFactory.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - -import static com.dnd.moddo.domain.user.entity.type.Authority.USER; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; +import com.dnd.moddo.domain.user.exception.UserNotFoundException; +import com.dnd.moddo.domain.user.repository.UserRepository; @ExtendWith(SpringExtension.class) @DataJpaTest public class UserTest { - @Autowired - private UserRepository userRepository; - - @DisplayName("이메일로 사용자를 조회할 수 있다.") - @Test - public void findByUser() { - // Given - LocalDateTime time = LocalDateTime.now(); + @Autowired + private UserRepository userRepository; - User user1 = new User("홍길동", "guest-UUID1@guest.com", "profile.png", false, USER, time, time.plusDays(7)); - User user2 = new User("심청이", "guest-UUID2@guest.com", "profile.png", false, USER, time, time.plusDays(7)); + @DisplayName("이메일로 사용자를 조회할 수 있다.") + @Test + public void findByUser() { + // Given + LocalDateTime time = LocalDateTime.now(); - userRepository.save(user1); - userRepository.save(user2); + User user1 = createGuestWithNameAndEmail("홍길동", "guest-UUID1@guest.com"); + User user2 = createGuestWithNameAndEmail("심청이", "guest-UUID2@guest.com"); - // When - User foundUser = userRepository.getByEmail("guest-UUID2@guest.com"); + userRepository.save(user1); + userRepository.save(user2); - // Then - assertThat(foundUser.getName()).isEqualTo("심청이"); - assertThat(foundUser.getEmail()).isEqualTo("guest-UUID2@guest.com"); - } + // When + User foundUser = userRepository.getByEmail("guest-UUID2@guest.com"); + // Then + assertThat(foundUser.getName()).isEqualTo("심청이"); + assertThat(foundUser.getEmail()).isEqualTo("guest-UUID2@guest.com"); + } - @DisplayName("이메일로 사용자를 조회할 때, 사용자가 없으면 예외를 발생시킨다.") - @Test - public void getByEmailNotFound() { - // When & Then - assertThrows(UserNotFoundException.class, () -> userRepository.getByEmail("exception@guest.com")); - } + @DisplayName("이메일로 사용자를 조회할 때, 사용자가 없으면 예외를 발생시킨다.") + @Test + public void getByEmailNotFound() { + // When & Then + assertThrows(UserNotFoundException.class, () -> userRepository.getByEmail("exception@guest.com")); + } } diff --git a/src/test/java/com/dnd/moddo/domain/user/service/CommandUserServiceTest.java b/src/test/java/com/dnd/moddo/domain/user/service/CommandUserServiceTest.java new file mode 100644 index 0000000..13c1d94 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/user/service/CommandUserServiceTest.java @@ -0,0 +1,90 @@ +package com.dnd.moddo.domain.user.service; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +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 com.dnd.moddo.domain.user.dto.request.GuestUserSaveRequest; +import com.dnd.moddo.domain.user.dto.request.UserSaveRequest; +import com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.service.implementation.UserCreator; +import com.dnd.moddo.domain.user.service.implementation.UserReader; + +@ExtendWith(MockitoExtension.class) +public class CommandUserServiceTest { + @Mock + private UserCreator userCreator; + @Mock + private UserReader userReader; + @InjectMocks + private CommandUserService commandUserService; + + @DisplayName("유효한 요청으로 게스트 유저를 생성할 수 있다.") + @Test + void whenSaveRequestIsValid_thenGuestUserIsSaved() { + //given + GuestUserSaveRequest request = new GuestUserSaveRequest("email", "Guest"); + when(userCreator.createUser(any(User.class))).thenReturn(request.toEntity()); + //when + User result = commandUserService.createGuestUser(request); + //then + assertThat(result.getEmail()).isEqualTo("email"); + assertThat(result.getName()).isEqualTo("Guest"); + assertThat(result.getIsMember()).isFalse(); + } + + @DisplayName("유효한 요청으로 카카오 유저를 생성할 수 있다.") + @Test + void whenSaveRequestIsValid_thenKakaoUserIsSaved() { + //given + UserSaveRequest request = new UserSaveRequest("email", "Kakao", 123456L); + when(userCreator.createUser(any(User.class))).thenReturn(request.toEntity()); + //when + User result = commandUserService.createKakaoUser(request); + //then + assertThat(result.getEmail()).isEqualTo("email"); + assertThat(result.getName()).isEqualTo("Kakao"); + assertThat(result.getKakaoId()).isEqualTo(123456L); + } + + @DisplayName("카카오 ID로 조회 시 유저가 없으면 새로 생성한다") + @Test + void whenUserDoesNotExist_thenCreateNewUser() { + //given + UserSaveRequest request = new UserSaveRequest("email", "Kakao", 123456L); + + when(userReader.findByKakaoId(anyLong())).thenReturn(Optional.empty()); + when(userCreator.createUser(any(User.class))).thenReturn(request.toEntity()); + //when + User result = commandUserService.getOrCreateUser(request); + + //then + assertThat(result.getEmail()).isEqualTo("email"); + assertThat(result.getName()).isEqualTo("Kakao"); + assertThat(result.getKakaoId()).isEqualTo(123456L); + } + + @DisplayName("카카오 ID로 유저 조회 시 이미 존재하면 기존 유저를 반환한다") + @Test + void getOrCreateUser() { + //given + UserSaveRequest request = new UserSaveRequest("email", "Kakao", 123456L); + + when(userReader.findByKakaoId(anyLong())).thenReturn(Optional.of(request.toEntity())); + //when + User result = commandUserService.getOrCreateUser(request); + + //then + assertThat(result.getEmail()).isEqualTo("email"); + assertThat(result.getName()).isEqualTo("Kakao"); + assertThat(result.getKakaoId()).isEqualTo(123456L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/user/service/implementation/UserCreatorTest.java b/src/test/java/com/dnd/moddo/domain/user/service/implementation/UserCreatorTest.java new file mode 100644 index 0000000..ca14b3d --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/user/service/implementation/UserCreatorTest.java @@ -0,0 +1,40 @@ +package com.dnd.moddo.domain.user.service.implementation; + +import static com.dnd.moddo.global.support.UserTestFactory.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.Mockito.*; + +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 com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +public class UserCreatorTest { + @Mock + private UserRepository userRepository; + @InjectMocks + private UserCreator userCreator; + + @Test + @DisplayName("사용자 생성 시 userRepository.save가 호출되고 저장된 User를 반환한다") + void whenCreateUser_thenReturnSavedUser() { + // given + User user = createWithEmail("test@example.com"); + + when(userRepository.save(user)).thenReturn(user); + + // when + User result = userCreator.createUser(user); + + // then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("test@example.com"); + verify(userRepository, times(1)).save(user); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/user/service/implementation/UserReaderTest.java b/src/test/java/com/dnd/moddo/domain/user/service/implementation/UserReaderTest.java new file mode 100644 index 0000000..b38ecc9 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/user/service/implementation/UserReaderTest.java @@ -0,0 +1,41 @@ +package com.dnd.moddo.domain.user.service.implementation; + +import static com.dnd.moddo.global.support.UserTestFactory.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +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 com.dnd.moddo.domain.user.entity.User; +import com.dnd.moddo.domain.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +public class UserReaderTest { + @Mock + private UserRepository userRepository; + @InjectMocks + private UserReader userReader; + + @DisplayName("kakaoId로 User를 조회하면 해당 User를 반환한다") + @Test + void whenFindByKakaoId_thenReturnUser() { + //given + User user = createWithEmail("test@example.com"); + Long kakaoId = user.getKakaoId(); + + when(userRepository.findByKakaoId(kakaoId)).thenReturn(Optional.of(user)); + //when + Optional result = userReader.findByKakaoId(kakaoId); + //then + assertThat(result).isPresent(); + assertThat(result.get().getKakaoId()).isEqualTo(kakaoId); + verify(userRepository, times(1)).findByKakaoId(kakaoId); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/user/service/queryUserServiceTest.java b/src/test/java/com/dnd/moddo/domain/user/service/queryUserServiceTest.java new file mode 100644 index 0000000..6ada626 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/user/service/queryUserServiceTest.java @@ -0,0 +1,52 @@ +package com.dnd.moddo.domain.user.service; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +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 com.dnd.moddo.domain.user.service.implementation.UserReader; + +@ExtendWith(MockitoExtension.class) +public class queryUserServiceTest { + @Mock + private UserReader userReader; + @InjectMocks + private QueryUserService queryUserService; + + @DisplayName("userId로 kakaoId를 조회하면 해당 kakaoId를 반환한다") + @Test + void whenFindKakaoIdById_thenReturnKakaoId() { + //given + Long userId = 1L; + Long kakaoId = 123456L; + when(userReader.findKakaoIdById(any())).thenReturn(Optional.of(kakaoId)); + //when + Optional result = queryUserService.findKakaoIdById(userId); + //then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(kakaoId); + verify(userReader, times(1)).findKakaoIdById(userId); + } + + @DisplayName("userId가 없을 때 null을 반환한다") + @Test + void whenUserIdNotFound_thenReturnNull() { + //given + when(userReader.findKakaoIdById(any())).thenReturn(Optional.empty()); + + //when + Optional result = queryUserService.findKakaoIdById(1L); + + //then + assertThat(result).isEmpty(); + verify(userReader, times(1)).findKakaoIdById(1L); + } +} diff --git a/src/test/java/com/dnd/moddo/global/support/UserTestFactory.java b/src/test/java/com/dnd/moddo/global/support/UserTestFactory.java new file mode 100644 index 0000000..dcd1041 --- /dev/null +++ b/src/test/java/com/dnd/moddo/global/support/UserTestFactory.java @@ -0,0 +1,55 @@ +package com.dnd.moddo.global.support; + +import static com.dnd.moddo.domain.user.entity.type.Authority.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.dnd.moddo.domain.user.entity.User; + +public class UserTestFactory { + public static User createGuestDefault() { + LocalDateTime time = LocalDateTime.now(); + + return User + .builder() + .name("연노른자") + .email("guest-" + UUID.randomUUID() + "@guest.com") + .profile("profile.png") + .isMember(false) + .authority(USER) + .createdAt(time) + .expiredAt(time.plusDays(7)) + .build(); + } + + public static User createGuestWithNameAndEmail(String name, String email) { + LocalDateTime time = LocalDateTime.now(); + + return User + .builder() + .name(name) + .email(email) + .profile("profile.png") + .isMember(false) + .authority(USER) + .createdAt(time) + .expiredAt(time.plusDays(7)) + .build(); + } + + public static User createWithEmail(String email) { + LocalDateTime time = LocalDateTime.now(); + return User + .builder() + .name("연노른자") + .email(email) + .profile("profile.png") + .isMember(true) + .kakaoId(1234565L) + .authority(USER) + .createdAt(time) + .expiredAt(time.plusDays(7)) + .build(); + } +} diff --git a/src/test/java/com/dnd/moddo/global/util/ControllerTest.java b/src/test/java/com/dnd/moddo/global/util/ControllerTest.java index 8e6e5c3..fed0773 100644 --- a/src/test/java/com/dnd/moddo/global/util/ControllerTest.java +++ b/src/test/java/com/dnd/moddo/global/util/ControllerTest.java @@ -8,6 +8,7 @@ import com.dnd.moddo.domain.auth.controller.AuthController; import com.dnd.moddo.domain.auth.service.AuthService; +import com.dnd.moddo.domain.auth.service.KakaoClient; import com.dnd.moddo.domain.auth.service.RefreshTokenService; import com.dnd.moddo.domain.character.controller.CharacterController; import com.dnd.moddo.domain.character.service.QueryCharacterService; @@ -24,6 +25,7 @@ import com.dnd.moddo.domain.image.service.CommandImageService; import com.dnd.moddo.domain.memberExpense.controller.MemberExpenseController; import com.dnd.moddo.domain.memberExpense.service.QueryMemberExpenseService; +import com.dnd.moddo.global.config.CookieProperties; import com.dnd.moddo.global.jwt.auth.JwtAuth; import com.dnd.moddo.global.jwt.auth.JwtFilter; import com.dnd.moddo.global.jwt.service.JwtService; @@ -55,6 +57,9 @@ public abstract class ControllerTest { @MockBean protected AuthService authService; + @MockBean + protected KakaoClient kakaoClient; + @MockBean protected RefreshTokenService refreshTokenService; @@ -84,7 +89,9 @@ public abstract class ControllerTest { @MockBean protected QueryMemberExpenseService queryMemberExpenseService; - + + @MockBean + protected CookieProperties cookieProperties; // Jwt @MockBean protected JwtAuth jwtAuth; diff --git a/src/test/java/com/dnd/moddo/integration/CacheIntegrationTest.java b/src/test/java/com/dnd/moddo/integration/CacheIntegrationTest.java index 3bdf997..9221917 100644 --- a/src/test/java/com/dnd/moddo/integration/CacheIntegrationTest.java +++ b/src/test/java/com/dnd/moddo/integration/CacheIntegrationTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.*; import static org.mockito.Mockito.*; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -63,6 +64,11 @@ void setUp() { groupRepository.save(GroupTestFactory.createDefault()); } + @AfterAll + static void tearDown() { + redis.close(); + } + @DisplayName("groupCode로 groupId를 조회하면 Redis에 캐싱되고, 같은 코드로 재조회 시 캐시에서 반환한다") @Test void findIdByCode_whenQueriedTwice_thenUsesCacheAndCallsReaderOnce() { diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3fd5560..a5f2c38 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -35,4 +35,20 @@ aws: access-key: accessKeyaccessKeyaccessKeyaccessKeyaccessKeyaccessKey secret-key: secretKeysecretKeysecretKeysecretKeysecretKeysecretKey region: - static: ap-northeast-2 \ No newline at end of file + static: ap-northeast-2 + +cookie: + secure: true + http-only: false + path: / + same-site: none + max-age: 7D + + +kakao: + client-id: clientidclientidclientidclientidclientidclientidclientid + admin-key: adminkeyadminkeyadminkeyadminkeyadminkey + redirect-uri: http://localhost:8080/api/v1/login/kakao/callback + token-request-uri: https://kauth.kakao.com/oauth/token + profile-request-uri: https://kapi.kakao.com/v2/user/me + logout-request-uri: https://kapi.kakao.com/v1/user/logout \ No newline at end of file