From 6729a1846613597e4ded5b2787e6810dd6708ca7 Mon Sep 17 00:00:00 2001 From: Kang Dong Hyeon Date: Sat, 14 Jun 2025 19:44:15 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EA=B8=B0=EB=B3=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SignUpRequest DTO 추가 (이메일, 비밀번호, 사용자명 검증) - AccountService 구현 (회원 조회, 등록 기능) - Member 엔티티 필수 필드 설정 (username, password nullable=false) - Spring Validation 의존성 추가 - 비밀번호 암호화 준비 (PasswordEncoder 주입) --- account-service/build.gradle | 2 + .../account_service/domain/Member.java | 4 +- .../account_service/dto/SignUpRequest.java | 20 +++++++++ .../service/AccountService.java | 42 +++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 account-service/src/main/java/com/synapse/account_service/dto/SignUpRequest.java create mode 100644 account-service/src/main/java/com/synapse/account_service/service/AccountService.java diff --git a/account-service/build.gradle b/account-service/build.gradle index a03aef4..1c750b3 100644 --- a/account-service/build.gradle +++ b/account-service/build.gradle @@ -40,6 +40,8 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' //H2 Database implementation 'com.h2database:h2' + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' } tasks.named('test') { diff --git a/account-service/src/main/java/com/synapse/account_service/domain/Member.java b/account-service/src/main/java/com/synapse/account_service/domain/Member.java index 0303a9f..924dae5 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/Member.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/Member.java @@ -19,10 +19,10 @@ public class Member extends BaseEntity { @Column(name = "member_id") private Long id; - @Column(name = "username") + @Column(name = "username", nullable = false) private String username; - @Column(name = "password") + @Column(name = "password", nullable = false) private String password; @Column(name = "email", nullable = false, unique = true, length = 50) diff --git a/account-service/src/main/java/com/synapse/account_service/dto/SignUpRequest.java b/account-service/src/main/java/com/synapse/account_service/dto/SignUpRequest.java new file mode 100644 index 0000000..49f8c91 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/dto/SignUpRequest.java @@ -0,0 +1,20 @@ +package com.synapse.account_service.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record SignUpRequest( + @NotBlank(message = "이메일은 필수 입력 항목입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + String email, + + @NotBlank(message = "비밀번호는 필수 입력 항목입니다.") + @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") + String password, + + @NotBlank(message = "사용자 이름은 필수 입력 항목입니다.") + String username +) { + +} diff --git a/account-service/src/main/java/com/synapse/account_service/service/AccountService.java b/account-service/src/main/java/com/synapse/account_service/service/AccountService.java new file mode 100644 index 0000000..0fd41fe --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/AccountService.java @@ -0,0 +1,42 @@ +package com.synapse.account_service.service; + +import java.util.Optional; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.enums.MemberRole; +import com.synapse.account_service.dto.SignUpRequest; +import com.synapse.account_service.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class AccountService { + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + public Optional findMemberByEmail(String email) { + return memberRepository.findByEmail(email); + } + + public Member registerMember(SignUpRequest request) { + Member member = Member.builder() + .email(request.email()) + // .password(encryptedPassword) // 암호화된 비밀번호 저장 + .username(request.username()) + .role(MemberRole.USER) // 기본 역할은 USER + .provider("local") // 일반 회원가입이므로 "local"로 지정 + .build(); + + findMemberByEmail(request.email()).ifPresent(m -> { + // throw new UnauthorizedException(ExceptionType.DUPLICATED_EMAIL); + }); + + return member; + } +} From 75478d0aacd41b9c58b66ce0f14ae248cc09c2d4 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sat, 14 Jun 2025 22:29:53 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원가입 시 비밀번호 암호화 로직 추가 - 중복 이메일/사용자명 검증 로직 추가 - 기본 구독 정보 자동 생성 기능 추가 - 예외 처리 체계 개선 (DuplicatedException 도입) - 테스트 코드 보강 (Controller, Service 계층) --- .../config/SecurityConfig.java | 33 +++++++ .../controller/AccountController.java | 28 ++++++ .../account_service/domain/Member.java | 6 ++ .../dto/{ => request}/SignUpRequest.java | 10 +- .../dto/response/SignUpResponse.java | 14 +++ .../exception/AccountServiceException.java | 13 +++ .../exception/DuplicatedException.java | 7 ++ .../exception/ExceptionType.java | 23 +++++ .../exception/GlobalExceptionHandler.java | 63 +++++++++++++ .../exception/dto/ExceptionResponse.java | 17 ++++ .../repository/MemberRepository.java | 1 + .../service/AccountService.java | 47 ++++++++-- .../controller/AccountControllerTest.java | 91 +++++++++++++++++++ .../service/AccountServiceTest.java | 83 +++++++++++++++++ .../src/test/resources/application-test.yml | 2 +- 15 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java create mode 100644 account-service/src/main/java/com/synapse/account_service/controller/AccountController.java rename account-service/src/main/java/com/synapse/account_service/dto/{ => request}/SignUpRequest.java (86%) create mode 100644 account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java create mode 100644 account-service/src/main/java/com/synapse/account_service/exception/AccountServiceException.java create mode 100644 account-service/src/main/java/com/synapse/account_service/exception/DuplicatedException.java create mode 100644 account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java create mode 100644 account-service/src/main/java/com/synapse/account_service/exception/GlobalExceptionHandler.java create mode 100644 account-service/src/main/java/com/synapse/account_service/exception/dto/ExceptionResponse.java create mode 100644 account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java create mode 100644 account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java diff --git a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java new file mode 100644 index 0000000..49d8403 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java @@ -0,0 +1,33 @@ +package com.synapse.account_service.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/accounts/signup").permitAll() + .anyRequest().authenticated() + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/controller/AccountController.java b/account-service/src/main/java/com/synapse/account_service/controller/AccountController.java new file mode 100644 index 0000000..0e823e3 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/controller/AccountController.java @@ -0,0 +1,28 @@ +package com.synapse.account_service.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.synapse.account_service.dto.request.SignUpRequest; +import com.synapse.account_service.dto.response.SignUpResponse; +import com.synapse.account_service.service.AccountService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +import static org.springframework.http.HttpStatus.CREATED; + +@RestController +@RequestMapping("/api/accounts") +@RequiredArgsConstructor +public class AccountController { + private final AccountService accountService; + + @PostMapping("/signup") + public ResponseEntity signUp(@Valid @RequestBody SignUpRequest request) { + return ResponseEntity.status(CREATED).body(accountService.registerMember(request)); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/domain/Member.java b/account-service/src/main/java/com/synapse/account_service/domain/Member.java index 924dae5..73e6df2 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/Member.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/Member.java @@ -1,5 +1,7 @@ package com.synapse.account_service.domain; +import org.springframework.security.crypto.password.PasswordEncoder; + import com.synapse.account_service.common.BaseEntity; import com.synapse.account_service.domain.enums.MemberRole; @@ -61,4 +63,8 @@ public void setSubscription(Subscription subscription) { subscription.setMemberInternal(this); // 무한 루프 방지를 위해 내부 메서드 호출 } } + + public void encodePassword(PasswordEncoder passwordEncoder) { + this.password = passwordEncoder.encode(this.password); + } } diff --git a/account-service/src/main/java/com/synapse/account_service/dto/SignUpRequest.java b/account-service/src/main/java/com/synapse/account_service/dto/request/SignUpRequest.java similarity index 86% rename from account-service/src/main/java/com/synapse/account_service/dto/SignUpRequest.java rename to account-service/src/main/java/com/synapse/account_service/dto/request/SignUpRequest.java index 49f8c91..453fa3f 100644 --- a/account-service/src/main/java/com/synapse/account_service/dto/SignUpRequest.java +++ b/account-service/src/main/java/com/synapse/account_service/dto/request/SignUpRequest.java @@ -1,4 +1,4 @@ -package com.synapse.account_service.dto; +package com.synapse.account_service.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -9,12 +9,12 @@ public record SignUpRequest( @Email(message = "유효한 이메일 형식이 아닙니다.") String email, + @NotBlank(message = "사용자 이름은 필수 입력 항목입니다.") + String username, + @NotBlank(message = "비밀번호는 필수 입력 항목입니다.") @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") - String password, - - @NotBlank(message = "사용자 이름은 필수 입력 항목입니다.") - String username + String password ) { } diff --git a/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java b/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java new file mode 100644 index 0000000..94691be --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java @@ -0,0 +1,14 @@ +package com.synapse.account_service.dto.response; + +import com.synapse.account_service.domain.Member; + +public record SignUpResponse( + Long id, + String email, + String username, + String role +) { + public static SignUpResponse from(Member member) { + return new SignUpResponse(member.getId(), member.getEmail(), member.getUsername(), member.getRole().name()); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/AccountServiceException.java b/account-service/src/main/java/com/synapse/account_service/exception/AccountServiceException.java new file mode 100644 index 0000000..f848d0b --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/AccountServiceException.java @@ -0,0 +1,13 @@ +package com.synapse.account_service.exception; + +import lombok.Getter; + +@Getter +public abstract class AccountServiceException extends RuntimeException { + private final ExceptionType exceptionType; + + protected AccountServiceException(final ExceptionType exceptionType) { + super(exceptionType.getMessage()); + this.exceptionType = exceptionType; + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/DuplicatedException.java b/account-service/src/main/java/com/synapse/account_service/exception/DuplicatedException.java new file mode 100644 index 0000000..ad3555a --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/DuplicatedException.java @@ -0,0 +1,7 @@ +package com.synapse.account_service.exception; + +public class DuplicatedException extends AccountServiceException { + public DuplicatedException(ExceptionType exceptionType) { + super(exceptionType); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java b/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java new file mode 100644 index 0000000..5d31e1b --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java @@ -0,0 +1,23 @@ +package com.synapse.account_service.exception; + +import org.springframework.http.HttpStatus; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ExceptionType { + DUPLICATED_EMAIL(CONFLICT, "001", "이미 존재하는 이메일입니다."), + DUPLICATED_USERNAME(CONFLICT, "002", "이미 존재하는 사용자 이름입니다."), + EXCEPTION(INTERNAL_SERVER_ERROR, "003", "예상치 못한 오류가 발생했습니다.") + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/GlobalExceptionHandler.java b/account-service/src/main/java/com/synapse/account_service/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..ce28acf --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/GlobalExceptionHandler.java @@ -0,0 +1,63 @@ +package com.synapse.account_service.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import com.synapse.account_service.exception.dto.ExceptionResponse; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private static final Logger log = LoggerFactory.getLogger("ErrorLogger"); + + private static final String LOG_FORMAT_INFO_PATTERN = "[🔵INFO] - (%s %s)\nExceptionType: %s\n %s: %s"; + private static final String LOG_FORMAT_WARN_PATTERN = "[🟠WARN] - (%s %s)\nExceptionType: %s\n %s: %s"; + private static final String LOG_FORMAT_ERROR_PATTERN = "[🔴ERROR] - (%s %s)\nExceptionType: %s\n %s: %s"; + + @ExceptionHandler(AccountServiceException.class) + public ResponseEntity handleAccountServiceException(AccountServiceException e, HttpServletRequest request) { + logInfo(e, request); + return ResponseEntity.status(e.getExceptionType().getStatus()).body(ExceptionResponse.from(e)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e, HttpServletRequest request) { + logError(e, request); + return ResponseEntity + .status(ExceptionType.EXCEPTION.getStatus()) + .body(new ExceptionResponse(ExceptionType.EXCEPTION.getCode(), ExceptionType.EXCEPTION.getMessage())); + } + + private void logInfo(AccountServiceException e, HttpServletRequest request) { + log.info(String.format(LOG_FORMAT_INFO_PATTERN, + request.getMethod(), + request.getRequestURI(), + e.getExceptionType(), + e.getClass().getName(), + e.getMessage())); + } + + private void logWarn(AccountServiceException e, HttpServletRequest request) { + log.warn(String.format(LOG_FORMAT_WARN_PATTERN, + request.getMethod(), + request.getRequestURI(), + e.getExceptionType(), + e.getClass().getName(), + e.getMessage())); + } + + private void logError(Exception e, HttpServletRequest request) { + log.error(String.format(LOG_FORMAT_ERROR_PATTERN, + request.getMethod(), + request.getRequestURI(), + e.getClass().getName(), + e.getMessage())); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/dto/ExceptionResponse.java b/account-service/src/main/java/com/synapse/account_service/exception/dto/ExceptionResponse.java new file mode 100644 index 0000000..0337fe0 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/dto/ExceptionResponse.java @@ -0,0 +1,17 @@ +package com.synapse.account_service.exception.dto; + +import com.synapse.account_service.exception.AccountServiceException; +import com.synapse.account_service.exception.ExceptionType; + +public record ExceptionResponse( + String code, + String message +) { + public static ExceptionResponse from(AccountServiceException exception) { + return ExceptionResponse.from(exception.getExceptionType()); + } + + public static ExceptionResponse from(ExceptionType exceptionType) { + return new ExceptionResponse(exceptionType.getCode(), exceptionType.getMessage()); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java b/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java index 7f2f3af..0a3c691 100644 --- a/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java +++ b/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java @@ -8,5 +8,6 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); + Optional findByUsername(String username); Optional findByProviderAndRegistrationId(String provider, String registrationId); } diff --git a/account-service/src/main/java/com/synapse/account_service/service/AccountService.java b/account-service/src/main/java/com/synapse/account_service/service/AccountService.java index 0fd41fe..81c43ba 100644 --- a/account-service/src/main/java/com/synapse/account_service/service/AccountService.java +++ b/account-service/src/main/java/com/synapse/account_service/service/AccountService.java @@ -1,5 +1,8 @@ package com.synapse.account_service.service; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Optional; import org.springframework.security.crypto.password.PasswordEncoder; @@ -7,8 +10,13 @@ import org.springframework.transaction.annotation.Transactional; import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.Subscription; import com.synapse.account_service.domain.enums.MemberRole; -import com.synapse.account_service.dto.SignUpRequest; +import com.synapse.account_service.domain.enums.SubscriptionTier; +import com.synapse.account_service.dto.request.SignUpRequest; +import com.synapse.account_service.dto.response.SignUpResponse; +import com.synapse.account_service.exception.ExceptionType; +import com.synapse.account_service.exception.DuplicatedException; import com.synapse.account_service.repository.MemberRepository; import lombok.RequiredArgsConstructor; @@ -24,19 +32,44 @@ public Optional findMemberByEmail(String email) { return memberRepository.findByEmail(email); } - public Member registerMember(SignUpRequest request) { + public Optional findMemberByUsername(String username) { + return memberRepository.findByUsername(username); + } + + public SignUpResponse registerMember(SignUpRequest request) { + findMemberByEmail(request.email()).ifPresent(m -> { + throw new DuplicatedException(ExceptionType.DUPLICATED_EMAIL); + }); + + findMemberByUsername(request.username()).ifPresent(m -> { + throw new DuplicatedException(ExceptionType.DUPLICATED_USERNAME); + }); + Member member = Member.builder() .email(request.email()) - // .password(encryptedPassword) // 암호화된 비밀번호 저장 + .password(request.password()) .username(request.username()) .role(MemberRole.USER) // 기본 역할은 USER .provider("local") // 일반 회원가입이므로 "local"로 지정 .build(); - findMemberByEmail(request.email()).ifPresent(m -> { - // throw new UnauthorizedException(ExceptionType.DUPLICATED_EMAIL); - }); + member.encodePassword(passwordEncoder); + + createAndSetDefaultSubscription(member); + + memberRepository.save(member); + + return SignUpResponse.from(member); + } + + private void createAndSetDefaultSubscription(Member member) { + ZonedDateTime nextRenewalDate = ZonedDateTime.now(ZoneId.systemDefault()).plusDays(1).with(LocalTime.MIDNIGHT); // 무료 사용자는 자정 초기화 - return member; + Subscription freeSubscription = Subscription.builder() + .tier(SubscriptionTier.FREE) + .nextRenewalDate(nextRenewalDate) + .build(); + + member.setSubscription(freeSubscription); } } diff --git a/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java b/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java new file mode 100644 index 0000000..93d63f8 --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java @@ -0,0 +1,91 @@ +package com.synapse.account_service.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.springframework.http.MediaType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.account_service.config.SecurityConfig; +import com.synapse.account_service.dto.request.SignUpRequest; +import com.synapse.account_service.dto.response.SignUpResponse; +import com.synapse.account_service.exception.ExceptionType; +import com.synapse.account_service.exception.GlobalExceptionHandler; +import com.synapse.account_service.exception.DuplicatedException; +import com.synapse.account_service.service.AccountService; + +@WebMvcTest(AccountController.class) // AccountController만 테스트 +@Import({GlobalExceptionHandler.class, SecurityConfig.class}) +public class AccountControllerTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private AccountService accountService; + + @Test + @DisplayName("회원가입 API 호출 성공") + void signUpApi_success() throws Exception { + // given + SignUpRequest request = new SignUpRequest("test@example.com", "유저", "password1234"); + SignUpResponse response = new SignUpResponse(1L, "test@example.com", "유저", "USER"); + + given(accountService.registerMember(any(SignUpRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/accounts/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) // 201 Created 상태인지 확인 + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.username").value("유저")) + .andExpect(jsonPath("$.role").value("USER")); + } + + @Test + @DisplayName("이메일 중복 시 409 Conflict 응답") + void signUpApi_fail_withDuplicateEmail() throws Exception { + // given + SignUpRequest request = new SignUpRequest("test1@example.com", "유저", "password1234"); + + // accountService.registerMember가 호출되면 BusinessException을 던지도록 설정 + given(accountService.registerMember(any(SignUpRequest.class))) + .willThrow(new DuplicatedException(ExceptionType.DUPLICATED_EMAIL)); + + // when & then + mockMvc.perform(post("/api/accounts/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) // 409 Conflict 상태인지 확인 + .andExpect(jsonPath("$.code").value(ExceptionType.DUPLICATED_EMAIL.getCode())); + } + + @Test + @DisplayName("잘못된 요청값으로 회원가입 API 호출 시 400 Bad Request 응답") + void signUpApi_fail_withInvalidInput() throws Exception { + // given + // 이메일 형식이 잘못된 요청 + SignUpRequest request = new SignUpRequest("test.com", "password1234", "유저"); + + // when & then + mockMvc.perform(post("/api/accounts/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); // @Valid에 의해 400 Bad Request가 발생하는지 확인 + } +} diff --git a/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java b/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java new file mode 100644 index 0000000..38a925f --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java @@ -0,0 +1,83 @@ +package com.synapse.account_service.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.any; + +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 org.springframework.security.crypto.password.PasswordEncoder; + +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.enums.MemberRole; +import com.synapse.account_service.dto.request.SignUpRequest; +import com.synapse.account_service.dto.response.SignUpResponse; +import com.synapse.account_service.exception.DuplicatedException; +import com.synapse.account_service.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +public class AccountServiceTest { + + @InjectMocks + private AccountService accountService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Test + @DisplayName("회원가입 성공") + void signUp_success() { + // given: 테스트 준비 + SignUpRequest request = new SignUpRequest("test@example.com", "테스트유저", "password123"); + Member member = Member.builder() + .email(request.email()) + .password("encodedPassword") + .role(MemberRole.USER) + .build(); + + // memberRepository.findByEmail이 호출되면, 비어있는 Optional을 반환하도록 설정 (중복 없음) + given(memberRepository.findByEmail(anyString())).willReturn(Optional.empty()); + // passwordEncoder.encode가 호출되면, "encodedPassword"를 반환하도록 설정 + given(passwordEncoder.encode(anyString())).willReturn("encodedPassword"); + // memberRepository.save가 호출되면, 준비된 member 객체를 반환하도록 설정 + given(memberRepository.save(any(Member.class))).willReturn(member); + + // when: 실제 테스트할 메서드 호출 + SignUpResponse response = accountService.registerMember(request); + + // then: 결과 검증 + assertThat(response.email()).isEqualTo("test@example.com"); + + // passwordEncoder.encode가 한 번 호출되었는지 검증 + verify(passwordEncoder).encode("password123"); + // memberRepository.save가 한 번 호출되었는지 검증 + verify(memberRepository).save(any(Member.class)); + } + + @Test + @DisplayName("이메일 중복으로 회원가입 실패") + void signUp_fail_withDuplicateEmail() { + // given + SignUpRequest request = new SignUpRequest("test@example.com", "테스트유저", "password123"); + + // memberRepository.findByEmail이 호출되면, 이미 존재하는 Member 객체를 반환하도록 설정 + given(memberRepository.findByEmail(anyString())).willReturn(Optional.of(Member.builder().build())); + + // when & then: BusinessException이 발생하는지 검증 + assertThrows(DuplicatedException.class, () -> { + accountService.registerMember(request); + }); + } +} diff --git a/account-service/src/test/resources/application-test.yml b/account-service/src/test/resources/application-test.yml index b2a30b2..94752a0 100644 --- a/account-service/src/test/resources/application-test.yml +++ b/account-service/src/test/resources/application-test.yml @@ -18,7 +18,7 @@ spring: highlight: sql: true hbm2ddl: - auto: create-drop + auto: create dialect: org.hibernate.dialect.PostgreSQLDialect open-in-view: false show-sql: true From f044ff1a96988f881599301c47d27448b32034d8 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sat, 14 Jun 2025 22:42:45 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore=20:=20=EA=B9=83=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EA=B6=8C=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend-ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 81ed733..3627ca0 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -10,6 +10,11 @@ on: branches: - 'feature/**' +permissions: + contents: read + checks: write + pull-requests: write + jobs: build-and-test: runs-on: ubuntu-latest