diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7001101 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: Java CI with Gradle and JaCoCo + +on: + pull_request: + branches: [ "main", "develop" ] + +permissions: + contents: read + pull-requests: write # PR에 댓글을 쓰기 위한 권한 + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Run build with Gradle + env: + SPRING_PROFILES_ACTIVE: test + run: ./gradlew build test + + - name: Add JaCoCo Report to PR + id: jacoco + uses: madrapps/jacoco-report@v1.6.1 + with: + paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 40 # 전체 커버리지 최소값 (옵션) + min-coverage-changed-files: 60 # 변경된 파일의 커버리지 최소값 (옵션) + title: 'Test Coverage Report' + update-comment: true diff --git a/build.gradle b/build.gradle index bdd3847..0b75a7b 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.5' id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' } group = 'kr.ac.ks' @@ -13,6 +14,10 @@ java { } } +jacoco { + toolVersion = "0.8.9" +} + configurations { compileOnly { extendsFrom annotationProcessor @@ -26,6 +31,8 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' @@ -39,3 +46,35 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +test { + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn(test) + + reports { + xml.required.set(true) + html.required.set(true) + + // 생성될 리포트의 위치 지정 + xml.destination(file("${buildDir}/reports/jacoco/test/jacocoTestReport.xml")) + html.destination(file("${buildDir}/reports/jacoco/test/jacocoTestReport.html")) + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, excludes: [ + "**/*Application.class", + "**/config/**", + "**/common/**", + "**/exeption/**", + "**/response/**", + "**/swagger/**", + "**/code/**", + "**/dto/**" + ]) + })) + } +} \ No newline at end of file diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/common/BaseEntity.java b/src/main/java/kr/ac/ks/cs_web_back/domain/common/BaseEntity.java new file mode 100644 index 0000000..bcc5a4e --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/common/BaseEntity.java @@ -0,0 +1,23 @@ +package kr.ac.ks.cs_web_back.domain.common; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @CreatedDate + private LocalDateTime generatedAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; + +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberController.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberController.java new file mode 100644 index 0000000..ce1a57e --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberController.java @@ -0,0 +1,27 @@ +package kr.ac.ks.cs_web_back.domain.member.controller; + +import jakarta.validation.Valid; +import kr.ac.ks.cs_web_back.domain.member.controller.code.MemberSuccessCode; +import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest; +import kr.ac.ks.cs_web_back.domain.member.service.MemberService; +import kr.ac.ks.cs_web_back.global.response.CsResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/member") +@RequiredArgsConstructor +public class MemberController implements SpringDocMemberController { + + private final MemberService memberService; + + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + public CsResponse register( + @Valid @RequestBody MemberCreateRequest request + ) { + Long id = memberService.createMember(request); + return CsResponse.of(MemberSuccessCode.GENERATED_REGISTERED, id); + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/SpringDocMemberController.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/SpringDocMemberController.java new file mode 100644 index 0000000..1f5fd2e --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/SpringDocMemberController.java @@ -0,0 +1,30 @@ +package kr.ac.ks.cs_web_back.domain.member.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest; +import kr.ac.ks.cs_web_back.global.response.CsResponse; +import kr.ac.ks.cs_web_back.global.swagger.error.ApiErrorResponse; +import kr.ac.ks.cs_web_back.global.swagger.error.ErrorCase; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "멤버 API", description = "회원 관리 API") +public interface SpringDocMemberController { + + @Operation(summary = "회원가입", description = "회원 가입") + @ApiResponse(responseCode = "201", description = "회원가입에 성공했습니다.") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/member/register", errorCases = { + @ErrorCase(description = "이메일 없음", code = 9010, exampleMessage = "이메일이 입력되지 않았습니다."), + @ErrorCase(description = "비밀번호 없음", code = 9011, exampleMessage = "비밀번호가 입력되지 않았습니다."), + @ErrorCase(description = "사용자명 없음", code = 9012, exampleMessage = "사용자명이 입력되지 않았습니다.") + }) + @ApiErrorResponse(status = HttpStatus.CONFLICT, instance = "/member/register", errorCases = { + @ErrorCase(description = "중복 이메일", code = 5010, exampleMessage = "이미 존재하는 이메일입니다."), + @ErrorCase(description = "중복 사용자명", code = 5011, exampleMessage = "이미 존재하는 사용자명입니다.") + }) + CsResponse register( + @RequestBody MemberCreateRequest request + ); +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/code/MemberExceptionCode.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/code/MemberExceptionCode.java new file mode 100644 index 0000000..1ac9747 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/code/MemberExceptionCode.java @@ -0,0 +1,20 @@ +package kr.ac.ks.cs_web_back.domain.member.controller.code; + +import kr.ac.ks.cs_web_back.global.exeption.dto.ExceptionCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberExceptionCode implements ExceptionCode { + + BAD_REQUEST_NO_EMAIL(9010, "이메일이 입력되지 않았습니다."), + BAD_REQUEST_NO_PASSWORD(9011, "비밀번호가 입력되지 않았습니다."), + BAD_REQUEST_NO_USERNAME(9012, "사용자명이 입력되지 않았습니다."), + CONFLICT_EMAIL(5010, "이미 존재하는 이메일입니다."), + CONFLICT_USERNAME(5012, "이미 존재하는 사용자명입니다."), + ; + + private final int code; + private final String message; +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/code/MemberSuccessCode.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/code/MemberSuccessCode.java new file mode 100644 index 0000000..1c7cab9 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/code/MemberSuccessCode.java @@ -0,0 +1,21 @@ +package kr.ac.ks.cs_web_back.domain.member.controller.code; + +import kr.ac.ks.cs_web_back.global.response.SuccessCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberSuccessCode implements SuccessCode { + + GENERATED_REGISTERED(2101, "회원가입에 성공했습니다."), + OK_WITHDRAWN(2102,"회원 탈퇴에 성공했습니다."), + OK_FOUND_USER_PROFILE(2103, "사용자 정보 조회에 성공했습니다."), + OK_MODIFIED_USER_PROFILE(2104, "사용자 정보 변경에 성공했습니다."), + OK_FOUND_USER_WRITTEN_POSTS(2105, "해당 사용자가 작성한 게시글을 조회했습니다."), + OK_FOUND_USER_WRITTEN_COMMENTS(2106, "해당 사용자가 작성한 댓글을 조회했습니다."), + ; + + private final int code; + private final String message; +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/dto/request/MemberCreateRequest.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/dto/request/MemberCreateRequest.java new file mode 100644 index 0000000..f7e35fa --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/dto/request/MemberCreateRequest.java @@ -0,0 +1,23 @@ +package kr.ac.ks.cs_web_back.domain.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; + +@Schema(description = "회원가입") +public record MemberCreateRequest( + @Schema(description = "이메일", example = "example@ks.ac.kr") + @NotBlank(message = "이메일이 입력되지 않았습니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @Schema(description = "비밀번호", example = "examplePassword123") + @NotBlank(message = "비밀번호가 입력되지 않았습니다.") + @Size(max = 24, message = "비밀번호는 24자 내로 입력해 주세요.") + String password, + + @Schema(description = "사용자명", example = "userNickName") + @NotBlank(message = "사용자명이 입력되지 않았습니다.") + @Size(max = 12, message = "유저명은 12자 내로 입력해 주세요.") + String username +) { +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/model/Member.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/model/Member.java new file mode 100644 index 0000000..ce27c0a --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/model/Member.java @@ -0,0 +1,43 @@ +package kr.ac.ks.cs_web_back.domain.member.model; + +import jakarta.persistence.*; +import kr.ac.ks.cs_web_back.domain.common.BaseEntity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String username; + + @Column + private LocalDate birthdate; + + @Column + private String number; + + @Builder + private Member(String email, String password, String username, LocalDate birthdate, String number) { + this.email = email; + this.password = password; + this.username = username; + this.birthdate = birthdate; + this.number = number; + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/repository/MemberRepository.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..3e5c7f9 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/repository/MemberRepository.java @@ -0,0 +1,9 @@ +package kr.ac.ks.cs_web_back.domain.member.repository; + +import kr.ac.ks.cs_web_back.domain.member.model.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + boolean existsByEmail(String email); + boolean existsByUsername(String username); +} \ No newline at end of file diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/service/MemberService.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/service/MemberService.java new file mode 100644 index 0000000..a8b60d6 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/service/MemberService.java @@ -0,0 +1,38 @@ +package kr.ac.ks.cs_web_back.domain.member.service; + +import kr.ac.ks.cs_web_back.domain.member.controller.code.MemberExceptionCode; +import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest; +import kr.ac.ks.cs_web_back.domain.member.model.Member; +import kr.ac.ks.cs_web_back.domain.member.repository.MemberRepository; +import kr.ac.ks.cs_web_back.global.exeption.domain.ConflictException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + public Long createMember(MemberCreateRequest request) { + if (memberRepository.existsByEmail(request.email())) + throw new ConflictException(MemberExceptionCode.CONFLICT_EMAIL); + + if (memberRepository.existsByUsername(request.username())) + throw new ConflictException(MemberExceptionCode.CONFLICT_USERNAME); + + String encodedPassword = passwordEncoder.encode(request.password()); + + Member member = Member.builder() + .email(request.email()) + .password(encodedPassword) + .username(request.username()) + .build(); + + return memberRepository.save(member).getId(); + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/config/JpaConfig.java b/src/main/java/kr/ac/ks/cs_web_back/global/config/JpaConfig.java index 34ceef5..48c47f1 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/config/JpaConfig.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/config/JpaConfig.java @@ -1,4 +1,9 @@ package kr.ac.ks.cs_web_back.global.config; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing public class JpaConfig { } diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java b/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java new file mode 100644 index 0000000..8dc49ea --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java @@ -0,0 +1,38 @@ +package kr.ac.ks.cs_web_back.global.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; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + // H2 콘솔을 위한 설정 + .headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz + .requestMatchers(PathRequest.toH2Console()).permitAll() // H2 콘솔 경로 허용 + .requestMatchers("/member/register", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated() + ); + + return http.build(); + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java index 602cebb..d86aa75 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java @@ -3,10 +3,17 @@ import kr.ac.ks.cs_web_back.global.exeption.domain.*; import kr.ac.ks.cs_web_back.global.exeption.dto.ExceptionCode; import kr.ac.ks.cs_web_back.global.exeption.dto.ExceptionResponse; +import kr.ac.ks.cs_web_back.global.exeption.dto.ValidationExceptionCode; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import kr.ac.ks.cs_web_back.global.exeption.dto.ValidationExceptionCode; +import org.springframework.validation.FieldError; + +import java.util.List; @RestControllerAdvice public class GlobalExceptionHandler { @@ -28,6 +35,27 @@ public ResponseEntity handleException(final BadRequestExcepti .body(new ExceptionResponse(exception.getCode(), exception.getMessage())); } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleException(final MethodArgumentNotValidException e) { + System.out.printf("%s : %s\n", e.getClass(), e.getMessage()); + + final FieldError fieldError = e.getBindingResult().getFieldError(); + + if (fieldError == null) { + return ResponseEntity.badRequest() + .body(new ExceptionResponse(ValidationExceptionCode.DEFAULT.getCode(), ValidationExceptionCode.DEFAULT.getMessage())); + } + + final String annotationName = fieldError.getCodes()[fieldError.getCodes().length - 1]; + final ValidationExceptionCode exceptionCode = ValidationExceptionCode.findByAnnotationName(annotationName); + + final String errorMessage = fieldError.getDefaultMessage(); + + return ResponseEntity.badRequest() + .body(new ExceptionResponse(exceptionCode.getCode(), errorMessage)); + } + @ExceptionHandler(UnauthorizedException.class) public ResponseEntity handleException(final UnauthorizedException e) { System.out.printf("%s : %s\n", e.getClass(), e.getMessage()); diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/exeption/dto/ValidationExceptionCode.java b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/dto/ValidationExceptionCode.java new file mode 100644 index 0000000..58c1320 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/dto/ValidationExceptionCode.java @@ -0,0 +1,33 @@ +package kr.ac.ks.cs_web_back.global.exeption.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum ValidationExceptionCode implements ExceptionCode { + + NOT_BLANK("NotBlank", 9010, "공백일 수 없습니다."), + NOT_NULL("NotNull", 9011, "Null일 수 없습니다."), + NOT_EMPTY("NotEmpty", 9012, "비어있을 수 없습니다."), + EMAIL("Email", 9020, "유효한 이메일 형식이 아닙니다."), + SIZE("Size", 9030, "크기가 지정된 범위를 벗어났습니다."), + MAX("Max", 9031, "지정된 최댓값보다 큽니다."), + MIN("Min", 9032, "지정된 최솟값보다 작습니다."), + + // 기본값 + DEFAULT("Default", 9000, "유효성 검사에 실패했습니다."); + + private final String annotationName; + private final int code; + private final String message; + + public static ValidationExceptionCode findByAnnotationName(String name) { + return Arrays.stream(values()) + .filter(code -> code.annotationName.equals(name)) + .findFirst() + .orElse(DEFAULT); + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ApiErrorResponsesCustomizer.java b/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ApiErrorResponsesCustomizer.java index e6297b2..2529616 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ApiErrorResponsesCustomizer.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ApiErrorResponsesCustomizer.java @@ -79,6 +79,6 @@ private MediaType makeMediaType(ApiErrorResponse apiErrorResponse) { private Example makeExample(ApiErrorResponse apiErrorResponse, ErrorCase errorCase) { return new Example() .summary(errorCase.description()) - .value(ProblemDetailSchema.of(apiErrorResponse, errorCase.exampleMessage())); + .value(ProblemDetailSchema.of(apiErrorResponse, errorCase.code(), errorCase.exampleMessage())); } } diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ErrorCase.java b/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ErrorCase.java index 36975cf..ba5911a 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ErrorCase.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ErrorCase.java @@ -10,5 +10,6 @@ @Retention(RetentionPolicy.RUNTIME) public @interface ErrorCase { String description(); + int code(); String exampleMessage(); } diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ProblemDetailSchema.java b/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ProblemDetailSchema.java index d828e39..32662f3 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ProblemDetailSchema.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/swagger/error/ProblemDetailSchema.java @@ -1,17 +1,13 @@ package kr.ac.ks.cs_web_back.global.swagger.error; public record ProblemDetailSchema( - String type, - String title, - int status, + int code, String detail, String instance ) { - public static ProblemDetailSchema of(ApiErrorResponse apiErrorResponse, String detail) { + public static ProblemDetailSchema of(ApiErrorResponse apiErrorResponse, int code, String detail) { return new ProblemDetailSchema( - apiErrorResponse.type(), - apiErrorResponse.status().name(), - apiErrorResponse.status().value(), + code, detail, apiErrorResponse.instance() ); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bcc6e41..79f5388 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,4 +1,4 @@ -spring.config.import=env.properties +spring.config.import=optional:env.properties spring.application.name=cs_web_back spring.profiles.active=${PROPERTY_PROFILE} \ No newline at end of file diff --git a/src/test/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberControllerTest.java b/src/test/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberControllerTest.java new file mode 100644 index 0000000..6220cfe --- /dev/null +++ b/src/test/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberControllerTest.java @@ -0,0 +1,151 @@ +package kr.ac.ks.cs_web_back.domain.member.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest; +import kr.ac.ks.cs_web_back.domain.member.service.MemberService; +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.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; + +@WebMvcTest(controllers = MemberController.class, excludeAutoConfiguration = SecurityAutoConfiguration.class) +public class MemberControllerTest { + + @MockitoBean + private MemberService memberService; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("회원가입 성공: 회원가입에 성공하면 201 Created를 반환한다.") + public void registeredSuccessfullyReturns201Created() throws Exception { + // given + MemberCreateRequest request = new MemberCreateRequest( + "validEmail123@ks.ac.kr", + "validPassword1234!", + "exampleUser" + ); + + // when & then + mockMvc.perform(post("/member/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("회원가입 실패: 이메일 필드가 없으면 400 BadRequest를 반환한다.") + public void registeredWithoutEmailReturns400BadRequest() throws Exception { + // given + MemberCreateRequest request = new MemberCreateRequest( + "", + "validPassword1234!", + "exampleUser" + ); + + // when & then + mockMvc.perform(post("/member/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("회원가입 실패: 비밀번호 필드가 없으면 400 BadRequest를 반환한다.") + public void registeredWithoutPasswordReturns400BadRequest() throws Exception { + // given + MemberCreateRequest request = new MemberCreateRequest( + "example@ks.ac.kr", + "", + "exampleUser" + ); + + // when & then + mockMvc.perform(post("/member/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("회원가입 실패: 유저네임 필드가 없으면 400 BadRequest를 반환한다.") + public void registeredWithoutUsernameReturns400BadRequest() throws Exception { + // given + MemberCreateRequest request = new MemberCreateRequest( + "example@ks.ac.kr", + "validPassword1234!", + "" + ); + + // when & then + mockMvc.perform(post("/member/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("회원가입 실패: 입력된 이메일 형식이 유효하지 않으면 400 BadRequest를 반환한다.") + public void registeredWithInvalidEmailReturns400BadRequest() throws Exception { + // given + MemberCreateRequest request = new MemberCreateRequest( + "invalidEmail", + "validPassword1234!", + "exampleUser" + ); + + // when & then + mockMvc.perform(post("/member/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("회원가입 실패: 비밀번호가 24글자를 초과하면 400 BadRequest를 반환한다.") + public void tooLongPasswordReturns400BadRequest() throws Exception { + // given + MemberCreateRequest request = new MemberCreateRequest( + "invalidEmail", + "password".repeat(10), + "exampleUser" + ); + + // when & then + mockMvc.perform(post("/member/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("회원가입 실패: 유저네임이 12글자를 초과하면 400 BadRequest를 반환한다.") + public void tooLongUsernameReturns400BadRequest() throws Exception { + // given + MemberCreateRequest request = new MemberCreateRequest( + "invalidEmail", + "validPassword1234!", + "username".repeat(10) + ); + + // when & then + mockMvc.perform(post("/member/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + +} diff --git a/src/test/java/kr/ac/ks/cs_web_back/domain/member/fixture/MemberFixture.java b/src/test/java/kr/ac/ks/cs_web_back/domain/member/fixture/MemberFixture.java new file mode 100644 index 0000000..9e88e90 --- /dev/null +++ b/src/test/java/kr/ac/ks/cs_web_back/domain/member/fixture/MemberFixture.java @@ -0,0 +1,20 @@ +package kr.ac.ks.cs_web_back.domain.member.fixture; + +import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest; +import kr.ac.ks.cs_web_back.domain.member.model.Member; + +import java.time.LocalDate; + +public class MemberFixture { + + public static Member memberFixture() { + return Member.builder() + .email("example@ks.ac.kr") + .password("examplePassword1234!") + .username("exampleUser") + .birthdate(LocalDate.parse("2002-09-05")) + .number("055-663-5140") + .build(); + } + +} diff --git a/src/test/java/kr/ac/ks/cs_web_back/domain/member/service/MemberServiceTest.java b/src/test/java/kr/ac/ks/cs_web_back/domain/member/service/MemberServiceTest.java new file mode 100644 index 0000000..226e7a2 --- /dev/null +++ b/src/test/java/kr/ac/ks/cs_web_back/domain/member/service/MemberServiceTest.java @@ -0,0 +1,105 @@ +package kr.ac.ks.cs_web_back.domain.member.service; + +import kr.ac.ks.cs_web_back.domain.member.controller.code.MemberExceptionCode; +import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest; +import kr.ac.ks.cs_web_back.domain.member.model.Member; +import kr.ac.ks.cs_web_back.domain.member.repository.MemberRepository; +import kr.ac.ks.cs_web_back.global.exeption.domain.ConflictException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +@Sql(statements = "ALTER TABLE member ALTER COLUMN id RESTART WITH 1", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +public class MemberServiceTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberService memberService; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Test + @DisplayName("회원가입 성공: 멤버 객체를 생성 후 생성된 멤버의 id를 반환한다.") + void shouldCreateMemberAndReturnsCreatedId() { + // given + MemberCreateRequest request = new MemberCreateRequest( + "example@ks.ac.kr", + "validPassword1234!", + "exampleUser" + ); + + // when + Long createdId = memberService.createMember(request); + + // then + Member savedMember = memberRepository.findById(createdId).orElseThrow(); + + assertThat(createdId).isEqualTo(1L); + assertThat(savedMember.getEmail()).isEqualTo(request.email()); + assertThat(savedMember.getUsername()).isEqualTo(request.username()); + assertThat(passwordEncoder.matches(request.password(), savedMember.getPassword())).isTrue(); + } + + + @Test + @DisplayName("회원가입 실패: 중복된 이메일로 가입 시 ConflictException이 발생한다.") + void registeredFailedByConflictEmail() { + // given + MemberCreateRequest request = new MemberCreateRequest( + "example@ks.ac.kr", + "validpassword1234!", + "exampleUser" + ); + + memberRepository.saveAndFlush(Member.builder() + .email(request.email()) + .password("anyPassword") + .username("anyUsername") + .build()); + + // when & then + assertThatThrownBy(() -> memberService.createMember(request)) + .isInstanceOf(ConflictException.class) + .satisfies(exception -> { + ConflictException e = (ConflictException) exception; + assertThat(e.getExceptionCode()).isEqualTo(MemberExceptionCode.CONFLICT_EMAIL); + }); + } + + @Test + @DisplayName("회원가입 실패: 중복된 유저명으로 가입 시 ConflictException이 발생한다.") + void registeredFailedByConflictUsername() { + // given + MemberCreateRequest request = new MemberCreateRequest( + "example@ks.ac.kr", + "validpassword1234!", + "exampleUser" + ); + + memberRepository.saveAndFlush(Member.builder() + .email("anyEmail@naver.com") + .password("anyPassword") + .username(request.username()) + .build()); + + // when & then + assertThatThrownBy(() -> memberService.createMember(request)) + .isInstanceOf(ConflictException.class) + .satisfies(exception -> { + ConflictException e = (ConflictException) exception; + assertThat(e.getExceptionCode()).isEqualTo(MemberExceptionCode.CONFLICT_USERNAME); + }); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..f075f76 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,6 @@ +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=update +spring.datasource.generate-unique-name=false \ No newline at end of file