diff --git a/.gitignore b/.gitignore index 4a684a41..2b06d3f7 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ out/ src/main/resources/application.yml ### jacoco ### -jacoco \ No newline at end of file +jacoco +src/main/java/com/example/ApiClient.java diff --git a/src/main/java/com/example/spot/api/code/status/ErrorStatus.java b/src/main/java/com/example/spot/api/code/status/ErrorStatus.java index 77a337f4..37862f98 100644 --- a/src/main/java/com/example/spot/api/code/status/ErrorStatus.java +++ b/src/main/java/com/example/spot/api/code/status/ErrorStatus.java @@ -27,6 +27,7 @@ public enum ErrorStatus implements BaseErrorCode { _VALUE_RANGE_EXCEEDED(HttpStatus.BAD_REQUEST, "COMMON4012", "값이 지정된 범위를 초과합니다."), _TERMS_NOT_AGREED(HttpStatus.FORBIDDEN, "COMMON4013", "이용 약관이 동의되지 않았습니다."), _MEMBER_EMAIL_EXIST(HttpStatus.BAD_REQUEST, "COMMON4014", "이미 가입 된 이메일입니다. 다른 로그인 방식을 이용해주세요."), + _RSA_ERROR(HttpStatus.BAD_REQUEST, "COMMON4015", "RSA 에러가 발생했습니다."), // 네이버 소셜 로그인 관련 에러 _NAVER_SIGN_IN_INTEGRATION_FAILED(HttpStatus.UNAUTHORIZED, "NAVER4001", "네이버 로그인 연동에 실패하였습니다."), diff --git a/src/main/java/com/example/spot/api/code/status/SuccessStatus.java b/src/main/java/com/example/spot/api/code/status/SuccessStatus.java index 61e27953..b606afd3 100644 --- a/src/main/java/com/example/spot/api/code/status/SuccessStatus.java +++ b/src/main/java/com/example/spot/api/code/status/SuccessStatus.java @@ -30,6 +30,7 @@ public enum SuccessStatus implements BaseCode { _MEMBER_LOGIN_ID_FOUND(HttpStatus.OK, "MEMBER2012", "회원 아이디 조회 완료"), _MEMBER_LOGIN_ID_CHECK_COMPLETED(HttpStatus.OK, "MEMBER2012", "회원 아이디 사용 가능 여부 조회 완료"), _MEMBER_EMAIL_CHECK_COMPLETED(HttpStatus.OK, "MEMBER2013", "회원 이메일 사용 가능 여부 조회 완료"), + _RSA_PUBLIC_KEY_FOUND(HttpStatus.OK, "MEMBER2014", "RSA Public Key 조회 완료"), //스터디 게시글 관련 응답 _STUDY_POST_CREATED(HttpStatus.CREATED, "STUDYPOST3001", "스터디 게시글 작성 완료"), diff --git a/src/main/java/com/example/spot/config/WebSecurity.java b/src/main/java/com/example/spot/config/WebSecurity.java index 2f6c76fa..0c9956e2 100644 --- a/src/main/java/com/example/spot/config/WebSecurity.java +++ b/src/main/java/com/example/spot/config/WebSecurity.java @@ -55,6 +55,7 @@ protected SecurityFilterChain configure(HttpSecurity http) throws Exception { .requestMatchers(new AntPathRequestMatcher("/spot/reissue")).permitAll() .requestMatchers(new AntPathRequestMatcher("/spot/sign-up", "POST")).permitAll() .requestMatchers(new AntPathRequestMatcher("/spot/login", "POST")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/spot/login/rsa", "POST")).permitAll() .requestMatchers(new AntPathRequestMatcher("/spot/members/sign-in/naver", "POST")).permitAll() .requestMatchers(new AntPathRequestMatcher("/spot/members/sign-in/naver/redirect", "GET")).permitAll() .requestMatchers(new AntPathRequestMatcher("/spot/members/sign-in/naver/authorize/test", "GET")).permitAll() diff --git a/src/main/java/com/example/spot/domain/Region.java b/src/main/java/com/example/spot/domain/Region.java index 57d4b8ab..34168b2e 100644 --- a/src/main/java/com/example/spot/domain/Region.java +++ b/src/main/java/com/example/spot/domain/Region.java @@ -15,6 +15,9 @@ @Entity @Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor public class Region extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -28,25 +31,15 @@ public class Region extends BaseEntity { private String neighborhood; + @Builder.Default @OneToMany(mappedBy = "region") private List regionStudyList = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "region") private List prefferedRegionList = new ArrayList<>(); -/* ----------------------------- 생성자 ------------------------------------- */ - - protected Region() {} - - @Builder - public Region(String code, String province, String district, String neighborhood) { - this.code = code; - this.province = province; - this.district = district; - this.neighborhood = neighborhood; - } - /* ----------------------------- 연관관계 메소드 ------------------------------------- */ public void addRegionStudy(RegionStudy regionStudy) { diff --git a/src/main/java/com/example/spot/domain/Theme.java b/src/main/java/com/example/spot/domain/Theme.java index 76881e9c..396de520 100644 --- a/src/main/java/com/example/spot/domain/Theme.java +++ b/src/main/java/com/example/spot/domain/Theme.java @@ -10,6 +10,7 @@ import lombok.Builder; import lombok.Getter; +import java.util.ArrayList; import java.util.List; import lombok.NoArgsConstructor; import org.hibernate.annotations.DynamicInsert; @@ -34,12 +35,14 @@ public class Theme extends BaseEntity { private ThemeType studyTheme; //== 해당 테마를 선호하는 멤버 목록 ==// + @Builder.Default @OneToMany(mappedBy = "theme", cascade = CascadeType.ALL) - private List memberThemeList; + private List memberThemeList = new ArrayList<>(); //== 테마별 스터디 목록 ==// + @Builder.Default @OneToMany(mappedBy = "theme", cascade = CascadeType.ALL) - private List studyThemeList; + private List studyThemeList = new ArrayList<>(); /* ----------------------------- 연관관계 메소드 ------------------------------------- */ diff --git a/src/main/java/com/example/spot/domain/auth/RsaKey.java b/src/main/java/com/example/spot/domain/auth/RsaKey.java new file mode 100644 index 00000000..5736b390 --- /dev/null +++ b/src/main/java/com/example/spot/domain/auth/RsaKey.java @@ -0,0 +1,30 @@ +package com.example.spot.domain.auth; + +import com.example.spot.domain.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class RsaKey extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false, unique = true) + private Long id; + + @Column(columnDefinition = "text") + private String privateKey; + + @Column(columnDefinition = "text") + private String publicKey; + + @Column(columnDefinition = "text") + private String modulus; + + @Column(columnDefinition = "text") + private String exponent; +} diff --git a/src/main/java/com/example/spot/repository/rsa/RSAKeyRepository.java b/src/main/java/com/example/spot/repository/rsa/RSAKeyRepository.java new file mode 100644 index 00000000..6f31d0d9 --- /dev/null +++ b/src/main/java/com/example/spot/repository/rsa/RSAKeyRepository.java @@ -0,0 +1,13 @@ +package com.example.spot.repository.rsa; + +import com.example.spot.domain.auth.RsaKey; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +public interface RSAKeyRepository extends JpaRepository { + + void deleteByCreatedAtBefore(LocalDateTime localDateTime); +} diff --git a/src/main/java/com/example/spot/security/utils/RSAUtils.java b/src/main/java/com/example/spot/security/utils/RSAUtils.java new file mode 100644 index 00000000..04f96451 --- /dev/null +++ b/src/main/java/com/example/spot/security/utils/RSAUtils.java @@ -0,0 +1,134 @@ +package com.example.spot.security.utils; + +import com.example.spot.api.code.status.ErrorStatus; +import com.example.spot.api.exception.handler.MemberHandler; +import com.example.spot.domain.auth.RsaKey; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; + +@Component +public class RSAUtils { + + private final KeyPairGenerator generator; + private final KeyFactory keyFactory; + private final Cipher cipher; + + public RSAUtils() throws Exception { + SecureRandom secureRandom = new SecureRandom(); + generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048, secureRandom); + keyFactory = KeyFactory.getInstance("RSA"); + cipher = Cipher.getInstance("RSA"); + } + + /** + * RSA Public Key와 Private Key를 생성하는 함수 + * @return 생성된 RSA 객체 + */ + public RsaKey createRSA() { + + try { + // Key Pair 생성 + KeyPair keyPair = generator.generateKeyPair(); + PublicKey publicKey = keyPair.getPublic(); + PrivateKey privateKey = keyPair.getPrivate(); + + RSAPublicKeySpec publicKeySpec = keyFactory.getKeySpec(publicKey, RSAPublicKeySpec.class); + String modulus = publicKeySpec.getModulus().toString(16); // 16진수 문자열 + String exponent = publicKeySpec.getPublicExponent().toString(16); // 16진수 문자열 + + return RsaKey.builder() + .publicKey(getBase64StringFromPublicKey(publicKey)) + .privateKey(getBase64StringFromPrivateKey(privateKey)) + .modulus(modulus) + .exponent(exponent) + .build(); + + } catch (Exception e) { + e.printStackTrace(); + throw new MemberHandler(ErrorStatus._RSA_ERROR); + } + + } + + /** + * Public Key를 Base64 문자열로 변환하는 함수 + * @param publicKey : 암호화에 사용될 publicKey + * @return Public Key를 Base64 문자열로 변환한 값 + */ + public String getBase64StringFromPublicKey(PublicKey publicKey) { + return Base64.getEncoder().encodeToString(publicKey.getEncoded()); + } + + /** + * Private Key를 Base64 문자열로 변환하는 함수 + * @param privateKey : 암호화에 사용될 privateKey + * @return Public Key를 Base64 문자열로 변환한 값 + */ + public String getBase64StringFromPrivateKey(PrivateKey privateKey) { + return Base64.getEncoder().encodeToString(privateKey.getEncoded()); + } + + /** + * Base64 문자열을 PrivateKey 객체로 변환하는 함수 + * @param privateKeyString :Private Key를 Base64 문자열로 변환한 값 + * @return PrivateKey 객체 + */ + public PrivateKey getPrivateKeyFromBase64String(String privateKeyString) { + + try { + // Base64 디코딩 + byte[] keyBytes = Base64.getDecoder().decode(privateKeyString); + + // PrivateKey 객체 생성 + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + e.printStackTrace(); + throw new MemberHandler(ErrorStatus._RSA_ERROR); + } + + } + + /** + * Public Key로 문자열을 암호화하는 함수 + * @param publicKey : 암호화에 사용될 publicKey + * @param plainText : 암호화되지 않은 문자열 + * @return 암호화된 문자열 + */ + public String getEncryptedText(PublicKey publicKey, String plainText) { + try { + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + byte[] plainBytes = cipher.doFinal(plainText.getBytes()); + return Base64.getEncoder().encodeToString(plainBytes); + } catch (Exception e) { + e.printStackTrace(); + throw new MemberHandler(ErrorStatus._RSA_ERROR); + } + } + + /** + * Private Key로 문자열을 복호화하는 함수 + * @param privateKey : 복호화에 사용될 privateKey + * @param encryptedText : 암호화된 문자열 + * @return 복호화된 문자열 + */ + public String getDecryptedText(PrivateKey privateKey, String encryptedText) { + try { + byte[] encryptedBytes = Base64.getDecoder().decode(encryptedText); + cipher.init(Cipher.DECRYPT_MODE, privateKey); + byte[] plainBytes = cipher.doFinal(encryptedBytes); + return new String(plainBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + e.printStackTrace(); + throw new MemberHandler(ErrorStatus._RSA_ERROR); + } + } +} diff --git a/src/main/java/com/example/spot/service/auth/AuthService.java b/src/main/java/com/example/spot/service/auth/AuthService.java index 02f2bc9f..57539b1e 100644 --- a/src/main/java/com/example/spot/service/auth/AuthService.java +++ b/src/main/java/com/example/spot/service/auth/AuthService.java @@ -1,5 +1,6 @@ package com.example.spot.service.auth; +import com.example.spot.web.dto.rsa.Rsa; import com.example.spot.web.dto.member.MemberRequestDTO; import com.example.spot.web.dto.member.MemberResponseDTO; import com.example.spot.web.dto.member.MemberResponseDTO.SocialLoginSignInDTO; @@ -23,13 +24,15 @@ public interface AuthService { SocialLoginSignInDTO signInWithNaver(HttpServletRequest request, HttpServletResponse response, NaverOAuthToken.NaverTokenIssuanceDTO naverTokenDTO) throws Exception; - MemberResponseDTO.MemberSignInDTO signIn(MemberRequestDTO.SignInDTO signInDTO); + MemberResponseDTO.MemberSignInDTO signIn(Long httpSession, MemberRequestDTO.SignInDTO signInDTO) throws Exception; + + Rsa.RSAPublicKey getRSAPublicKey() throws Exception; void sendVerificationCode(HttpServletRequest request, HttpServletResponse response, String email); TokenResponseDTO.TempTokenDTO verifyEmail(String verificationCode, String email); - MemberResponseDTO.MemberSignInDTO signUp(MemberRequestDTO.SignUpDTO signUpDTO); + MemberResponseDTO.MemberSignInDTO signUp(Long rsaId, MemberRequestDTO.SignUpDTO signUpDTO) throws Exception; MemberResponseDTO.FindIdDTO findId(); diff --git a/src/main/java/com/example/spot/service/auth/AuthServiceImpl.java b/src/main/java/com/example/spot/service/auth/AuthServiceImpl.java index 7bb30ab1..b043e1d0 100644 --- a/src/main/java/com/example/spot/service/auth/AuthServiceImpl.java +++ b/src/main/java/com/example/spot/service/auth/AuthServiceImpl.java @@ -5,6 +5,8 @@ import com.example.spot.api.exception.GeneralException; import com.example.spot.api.exception.handler.MemberHandler; import com.example.spot.domain.Member; +import com.example.spot.domain.auth.RsaKey; +import com.example.spot.web.dto.rsa.Rsa; import com.example.spot.domain.auth.RefreshToken; import com.example.spot.domain.auth.VerificationCode; import com.example.spot.domain.enums.Carrier; @@ -13,9 +15,11 @@ import com.example.spot.domain.enums.Status; import com.example.spot.repository.MemberRepository; import com.example.spot.repository.RefreshTokenRepository; +import com.example.spot.repository.rsa.RSAKeyRepository; import com.example.spot.repository.verification.VerificationCodeRepository; import com.example.spot.security.utils.JwtTokenProvider; import com.example.spot.security.utils.MemberUtils; +import com.example.spot.security.utils.RSAUtils; import com.example.spot.web.dto.member.MemberRequestDTO; import com.example.spot.web.dto.member.MemberResponseDTO; import com.example.spot.security.utils.SecurityUtils; @@ -27,8 +31,10 @@ import com.example.spot.web.dto.token.TokenResponseDTO; import com.example.spot.web.dto.token.TokenResponseDTO.TokenDTO; +import java.security.PrivateKey; import java.security.SecureRandom; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Objects; import java.util.Random; @@ -56,6 +62,9 @@ public class AuthServiceImpl implements AuthService{ private final MailService mailService; private final NaverOAuthService naverOAuthService; + private final RSAUtils rsaUtils; + private final RSAKeyRepository rsaKeyRepository; + @Value("${image.post.anonymous.profile}") private String DEFAULT_PROFILE_IMAGE_URL; @@ -258,18 +267,19 @@ private void signUpWithNaver(NaverMember.ResponseDTO memberDTO) { /** * 일반 로그인을 위한 메서드입니다. 아이디와 비밀번호를 확인한 후 토큰을 발급하는 로직을 수행합니다. - * @param signInDTO 로그인할 회원의 아이디와 비밀번호를 입력 받습니다. + * @param signInDTO 로그인할 회원의 아이디와 비밀번호를 입력 받습니다. * @return 로그인한 회원의 토큰 정보(액세스 & 리프레시 토큰 & 만료기간), 이메일과 회원 아이디(정수)가 반환됩니다. */ @Override - public MemberResponseDTO.MemberSignInDTO signIn(MemberRequestDTO.SignInDTO signInDTO) { + public MemberResponseDTO.MemberSignInDTO signIn(Long rsaId, MemberRequestDTO.SignInDTO signInDTO) throws Exception { // 아이디가 일치하는 유저가 있는지 확인 Member member = memberRepository.findByLoginId(signInDTO.getLoginId()) .orElseThrow(() -> new MemberHandler(ErrorStatus._MEMBER_NOT_FOUND)); // 비밀번호 확인 - if (!signInDTO.getPassword().equals(member.getPassword())) { + String password = getDecryptedPassword(rsaId, signInDTO.getPassword()); + if (!password.equals(member.getPassword())) { throw new MemberHandler(ErrorStatus._MEMBER_PASSWORD_NOT_MATCH); } @@ -285,6 +295,33 @@ public MemberResponseDTO.MemberSignInDTO signIn(MemberRequestDTO.SignInDTO signI .build(); } + private String getDecryptedPassword(Long rsaId, String encryptedPassword) throws Exception { + + // Private Key 추출 후 Session에 저장된 Private Key 초기화 + RsaKey rsaKey = rsaKeyRepository.findById(rsaId) + .orElseThrow(() -> new MemberHandler(ErrorStatus._RSA_ERROR)); + rsaKeyRepository.deleteByCreatedAtBefore(LocalDateTime.now().minusMinutes(1)); + + // 복호화된 패스워드 반환 + PrivateKey privateKey = rsaUtils.getPrivateKeyFromBase64String(rsaKey.getPrivateKey()); + return rsaUtils.getDecryptedText(privateKey, encryptedPassword); + } + + @Override + public Rsa.RSAPublicKey getRSAPublicKey() throws Exception { + + // RSA 생성 + RsaKey rsa = rsaUtils.createRSA(); + rsa = rsaKeyRepository.save(rsa); + + // Public Key 반환 + return Rsa.RSAPublicKey.builder() + .rsaId(rsa.getId()) + .modulus(rsa.getModulus()) + .exponent(rsa.getExponent()) + .build(); + } + /** * 인증 코드를 전송하는 메서드입니다. * 일반 회원가입, 아이디 찾기, 비밀번호 찾기에 공통으로 적용되는 인증 메일 전송 로직입니다. @@ -344,18 +381,20 @@ public TokenResponseDTO.TempTokenDTO verifyEmail(String code, String email) { /** * 일반 회원가입에 사용되는 메서드입니다. + * + * @param rsaId * @param signUpDTO 회원의 기본 정보를 입력 받습니다. * name : 이름 * frontRID : 주민번호 앞자리 * backRID : 주민번호 뒷자리 첫 글자 * email : 이메일 * loginId : 아이디 - * password : 비밀번호 + * password : 비밀번호 (RSA Key로 암호화한 값) * pwCheck : 비밀번호 확인 * @return 가입한 회원은 자동으로 로그인되며, 회원의 토큰 정보(액세스 & 리프레시 토큰 & 만료기간), 이메일과 회원 아이디(정수)가 반환됩니다. */ @Override - public MemberResponseDTO.MemberSignInDTO signUp(MemberRequestDTO.SignUpDTO signUpDTO) { + public MemberResponseDTO.MemberSignInDTO signUp(Long rsaId, MemberRequestDTO.SignUpDTO signUpDTO) throws Exception { // 회원 생성 if (memberRepository.existsByEmail(signUpDTO.getEmail())) { @@ -364,7 +403,10 @@ public MemberResponseDTO.MemberSignInDTO signUp(MemberRequestDTO.SignUpDTO signU if (memberRepository.existsByLoginId(signUpDTO.getLoginId())) { throw new MemberHandler(ErrorStatus._MEMBER_LOGIN_ID_ALREADY_EXISTS); } - if (!signUpDTO.getPassword().equals(signUpDTO.getPwCheck())) { + + String password = getDecryptedPassword(rsaId, signUpDTO.getPassword()); + String pwCheck = getDecryptedPassword(rsaId, signUpDTO.getPwCheck()); + if (!password.equals(pwCheck)) { throw new MemberHandler(ErrorStatus._MEMBER_PW_AND_PW_CHECK_DO_NOT_MATCH); } @@ -389,7 +431,7 @@ public MemberResponseDTO.MemberSignInDTO signUp(MemberRequestDTO.SignUpDTO signU .carrier(Carrier.NONE) .phone("") .loginId(signUpDTO.getLoginId()) - .password(signUpDTO.getPassword()) + .password(password) .profileImage(DEFAULT_PROFILE_IMAGE_URL) .personalInfo(false) .idInfo(false) diff --git a/src/main/java/com/example/spot/web/controller/AuthController.java b/src/main/java/com/example/spot/web/controller/AuthController.java index 32ebf603..ef894d96 100644 --- a/src/main/java/com/example/spot/web/controller/AuthController.java +++ b/src/main/java/com/example/spot/web/controller/AuthController.java @@ -2,6 +2,7 @@ import com.example.spot.api.ApiResponse; import com.example.spot.api.code.status.SuccessStatus; +import com.example.spot.web.dto.rsa.Rsa; import com.example.spot.service.auth.AuthService; import com.example.spot.validation.annotation.TextLength; import com.example.spot.web.dto.member.MemberRequestDTO; @@ -15,6 +16,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -177,14 +179,16 @@ public ApiResponse checkEmailAvailability( ## [회원 가입] 일반 회원 가입 API입니다. * 아이디(이메일)과 비밀번호 등을 포함하여 회원 가입을 진행합니다. * 주민번호 앞자리(frontRID)와 뒷자리(backRID)는 모두 String 타입입니다. + * <비밀번호>와 <비밀번호 확인>은 반드시 RSA Public Key로 암호화하여 전송해야 합니다. * 회원 가입에 성공하면, 액세스 토큰과 리프레시 토큰이 발급됩니다. * 액세스 토큰은 사용자의 정보를 인증하는데 사용되며, 리프레시 토큰은 액세스 토큰이 만료된 경우, 액세스 토큰을 재발급 하는데 사용됩니다. * 액세스 토큰이 만료된 경우, 유효한 상태의 리프레시 토큰을 통해 액세스 토큰을 재발급 받을 수 있습니다. """) @PostMapping("/sign-up") public ApiResponse signUp( - @RequestBody @Valid MemberRequestDTO.SignUpDTO signUpDTO) { - MemberResponseDTO.MemberSignInDTO memberSignUpDTO = authService.signUp(signUpDTO); + @RequestParam Long rsaId, + @RequestBody @Valid MemberRequestDTO.SignUpDTO signUpDTO) throws Exception { + MemberResponseDTO.MemberSignInDTO memberSignUpDTO = authService.signUp(rsaId, signUpDTO); return ApiResponse.onSuccess(SuccessStatus._MEMBER_CREATED, memberSignUpDTO); } @@ -217,19 +221,34 @@ public ApiResponse findPw( return ApiResponse.onSuccess(SuccessStatus._MEMBER_FOUND, findPwDTO); } + @Tag(name = "회원 관리 API - 개발 완료", description = "회원 관리 API") + @Operation(summary = "[로그인] RSA Public Key 발급 API", + description = """ + ## [로그인] 비밀번호 전송을 위해 RSA Public Key를 발급하는 API입니다. + * 서버에서 발급한 RSA Public Key와 해당 키의 식별자인 rsaId를 클라이언트에 전달합니다. + * 로그인 및 회원가입 시 해당 키를 통해 비밀번호를 암호화하여 전송해야 합니다. + """) + @PostMapping("/login/rsa") + public ApiResponse getRSAPublicKey() throws Exception { + Rsa.RSAPublicKey rsaPublicKey = authService.getRSAPublicKey(); + return ApiResponse.onSuccess(SuccessStatus._RSA_PUBLIC_KEY_FOUND, rsaPublicKey); + } @Tag(name = "회원 관리 API - 개발 완료", description = "회원 관리 API") @Operation(summary = "[로그인] 일반 로그인 API", description = """ ## [로그인] 아이디(이메일)과 비밀번호를 통해 로그인 하는 API입니다. - 로그인에 성공하면, 액세스 토큰과 리프레시 토큰이 발급됩니다. - 액세스 토큰은 사용자의 정보를 인증하는데 사용되며, 리프레시 토큰은 액세스 토큰이 만료된 경우, 액세스 토큰을 재발급 하는데 사용됩니다. - 액세스 토큰이 만료된 경우, 유효한 상태의 리프레시 토큰을 통해 액세스 토큰을 재발급 받을 수 있습니다. + * 로그인에 성공하면, 액세스 토큰과 리프레시 토큰이 발급됩니다. + * 액세스 토큰은 사용자의 정보를 인증하는데 사용되며, 리프레시 토큰은 액세스 토큰이 만료된 경우, 액세스 토큰을 재발급 하는데 사용됩니다. + * 액세스 토큰이 만료된 경우, 유효한 상태의 리프레시 토큰을 통해 액세스 토큰을 재발급 받을 수 있습니다. + * rsaId에는 RSA Public Key 발급 API를 통해 획득한 식별자(id)를 입력해야 합니다. + * 비밀번호는 반드시 RSA Public Key로 암호화하여 전송해야 합니다. """) @PostMapping("/login") public ApiResponse login( - @RequestBody @Valid MemberRequestDTO.SignInDTO signInDTO) { - MemberResponseDTO.MemberSignInDTO memberSignInDTO = authService.signIn(signInDTO); + @RequestParam Long rsaId, + @RequestBody @Valid MemberRequestDTO.SignInDTO signInDTO) throws Exception { + MemberResponseDTO.MemberSignInDTO memberSignInDTO = authService.signIn(rsaId, signInDTO); return ApiResponse.onSuccess(SuccessStatus._MEMBER_SIGNED_IN, memberSignInDTO); } diff --git a/src/main/java/com/example/spot/web/controller/StudyController.java b/src/main/java/com/example/spot/web/controller/StudyController.java index 641cf04c..7ccbfa40 100644 --- a/src/main/java/com/example/spot/web/controller/StudyController.java +++ b/src/main/java/com/example/spot/web/controller/StudyController.java @@ -49,7 +49,6 @@ public ApiResponse getStudyInfo( ## [스터디 생성/참여] 스터디 페이지 > 신청하기 클릭, 로그인한 회원이 스터디에 신청합니다. 로그인한 회원이 member_study에 application_status = APPLIED 상태로 추가됩니다. """) - @Parameter(name = "memberId", description = "스터디에 참여하는 회원의 id를 입력 받습니다.", required = true) @Parameter(name = "studyId", description = "참여할 스터디의 id를 입력 받습니다.", required = true) @PostMapping("/studies/{studyId}") public ApiResponse applyToStudy( @@ -67,7 +66,6 @@ public ApiResponse applyToStudy( regions에는 지역 코드를 입력해야 합니다. """) - @Parameter(name = "memberId", description = "스터디를 생성할 회원의 id를 입력 받습니다.", required = true) @PostMapping("/studies") public ApiResponse registerStudy( @RequestBody @Valid StudyRegisterRequestDTO.RegisterDTO studyRegisterRequestDTO) { diff --git a/src/main/java/com/example/spot/web/dto/member/MemberRequestDTO.java b/src/main/java/com/example/spot/web/dto/member/MemberRequestDTO.java index 6a8dd967..4a8580ca 100644 --- a/src/main/java/com/example/spot/web/dto/member/MemberRequestDTO.java +++ b/src/main/java/com/example/spot/web/dto/member/MemberRequestDTO.java @@ -19,12 +19,11 @@ public static class SignInDTO { @TextLength(max = 100) private final String loginId; - - @TextLength(max = 100) private final String password; } @Getter + @Builder @RequiredArgsConstructor public static class SignUpDTO { @@ -43,10 +42,8 @@ public static class SignUpDTO { @TextLength(min=6, max = 100) private final String loginId; - @TextLength(max = 100) private final String password; - @TextLength(max = 100) private final String pwCheck; } diff --git a/src/main/java/com/example/spot/web/dto/rsa/Rsa.java b/src/main/java/com/example/spot/web/dto/rsa/Rsa.java new file mode 100644 index 00000000..8ec91530 --- /dev/null +++ b/src/main/java/com/example/spot/web/dto/rsa/Rsa.java @@ -0,0 +1,33 @@ +package com.example.spot.web.dto.rsa; + +import lombok.*; + +import java.security.PrivateKey; +import java.security.PublicKey; +import java.time.LocalDateTime; + +@Getter +public class Rsa { + + @Getter + @Builder + @RequiredArgsConstructor + public static class RSAKey { + private final LocalDateTime createdAt; + private final PrivateKey privateKey; + private final PublicKey publicKey; + private final String modulus; + private final String exponent; + } + + @Getter + @Builder + @RequiredArgsConstructor + public static class RSAPublicKey { + private final Long rsaId; + private final String modulus; + private final String exponent; + } + + +} diff --git a/src/main/java/com/example/spot/web/dto/study/request/StudyJoinRequestDTO.java b/src/main/java/com/example/spot/web/dto/study/request/StudyJoinRequestDTO.java index d06e918a..8a0c38e0 100644 --- a/src/main/java/com/example/spot/web/dto/study/request/StudyJoinRequestDTO.java +++ b/src/main/java/com/example/spot/web/dto/study/request/StudyJoinRequestDTO.java @@ -2,6 +2,7 @@ import com.example.spot.validation.annotation.TextLength; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -9,6 +10,7 @@ public class StudyJoinRequestDTO { @Getter + @Builder @NoArgsConstructor @AllArgsConstructor public static class StudyJoinDTO { diff --git a/src/main/java/com/example/spot/web/dto/study/request/StudyRegisterRequestDTO.java b/src/main/java/com/example/spot/web/dto/study/request/StudyRegisterRequestDTO.java index f44a6c8f..5f7d4064 100644 --- a/src/main/java/com/example/spot/web/dto/study/request/StudyRegisterRequestDTO.java +++ b/src/main/java/com/example/spot/web/dto/study/request/StudyRegisterRequestDTO.java @@ -6,6 +6,7 @@ import com.example.spot.validation.annotation.LongSize; import com.example.spot.validation.annotation.TextLength; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -15,6 +16,7 @@ public class StudyRegisterRequestDTO { @Getter + @Builder @NoArgsConstructor @AllArgsConstructor public static class RegisterDTO { diff --git a/src/main/java/com/example/spot/web/dto/study/response/StudyJoinResponseDTO.java b/src/main/java/com/example/spot/web/dto/study/response/StudyJoinResponseDTO.java index 178e9d96..fc00eefb 100644 --- a/src/main/java/com/example/spot/web/dto/study/response/StudyJoinResponseDTO.java +++ b/src/main/java/com/example/spot/web/dto/study/response/StudyJoinResponseDTO.java @@ -30,7 +30,7 @@ public static JoinDTO toDTO(Member member, Study study) { } @Getter - private static class TitleDTO { + public static class TitleDTO { private final Long studyId; private final String title; diff --git a/src/test/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceTest.java b/src/test/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceTest.java new file mode 100644 index 00000000..8e79e214 --- /dev/null +++ b/src/test/java/com/example/spot/service/memberstudy/MemberStudyCommandServiceTest.java @@ -0,0 +1,242 @@ +package com.example.spot.service.memberstudy; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.when; + +import com.example.spot.api.exception.handler.StudyHandler; +import com.example.spot.domain.Member; +import com.example.spot.domain.mapping.MemberStudy; +import com.example.spot.domain.study.Study; +import com.example.spot.domain.study.ToDoList; +import com.example.spot.repository.MemberRepository; +import com.example.spot.repository.MemberStudyRepository; +import com.example.spot.repository.StudyRepository; +import com.example.spot.repository.ToDoListRepository; +import com.example.spot.web.dto.memberstudy.request.toDo.ToDoListRequestDTO.ToDoListCreateDTO; +import com.example.spot.web.dto.memberstudy.request.toDo.ToDoListResponseDTO.ToDoListCreateResponseDTO; +import com.example.spot.web.dto.memberstudy.request.toDo.ToDoListResponseDTO.ToDoListUpdateResponseDTO; +import java.time.LocalDate; +import java.util.Collections; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.BDDMockito; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class MemberStudyCommandServiceTest { + + @InjectMocks + private MemberStudyCommandServiceImpl memberStudyCommandService; + + @Mock + private StudyRepository studyRepository; + + @Mock + private MemberStudyRepository memberStudyRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private ToDoListRepository toDoListRepository; + + @Mock + private Study study; + + @Mock + private Member member; + + @Mock + private MemberStudy memberStudy; + + @Mock + private ToDoList toDoList; + + private ToDoListCreateDTO requestDTO; + + @BeforeEach + void init() { + requestDTO = ToDoListCreateDTO.builder() + .content("test") + .date(LocalDate.EPOCH) + .build(); + + given(toDoList.getStudy()).willReturn(study); + given(study.getId()).willReturn(1L); + given(toDoList.getMember()).willReturn(member); + given(member.getId()).willReturn(1L); + + Authentication authentication = new UsernamePasswordAuthenticationToken("1", null, Collections.emptyList()); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + } + + /* ---------------------------- To-Do 생성 관련 메서드 ---------------------------- */ + + @Test + @DisplayName("To-Do 생성 - 성공") + void createToDoList() { + // given + when(studyRepository.findById(anyLong())).thenReturn(Optional.ofNullable(study)); + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(anyLong(), anyLong(), any())).thenReturn( + Optional.ofNullable(memberStudy)); + when(memberRepository.findById(anyLong())).thenReturn(Optional.ofNullable(member)); + + when(toDoListRepository.save(any())).thenReturn(toDoList); + + // when + ToDoListCreateResponseDTO responseDTO = memberStudyCommandService.createToDoList(1L, requestDTO); + + // then + assertEquals(responseDTO.getContent(), requestDTO.getContent()); + } + + @Test + @DisplayName("To-Do 생성 - 스터디 회원이 아닌 경우") + void ToDo_생성_시_스터디_회원이_아닌_경우() { + // given + when(studyRepository.findById(anyLong())).thenReturn(Optional.ofNullable(study)); + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(anyLong(), anyLong(), any())).thenReturn( + Optional.empty()); + + + // when & then + assertThrows(StudyHandler.class, () -> { + memberStudyCommandService.createToDoList(1L, requestDTO); + }); + } + + /* ---------------------------- To-Do 수정 관련 메서드 ---------------------------- */ + + @Test + @DisplayName("To-Do 수정 - 성공") + void ToDo_수정_성공() { + // given + when(toDoListRepository.findById(anyLong())).thenReturn(Optional.ofNullable(toDoList)); + + // when + ToDoListUpdateResponseDTO responseDTO = memberStudyCommandService.updateToDoList(1L,1L, requestDTO); + + // then + assertEquals(false, responseDTO.isDone()); + + } + + @Test + @DisplayName("To-Do 수정 - To-Do가 없는 경우") + void ToDo_수정_시_ToDo가_없는_경우() { + // given + when(toDoListRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThrows(StudyHandler.class, () -> { + memberStudyCommandService.updateToDoList(1L,1L, requestDTO); + }); + } + + @Test + @DisplayName("To-Do 수정 - To-Do가 다른 스터디의 것인 경우") + void ToDo_수정_시_ToDo가_다른_스터디의_것인_경우() { + // given + when(toDoListRepository.findById(anyLong())).thenReturn(Optional.ofNullable(toDoList)); + given(toDoList.getStudy()).willReturn(Mockito.mock(Study.class)); + given(toDoList.getStudy().getId()).willReturn(2L); + + // when & then + assertThrows(StudyHandler.class, () -> { + memberStudyCommandService.updateToDoList(1L,1L, requestDTO); + }); + } + + @Test + @DisplayName("To-Do 수정 - To-Do가 다른 회원의 것인 경우") + void ToDo_수정_시_ToDo가_다른_회원의_것인_경우() { + // given + when(toDoListRepository.findById(anyLong())).thenReturn(Optional.ofNullable(toDoList)); + given(toDoList.getMember()).willReturn(Mockito.mock(Member.class)); + given(toDoList.getMember().getId()).willReturn(2L); + + // when & then + assertThrows(StudyHandler.class, () -> { + memberStudyCommandService.updateToDoList(1L,1L, requestDTO); + }); + } + + /* ---------------------------- To-Do 삭제 관련 메서드 ---------------------------- */ + + @Test + @DisplayName("To-Do 삭제 - 성공") + void ToDo_삭제_성공() { + // given + when(toDoListRepository.findById(anyLong())).thenReturn(Optional.ofNullable(toDoList)); + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(anyLong(), anyLong(), any())).thenReturn( + Optional.ofNullable(memberStudy)); + + // when + ToDoListUpdateResponseDTO responseDTO = memberStudyCommandService.deleteToDoList(1L, 1L); + + // then + verify(toDoListRepository, times(1)).deleteById(1L); + } + + @Test + @DisplayName("To-Do 삭제 - To-Do가 없는 경우") + void ToDo_삭제_시_ToDo가_없는_경우() { + // given + when(toDoListRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThrows(StudyHandler.class, () -> { + memberStudyCommandService.deleteToDoList(1L, 1L); + }); + } + + @Test + @DisplayName("To-Do 삭제 - To-Do가 다른 스터디의 것인 경우") + void ToDo_삭제_시_ToDo가_다른_스터디의_것인_경우() { + // given + when(toDoListRepository.findById(anyLong())).thenReturn(Optional.ofNullable(toDoList)); + given(toDoList.getStudy()).willReturn(Mockito.mock(Study.class)); + given(toDoList.getStudy().getId()).willReturn(2L); + + // when & then + assertThrows(StudyHandler.class, () -> { + memberStudyCommandService.deleteToDoList(1L, 1L); + }); + } + + @Test + @DisplayName("To-Do 삭제 - To-Do가 다른 회원의 것인 경우") + void ToDo_삭제_시_ToDo가_다른_회원의_것인_경우() { + // given + when(toDoListRepository.findById(anyLong())).thenReturn(Optional.ofNullable(toDoList)); + given(toDoList.getMember()).willReturn(Mockito.mock(Member.class)); + given(toDoList.getMember().getId()).willReturn(2L); + + // when & then + assertThrows(StudyHandler.class, () -> { + memberStudyCommandService.deleteToDoList(1L, 1L); + }); + } + + + +} diff --git a/src/test/java/com/example/spot/service/memberstudy/MemberStudyQueryServiceTest.java b/src/test/java/com/example/spot/service/memberstudy/MemberStudyQueryServiceTest.java index 94e5b029..01c7db33 100644 --- a/src/test/java/com/example/spot/service/memberstudy/MemberStudyQueryServiceTest.java +++ b/src/test/java/com/example/spot/service/memberstudy/MemberStudyQueryServiceTest.java @@ -8,16 +8,20 @@ import com.example.spot.domain.study.Schedule; import com.example.spot.domain.study.Study; import com.example.spot.domain.study.StudyPost; +import com.example.spot.domain.study.ToDoList; import com.example.spot.repository.MemberRepository; import com.example.spot.repository.MemberStudyRepository; import com.example.spot.repository.ScheduleRepository; import com.example.spot.repository.StudyPostRepository; +import com.example.spot.repository.ToDoListRepository; import com.example.spot.security.utils.SecurityUtils; +import com.example.spot.web.dto.memberstudy.request.toDo.ToDoListResponseDTO.ToDoListSearchResponseDTO; import com.example.spot.web.dto.study.response.StudyMemberResponseDTO; import com.example.spot.web.dto.study.response.StudyMemberResponseDTO.StudyApplicantDTO; import com.example.spot.web.dto.study.response.StudyMemberResponseDTO.StudyApplyMemberDTO; import com.example.spot.web.dto.study.response.StudyPostResponseDTO; import com.example.spot.web.dto.study.response.StudyScheduleResponseDTO; +import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Map; @@ -39,6 +43,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -61,25 +66,40 @@ public class MemberStudyQueryServiceTest { @Mock private ScheduleRepository scheduleRepository; @Mock + private ToDoListRepository toDoListRepository; + @Mock private SecurityUtils securityUtils; private static Member member; + private static Member member2; private static Study study; private static MemberStudy memberStudy; + private static MemberStudy studyMember; private static MemberStudy apply; + private static ToDoList toDoList; @BeforeEach void setup(){ member = Member.builder() .id(1L) .build(); + member2 = Member.builder() + .id(2L) + .build(); + study = Study.builder() .build(); memberStudy = MemberStudy.builder() .introduction("title").study(study).member(member).isOwned(true).status(ApplicationStatus.APPROVED).build(); + apply = MemberStudy.builder() .introduction("title").study(study).member(member).isOwned(false).status(ApplicationStatus.APPLIED).build(); + studyMember = MemberStudy.builder() + .introduction("title").study(study).member(member2).isOwned(true).status(ApplicationStatus.APPROVED).build(); + toDoList = ToDoList.builder() + .id(1L) + .build(); Long studyId = 1L; @@ -361,4 +381,110 @@ void setup(){ // when & then assertThrows(GeneralException.class, () -> memberStudyQueryService.isApplied(100L)); } + + /* ------------------------------------------------ To-Do 조회 --------------------------------------------------- */ + + @Test + @DisplayName("To-Do 조회 - 성공") + void ToDo_조회_성공() { + // given + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(1L, 100L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.ofNullable(memberStudy)); + when(toDoListRepository.findByStudyIdAndMemberIdAndDateOrderByCreatedAtDesc(anyLong(), anyLong(), any(), any())) + .thenReturn(List.of(toDoList)); + when(toDoListRepository.countByStudyIdAndMemberIdAndDate(anyLong(), anyLong(), any())) + .thenReturn(1L); + + // when + ToDoListSearchResponseDTO responseDTO = memberStudyQueryService.getToDoList(1L, LocalDate.MAX, PageRequest.of(0, 10)); + + // then + assertEquals(1, responseDTO.getTotalElements()); + assertEquals(1L, responseDTO.getContent().get(0).getId()); + } + + @Test + @DisplayName("To-Do 조회 - 로그인 한 회원이 스터디 회원이 아닌 경우") + void ToDo_조회_시_로그인_한_회원이_스터디_회원이_아닌_경우() { + // given + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(1L, 100L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.empty()); + + // when & then + assertThrows(GeneralException.class, () -> memberStudyQueryService.getToDoList(100L, LocalDate.MAX, PageRequest.of(0, 10))); + } + + @Test + @DisplayName("To-Do 조회 - 회원의 To-Do가 존재하지 않는 경우") + void ToDo가_존재하지_않는_경우() { + // given + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(1L, 100L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.ofNullable(memberStudy)); + when(toDoListRepository.findByStudyIdAndMemberIdAndDateOrderByCreatedAtDesc(anyLong(), anyLong(), any(), any())) + .thenReturn(List.of()); + + // when & then + assertThrows(GeneralException.class, () -> memberStudyQueryService.getToDoList(100L, LocalDate.MAX, PageRequest.of(0, 10))); + } + + /* ------------------------------------------------ 다른 스터디원의 To-Do 조회 --------------------------------------------------- */ + + @Test + @DisplayName("특정 스터디 원 To-Do 조회 - 성공") + void 스터디_원_ToDo_조회_성공() { + // given + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(1L, 1L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.ofNullable(memberStudy)); + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(2L, 1L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.ofNullable(studyMember)); + when(toDoListRepository.findByStudyIdAndMemberIdAndDateOrderByCreatedAtDesc(anyLong(), anyLong(), any(), any())) + .thenReturn(List.of(toDoList)); + when(toDoListRepository.countByStudyIdAndMemberIdAndDate(anyLong(), anyLong(), any())) + .thenReturn(1L); + + // when + ToDoListSearchResponseDTO responseDTO = memberStudyQueryService.getMemberToDoList(1L, 2L, LocalDate.MAX, PageRequest.of(0, 10)); + + // then + assertEquals(1, responseDTO.getTotalElements()); + assertEquals(1L, responseDTO.getContent().get(0).getId()); + } + + @Test + @DisplayName("특정 스터디 원 To-Do 조회 - 로그인 한 회원이 스터디 회원이 아닌 경우") + void 스터디_원_ToDo_조회_시_로그인_한_회원이_스터디_회원이_아닌_경우() { + // given + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(1L, 100L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.empty()); + + // when & then + assertThrows(GeneralException.class, () -> memberStudyQueryService.getMemberToDoList(100L, 2L, LocalDate.MAX, PageRequest.of(0, 10))); + } + + @Test + @DisplayName("특정 스터디 원 To-Do 조회 - 조회 하려는 회원이 스터디 회원이 아닌 경우") + void 스터디_원_ToDo_조회_시_조회_하려는_회원이_스터디_회원이_아닌_경우() { + // given + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(1L, 100L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.ofNullable(memberStudy)); + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(2L, 100L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.empty()); + + // when & then + assertThrows(GeneralException.class, () -> memberStudyQueryService.getMemberToDoList(100L, 2L, LocalDate.MAX, PageRequest.of(0, 10))); + } + + @Test + @DisplayName("특정 스터디 원 To-Do 조회 - 회원의 To-Do가 존재하지 않는 경우") + void 스터디_원_ToDo가_존재하지_않는_경우() { + // given + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(1L, 100L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.ofNullable(memberStudy)); + when(toDoListRepository.findByStudyIdAndMemberIdAndDateOrderByCreatedAtDesc(anyLong(), anyLong(), any(), any())) + .thenReturn(List.of()); + + // when & then + assertThrows(GeneralException.class, () -> memberStudyQueryService.getToDoList(100L, LocalDate.MAX, PageRequest.of(0, 10))); + } + } diff --git a/src/test/java/com/example/spot/service/notification/NotificationCommandServiceTest.java b/src/test/java/com/example/spot/service/notification/NotificationCommandServiceTest.java new file mode 100644 index 00000000..463e9464 --- /dev/null +++ b/src/test/java/com/example/spot/service/notification/NotificationCommandServiceTest.java @@ -0,0 +1,189 @@ +package com.example.spot.service.notification; + + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.when; + +import com.example.spot.api.exception.GeneralException; +import com.example.spot.domain.Member; +import com.example.spot.domain.Notification; +import com.example.spot.domain.enums.ApplicationStatus; +import com.example.spot.domain.enums.NotifyType; +import com.example.spot.domain.mapping.MemberStudy; +import com.example.spot.domain.study.Study; +import com.example.spot.repository.MemberStudyRepository; +import com.example.spot.repository.NotificationRepository; +import com.example.spot.web.dto.notification.NotificationResponseDTO.NotificationProcessDTO; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.BDDMockito; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class NotificationCommandServiceTest { + + @InjectMocks + private NotificationCommandServiceImpl notificationCommandService; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private MemberStudyRepository memberStudyRepository; + + @Mock + private Member member; + + @Mock + private Study study; + + private Notification notification1; + private Notification notification2; + private Notification notification3; + + private MemberStudy memberStudy; + + @BeforeEach + void init() { + this.notification1 = Notification.builder() + .id(1L).study(study).member(member).type(NotifyType.STUDY_APPLY).notifierName("Test") + .isChecked(false).build(); + this.notification2 = Notification.builder() + .id(2L).study(study).member(member).type(NotifyType.ANNOUNCEMENT).notifierName("Test") + .isChecked(false).build(); + this.notification2 = Notification.builder() + .id(3L).study(study).member(member).type(NotifyType.STUDY_APPLY).notifierName("Test") + .isChecked(true).build(); + + this.memberStudy = MemberStudy.builder().status(ApplicationStatus.APPLIED).build(); + } + + /* --------------------------------- 알림 읽음 처리 ----------------------------------- */ + + @Test + @DisplayName("알림 읽음 처리 성공") + void 알림_읽음_처리_성공() { + // given + given(member.getId()).willReturn(1L); + + when(notificationRepository.findById(anyLong())) + .thenReturn(Optional.ofNullable(notification1)); + + // when + NotificationProcessDTO response = notificationCommandService.readNotification(1L, 1L); + + // then + assertEquals(true, response.isAccept()); + } + + @Test + @DisplayName("알림이 없는 경우") + void 알림이_없는_경우() { + // given + when(notificationRepository.findById(anyLong())) + .thenReturn(Optional.empty()); + + // when & then + assertThrows(GeneralException.class, () -> { + notificationCommandService.readNotification(1L, 1L); + }); + } + + @Test + @DisplayName("알림이 사용자에게 속하지 않는 경우") + void 알림이_사용자에게_속하지_않는_경우() { + // given + given(member.getId()).willReturn(1L); + + when(notificationRepository.findById(anyLong())) + .thenReturn(Optional.ofNullable(notification1)); + + // when & then + assertThrows(GeneralException.class, () -> { + notificationCommandService.readNotification(2L, 1L); + }); + } + + @Test + @DisplayName("이미 읽음 처리된 경우") + void 이미_읽음_처리_된_경우() { + // given + given(member.getId()).willReturn(1L); + + when(notificationRepository.findById(anyLong())) + .thenReturn(Optional.ofNullable(notification3)); + + // when & then + assertThrows(GeneralException.class, () -> { + notificationCommandService.readNotification(1L, 1L); + }); + } + + /* --------------------------------- 스터디 알림 처리 ----------------------------------- */ + + @Test + @DisplayName("스터디 신청 처리 성공") + void 스터디_신청_처리_성공() { + // given + when(notificationRepository.findByMemberIdAndStudyIdAndTypeAndIsChecked( + anyLong(), anyLong(), any(), anyBoolean() + )).thenReturn(Optional.ofNullable(notification1)); + + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus( + anyLong(), anyLong(), any() + )).thenReturn(Optional.ofNullable(memberStudy)); + + // when + NotificationProcessDTO response = + notificationCommandService.joinAppliedStudy(1L, 1L, true); + + // then + assertEquals(true, response.isAccept()); + } + + @Test + @DisplayName("스터디 신청 알림이 이미 처리 된 경우") + void 스터디_알림이_이미_읽음_처리_된_경우() { + // given + when(notificationRepository.findByMemberIdAndStudyIdAndTypeAndIsChecked( + anyLong(), anyLong(), any(), anyBoolean() + )).thenReturn(Optional.ofNullable(notification3)); + + // when & then + assertThrows(GeneralException.class, () -> { + notificationCommandService.joinAppliedStudy(1L, 1L, true); + }); + } + + @Test + @DisplayName("스터디 신청 알림이 없는 경우") + void 스터디_신청이_없는_경우() { + // given + when(notificationRepository.findByMemberIdAndStudyIdAndTypeAndIsChecked( + anyLong(), anyLong(), any(), anyBoolean() + )).thenReturn(Optional.ofNullable(notification1)); + + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus( + anyLong(), anyLong(), any() + )).thenReturn(Optional.empty()); + + // when & then + assertThrows(GeneralException.class, () -> { + notificationCommandService.joinAppliedStudy(1L, 1L, true); + }); + } +} diff --git a/src/test/java/com/example/spot/service/notification/NotificationQueryServiceTest.java b/src/test/java/com/example/spot/service/notification/NotificationQueryServiceTest.java new file mode 100644 index 00000000..1d2004ee --- /dev/null +++ b/src/test/java/com/example/spot/service/notification/NotificationQueryServiceTest.java @@ -0,0 +1,147 @@ +package com.example.spot.service.notification; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.when; + +import com.example.spot.api.exception.GeneralException; +import com.example.spot.domain.Member; +import com.example.spot.domain.Notification; +import com.example.spot.domain.enums.NotifyType; +import com.example.spot.domain.study.Study; +import com.example.spot.repository.NotificationRepository; +import com.example.spot.web.dto.notification.NotificationResponseDTO.NotificationListDTO; +import com.example.spot.web.dto.notification.NotificationResponseDTO.StduyNotificationListDTO; +import com.example.spot.web.dto.notification.NotificationResponseDTO.StduyNotificationListDTO.StudyNotificationDTO; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.BDDMockito; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class NotificationQueryServiceTest { + + @InjectMocks + private NotificationQueryServiceImpl notificationQueryService; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private Member member; + + @Mock + private Study study; + + @Mock + private Pageable pageable; + + private Notification notification1; + private Notification notification2; + private Notification notification3; + + @BeforeEach + void init() { + this.notification1 = Notification.builder() + .id(1L).study(study).member(member).type(NotifyType.STUDY_APPLY).notifierName("Test") + .isChecked(false).build(); + this.notification2 = Notification.builder() + .id(2L).study(study).member(member).type(NotifyType.ANNOUNCEMENT).notifierName("Test") + .isChecked(false).build(); + this.notification2 = Notification.builder() + .id(3L).study(study).member(member).type(NotifyType.STUDY_APPLY).notifierName("Test") + .isChecked(true).build(); + } + + + /*---------------------- 스터디 알림 조회 ---------------------- */ + + @Test + @DisplayName("스터디 알림 조회 성공") + void 스터디_알림_조회_성공() { + // given + given(notification1.getStudy().getId()).willReturn(1L); + given(notification1.getStudy().getTitle()).willReturn("스터디 참여"); + given(notification1.getStudy().getProfileImage()).willReturn("img"); + + when(notificationRepository.findByMemberIdAndTypeAndIsChecked( + anyLong(), any(), any(), anyBoolean() + )).thenReturn(List.of(notification1)); + + + // when + StduyNotificationListDTO response = notificationQueryService.getAllAppliedStudyNotification(1L, pageable); + + // then + assertEquals(1L, response.getTotalNotificationCount()); + assertEquals(1L, response.getNotifications().get(0).getNotificationId()); + } + + @Test + @DisplayName("참여 신청한 스터디 알림이 없는 경우") + void 스터디_알림이_없는_경우() { + // given + when(notificationRepository.findByMemberIdAndTypeAndIsChecked( + anyLong(), any(), any(), anyBoolean() + )).thenReturn(List.of()); + + // when & then + assertThrows(GeneralException.class, () -> { + notificationQueryService.getAllAppliedStudyNotification( + 1L, pageable); + }); + } + + /*---------------------- 알림 전체 조회 ---------------------- */ + + @Test + @DisplayName("전체 알림 조회 성공") + void 전체_알림_조회_성공() { + // given + given(notification1.getStudy().getTitle()).willReturn("스터디 참여"); + + when(notificationRepository.findByMemberIdAndTypeNot( + anyLong(), any(), any() + )).thenReturn(List.of(notification1, notification2)); + + + // when + NotificationListDTO response = notificationQueryService.getAllNotifications(1L, pageable); + + // then + assertEquals(2L, response.getTotalNotificationCount()); + assertEquals(1L, response.getNotifications().get(0).getNotificationId()); + } + + @Test + @DisplayName("조회할 알림이 없는 경우") + void 조회할_알림이_없는_경우() { + // given + + when(notificationRepository.findByMemberIdAndTypeNot( + anyLong(), any(), any() + )).thenReturn(List.of()); + + // when & then + assertThrows(GeneralException.class, () -> { + notificationQueryService.getAllNotifications( + 1L, pageable); + }); + } + +} diff --git a/src/test/java/com/example/spot/service/study/StudyCommandServiceTest.java b/src/test/java/com/example/spot/service/study/StudyCommandServiceTest.java index 8afb7389..0c018c3f 100644 --- a/src/test/java/com/example/spot/service/study/StudyCommandServiceTest.java +++ b/src/test/java/com/example/spot/service/study/StudyCommandServiceTest.java @@ -1,20 +1,347 @@ package com.example.spot.service.study; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import com.example.spot.api.exception.handler.StudyHandler; +import com.example.spot.domain.Member; +import com.example.spot.domain.Region; +import com.example.spot.domain.Theme; +import com.example.spot.domain.enums.*; +import com.example.spot.domain.mapping.MemberStudy; +import com.example.spot.domain.mapping.RegionStudy; +import com.example.spot.domain.mapping.StudyTheme; +import com.example.spot.domain.study.Study; +import com.example.spot.repository.*; +import com.example.spot.web.dto.study.request.StudyJoinRequestDTO; +import com.example.spot.web.dto.study.request.StudyRegisterRequestDTO; +import com.example.spot.web.dto.study.response.StudyJoinResponseDTO; +import com.example.spot.web.dto.study.response.StudyRegisterResponseDTO; +import org.junit.jupiter.api.BeforeEach; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class StudyCommandServiceTest { + @Mock + private MemberRepository memberRepository; + + @Mock + private StudyRepository studyRepository; + @Mock + private MemberStudyRepository memberStudyRepository; + + @Mock + private RegionRepository regionRepository; + @Mock + private RegionStudyRepository regionStudyRepository; + + @Mock + private ThemeRepository themeRepository; + @Mock + private StudyThemeRepository studyThemeRepository; + + @InjectMocks + private StudyCommandServiceImpl studyCommandService; + + private static Study study; + private static Member member1; + private static Member member2; + private static Member owner; + private static MemberStudy member1Study; + private static MemberStudy ownerStudy; + private static Region region; + private static Theme theme; + + @BeforeEach + void setUp() { + initMember(); + initStudy(); + initMemberStudy(); + initRegion(); + initTheme(); + + when(memberRepository.findById(1L)).thenReturn(Optional.of(member1)); + when(memberRepository.findById(2L)).thenReturn(Optional.of(member2)); + when(memberRepository.findById(3L)).thenReturn(Optional.of(owner)); + + when(studyRepository.findById(1L)).thenReturn(Optional.of(study)); + when(regionRepository.findByCode("123456")).thenReturn(Optional.of(region)); + when(themeRepository.findByStudyTheme(ThemeType.자격증)).thenReturn(Optional.of(theme)); + + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(1L, 1L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.of(member1Study)); + when(memberStudyRepository.findByMemberIdAndStudyIdAndStatus(3L, 1L, ApplicationStatus.APPROVED)) + .thenReturn(Optional.of(ownerStudy)); + when(memberStudyRepository.findByMemberIdAndStudyIdAndIsOwned(3L, 1L, true)) + .thenReturn(Optional.of(ownerStudy)); + } + @Test - void applyToStudy() { + @DisplayName("스터디 신청 - (성공)") + void applyToStudy_Success() { + + // given + Long memberId = 2L; + Long studyId = 1L; + + getAuthentication(memberId); + + StudyJoinRequestDTO.StudyJoinDTO studyJoinRequestDTO = StudyJoinRequestDTO.StudyJoinDTO.builder() + .introduction("Hi") + .build(); + + MemberStudy memberStudy = MemberStudy.builder() + .id(3L) + .member(member2) + .study(study) + .status(ApplicationStatus.APPLIED) + .isOwned(false) + .introduction(studyJoinRequestDTO.getIntroduction()) + .build(); + + when(memberStudyRepository.countByStatusAndStudyId(ApplicationStatus.APPROVED, studyId)) + .thenReturn(2L); + when(memberStudyRepository.findByMemberIdAndStatusNot(memberId, ApplicationStatus.REJECTED)) + .thenReturn(List.of()); + when(memberStudyRepository.save(any(MemberStudy.class))) + .thenReturn(memberStudy); + + // when + StudyJoinResponseDTO.JoinDTO result = studyCommandService.applyToStudy(studyId, studyJoinRequestDTO); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(memberId); + verify(memberStudyRepository, times(1)).save(any(MemberStudy.class)); } @Test + @DisplayName("스터디 신청 - 이미 스터디에 신청했거나 스터디 회원인 경우(실패)") + void applyToStudy_StudyMember_Fail() { + + // given + Long memberId = 1L; + Long studyId = 1L; + + getAuthentication(memberId); + + StudyJoinRequestDTO.StudyJoinDTO studyJoinRequestDTO = StudyJoinRequestDTO.StudyJoinDTO.builder() + .introduction("Hi") + .build(); + + MemberStudy memberStudy = MemberStudy.builder() + .id(3L) + .member(member1) + .study(study) + .status(ApplicationStatus.APPLIED) + .isOwned(false) + .introduction(studyJoinRequestDTO.getIntroduction()) + .build(); + + when(memberStudyRepository.countByStatusAndStudyId(ApplicationStatus.APPROVED, studyId)) + .thenReturn(2L); + when(memberStudyRepository.findByMemberIdAndStatusNot(memberId, ApplicationStatus.REJECTED)) + .thenReturn(List.of(member1Study)); + when(memberStudyRepository.save(any(MemberStudy.class))) + .thenReturn(memberStudy); + + // when & then + assertThrows(StudyHandler.class, () -> studyCommandService.applyToStudy(studyId, studyJoinRequestDTO)); + } + + @Test + @DisplayName("스터디 신청 - 모집중인 스터디가 아닌 경우(실패)") + void applyToStudy_NotRecruitingStudy_Fail() { + + // given + Long memberId = 2L; + Long studyId = 2L; + + getAuthentication(memberId); + + Study study = Study.builder() + .title("마감된 스터디") + .maxPeople(1L) + .build(); + + StudyJoinRequestDTO.StudyJoinDTO studyJoinRequestDTO = StudyJoinRequestDTO.StudyJoinDTO.builder() + .introduction("Hi") + .build(); + + MemberStudy memberStudy = MemberStudy.builder() + .id(3L) + .member(member2) + .study(study) + .status(ApplicationStatus.APPLIED) + .isOwned(false) + .introduction(studyJoinRequestDTO.getIntroduction()) + .build(); + + when(memberStudyRepository.countByStatusAndStudyId(ApplicationStatus.APPROVED, studyId)) + .thenReturn(1L); + when(memberStudyRepository.findByMemberIdAndStatusNot(memberId, ApplicationStatus.REJECTED)) + .thenReturn(List.of()); + when(memberStudyRepository.save(any(MemberStudy.class))) + .thenReturn(memberStudy); + + // when & then + assertThrows(StudyHandler.class, () -> studyCommandService.applyToStudy(studyId, studyJoinRequestDTO)); + } + + @Test + @DisplayName("스터디 등록 - (성공)") void registerStudy() { + + // given + Long memberId = 1L; + + getAuthentication(memberId); + + StudyRegisterRequestDTO.RegisterDTO registerDTO = StudyRegisterRequestDTO.RegisterDTO.builder() + .themes(List.of(ThemeType.자격증)) + .title("새로운 스터디") + .goal("목표") + .introduction("소개") + .isOnline(false) + .profileImage("profileImage") + .regions(List.of("123456")) + .maxPeople(5L) + .gender(Gender.UNKNOWN) + .minAge(1) + .maxAge(100) + .fee(0) + .hasFee(false) + .build(); + + Study study = Study.builder() + .title("새로운 스터디") + .maxPeople(10L) + .build(); + + MemberStudy memberStudy = MemberStudy.builder() + .member(member1) + .study(study) + .build(); + + RegionStudy regionStudy = RegionStudy.builder() + .region(region) + .study(study) + .build(); + + StudyTheme studyTheme = StudyTheme.builder() + .theme(theme) + .study(study) + .build(); + + when(studyRepository.save(any(Study.class))).thenReturn(study); + when(memberStudyRepository.save(any(MemberStudy.class))).thenReturn(memberStudy); + when(regionStudyRepository.save(any(RegionStudy.class))).thenReturn(regionStudy); + when(studyThemeRepository.save(any(StudyTheme.class))).thenReturn(studyTheme); + + // when + StudyRegisterResponseDTO.RegisterDTO result = studyCommandService.registerStudy(registerDTO); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTitle()).isEqualTo("새로운 스터디"); + verify(studyRepository, times(2)).save(any(Study.class)); + verify(memberStudyRepository, times(1)).save(any(MemberStudy.class)); + verify(regionStudyRepository, times(1)).save(any(RegionStudy.class)); + verify(studyThemeRepository, times(1)).save(any(StudyTheme.class)); } @Test void likeStudy() { } + +/*-------------------------------------------------------- Utils ------------------------------------------------------------------------*/ + + private static void initMember() { + member1 = Member.builder() + .id(1L) + .scheduleList(new ArrayList<>()) + .build(); + member2 = Member.builder() + .id(2L) + .scheduleList(new ArrayList<>()) + .build(); + owner = Member.builder() + .id(3L) + .scheduleList(new ArrayList<>()) + .build(); + } + + private static void initStudy() { + study = Study.builder() + .gender(Gender.MALE) + .minAge(20) + .maxAge(29) + .fee(10000) + .profileImage("a.jpg") + .hasFee(true) + .isOnline(true) + .goal("SQLD") + .introduction("SQLD 자격증 스터디") + .title("SQLD Master") + .maxPeople(10L) + .build(); + } + + private static void initMemberStudy() { + ownerStudy = MemberStudy.builder() + .id(1L) + .status(ApplicationStatus.APPROVED) + .isOwned(true) + .introduction("Hi") + .member(owner) + .study(study) + .build(); + member1Study = MemberStudy.builder() + .id(2L) + .status(ApplicationStatus.APPROVED) + .isOwned(false) + .introduction("Hi") + .member(member1) + .study(study) + .build(); + } + + private static void initRegion() { + region = Region.builder() + .code("123456") + .build(); + } + + private static void initTheme() { + theme = Theme.builder() + .studyTheme(ThemeType.자격증) + .build(); + } + + private static void getAuthentication(Long memberId) { + String idString = String.valueOf(memberId); + Authentication authentication = new UsernamePasswordAuthenticationToken(idString, null, Collections.emptyList()); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + } } \ No newline at end of file