diff --git a/.github/workflows/backend-build-test.yml b/.github/workflows/backend-build-test.yml new file mode 100644 index 0000000..22778c7 --- /dev/null +++ b/.github/workflows/backend-build-test.yml @@ -0,0 +1,51 @@ +name: βœ… Backend Build Test +run-name: ${{ github.actor }} is Build Test πŸš€ +on: + push: + branches: + - main + pull_request: + branches: + - main + +defaults: + run: + working-directory: ./backend-board + +permissions: + contents: read + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Make docker-compose.yml + run: echo "${{ secrets.DOCKER_COMPOSE_YML }}" | base64 -d > docker-compose.yml + + - name: Run docker + run: docker compose -f "docker-compose.yml" up -d mysql-sandbox redis-sandbox + + - name: Wait for MySQL + run: | + while ! docker exec mysql-sandbox mysqladmin ping -h localhost --silent; do + echo "Waiting for MySQL..." + sleep 2 + done + + - name: Make application.yml + run: | + mkdir -p ./src/main/resources + cd ./src/main/resources + echo "${{ secrets.APPLICATION_YML }}" | base64 -d > application.yml + + - name: Test with Gradle + run: ./gradlew clean test + + - name: Build with Gradle + run: ./gradlew clean build -x test diff --git a/.github/workflows/backend-check-style.yml b/.github/workflows/backend-check-style.yml new file mode 100644 index 0000000..b485a42 --- /dev/null +++ b/.github/workflows/backend-check-style.yml @@ -0,0 +1,30 @@ +name: πŸ“ Backend Check Style +run-name: ${{ github.actor }} is Check Style πŸš€ +on: + push: + branches: + - main + pull_request: + branches: + - main + +defaults: + run: + working-directory: ./backend-board + +permissions: + contents: read + +jobs: + check-style: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + - name: Main checkstyle + run: ./gradlew --console verbose clean checkstyleMain + - name: ️Test checkstyle + run: ./gradlew --console verbose clean checkstyleTest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..038a0a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +docker-compose.yml +/nginx \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/backend-board/.gitignore b/backend-board/.gitignore index 9fde395..4347661 100644 --- a/backend-board/.gitignore +++ b/backend-board/.gitignore @@ -26,6 +26,7 @@ out/ !**/src/main/**/out/ !**/src/test/**/out/ /src/main/resources/application.yml +/src/test/resources/application.yml ### NetBeans ### /nbproject/private/ diff --git a/backend-board/build.gradle b/backend-board/build.gradle index 43f4702..014ad9c 100644 --- a/backend-board/build.gradle +++ b/backend-board/build.gradle @@ -1,42 +1,57 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.4.4' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.4.4' + id 'io.spring.dependency-management' version '1.1.7' + id 'checkstyle' +} + +checkstyle { + maxWarnings = 0 + configFile = file("${rootDir}/naver-checkstyle-rules.xml") + configProperties = ["suppressionFile": "${rootDir}/naver-checkstyle-suppressions.xml"] + toolVersion = "10.21.3" } group = 'com' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - //implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - //implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - //runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() +} + +test { + jvmArgs = ["-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('mockito-core') }}"] } diff --git a/backend-board/naver-checkstyle-rules.xml b/backend-board/naver-checkstyle-rules.xml new file mode 100644 index 0000000..a6e5eb3 --- /dev/null +++ b/backend-board/naver-checkstyle-rules.xml @@ -0,0 +1,440 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend-board/naver-checkstyle-suppressions.xml b/backend-board/naver-checkstyle-suppressions.xml new file mode 100644 index 0000000..3f11e0c --- /dev/null +++ b/backend-board/naver-checkstyle-suppressions.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/backend-board/naver-intellij-formatter.xml b/backend-board/naver-intellij-formatter.xml new file mode 100644 index 0000000..658fc65 --- /dev/null +++ b/backend-board/naver-intellij-formatter.xml @@ -0,0 +1,62 @@ + + + diff --git a/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java b/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java new file mode 100644 index 0000000..c060dd9 --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java @@ -0,0 +1,41 @@ +package com.backendboard.domain.user.controller; + +import org.springframework.http.HttpStatus; +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.RestController; + +import com.backendboard.domain.user.dto.JoinRequest; +import com.backendboard.domain.user.dto.JoinResponse; +import com.backendboard.domain.user.service.UserService; +import com.backendboard.global.error.dto.ErrorResponse; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "νšŒμ›", description = "νšŒμ› κ΄€λ ¨ API") +@RestController +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "201 성곡", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = JoinResponse.class))), + @ApiResponse(responseCode = "UR100", description = "403 아이디가 μ€‘λ³΅μž…λ‹ˆλ‹€.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "UR101", description = "403 λ‹‰λ„€μž„μ΄ μ€‘λ³΅μž…λ‹ˆλ‹€.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + }) + @PostMapping("/join") + public ResponseEntity join(@RequestBody @Valid JoinRequest request) { + JoinResponse response = userService.joinProcess(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinRequest.java b/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinRequest.java new file mode 100644 index 0000000..2bc3e7f --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinRequest.java @@ -0,0 +1,43 @@ +package com.backendboard.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Schema(description = "νšŒμ›κ°€μž… μš”μ²­ DTO") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class JoinRequest { + + @Schema(description = "둜그인 아이디", example = "user") + @NotBlank + @Size(max = 30) + private String loginId; + + @Schema(description = "둜그인 λΉ„λ°€λ²ˆν˜Έ", example = "1234") + @NotBlank + @Size(max = 30) + private String password; + + @Schema(description = "μœ μ €μ˜ 이름", example = "감자") + @NotBlank + @Size(max = 30) + private String username; + + @Schema(description = "λ‹‰λ„€μž„", example = "λ°°κ³ ν”ˆκ°μž") + @NotBlank + @Size(max = 30) + private String nickname; + + @Builder + private JoinRequest(String loginId, String password, String username, String nickname) { + this.loginId = loginId; + this.password = password; + this.username = username; + this.nickname = nickname; + } +} diff --git a/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinResponse.java b/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinResponse.java new file mode 100644 index 0000000..758b99d --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/dto/JoinResponse.java @@ -0,0 +1,43 @@ +package com.backendboard.domain.user.dto; + +import com.backendboard.domain.user.entitiy.User; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Schema(description = "νšŒμ›κ°€μž… 응닡 DTO") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class JoinResponse { + @Schema(description = "νšŒμ› 고유 번호", example = "1") + private Long id; + + @Schema(description = "둜그인 아이디", example = "user") + private String loginId; + + @Schema(description = "μœ μ €μ˜ 이름", example = "감자") + private String username; + + @Schema(description = "λ‹‰λ„€μž„", example = "λ°°κ³ ν”ˆκ°μž") + private String nickname; + + @Builder + private JoinResponse(Long id, String loginId, String username, String nickname) { + this.id = id; + this.loginId = loginId; + this.username = username; + this.nickname = nickname; + } + + public static JoinResponse toDto(User user) { + return builder() + .id(user.getId()) + .loginId(user.getAuthUser().getUsername()) + .username(user.getUsername()) + .nickname(user.getNickname()) + .build(); + } +} diff --git a/backend-board/src/main/java/com/backendboard/domain/user/entitiy/AuthUser.java b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/AuthUser.java new file mode 100644 index 0000000..c0ba726 --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/AuthUser.java @@ -0,0 +1,55 @@ +package com.backendboard.domain.user.entitiy; + +import com.backendboard.domain.user.entitiy.type.UserRole; +import com.backendboard.global.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AuthUser extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "VARCHAR(100)") + private UserRole role; + + private boolean status; + + public AuthUser(String username, String password, UserRole role) { + this.username = username; + this.password = password; + this.role = role; + this.status = true; + } + + public User createUser(String username, String nickname) { + return User.builder() + .username(username) + .nickname(nickname) + .authUser(this) + .build(); + } + + public void deactivate() { + this.status = false; + } +} diff --git a/backend-board/src/main/java/com/backendboard/domain/user/entitiy/User.java b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/User.java new file mode 100644 index 0000000..6f31544 --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/User.java @@ -0,0 +1,42 @@ +package com.backendboard.domain.user.entitiy; + +import com.backendboard.global.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String username; + + @Column(nullable = false, unique = true) + private String nickname; + + @OneToOne + @JoinColumn(name = "auth_user_id") + private AuthUser authUser; + + @Builder + private User(String username, String nickname, AuthUser authUser) { + this.username = username; + this.nickname = nickname; + this.authUser = authUser; + } +} diff --git a/backend-board/src/main/java/com/backendboard/domain/user/entitiy/type/UserRole.java b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/type/UserRole.java new file mode 100644 index 0000000..e84309a --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/entitiy/type/UserRole.java @@ -0,0 +1,15 @@ +package com.backendboard.domain.user.entitiy.type; + +import lombok.Getter; + +@Getter +public enum UserRole { + ADMIN("ROLE_ADMIN"), + USER("ROLE_USER"); + + private final String value; + + UserRole(String value) { + this.value = value; + } +} diff --git a/backend-board/src/main/java/com/backendboard/domain/user/repository/AuthUserRepository.java b/backend-board/src/main/java/com/backendboard/domain/user/repository/AuthUserRepository.java new file mode 100644 index 0000000..2911908 --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/repository/AuthUserRepository.java @@ -0,0 +1,11 @@ +package com.backendboard.domain.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.backendboard.domain.user.entitiy.AuthUser; + +@Repository +public interface AuthUserRepository extends JpaRepository { + boolean existsByUsername(String username); +} diff --git a/backend-board/src/main/java/com/backendboard/domain/user/repository/UserRepository.java b/backend-board/src/main/java/com/backendboard/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..be5447e --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.backendboard.domain.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.backendboard.domain.user.entitiy.User; + +@Repository +public interface UserRepository extends JpaRepository { + boolean existsByNickname(String nickName); +} diff --git a/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java b/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java new file mode 100644 index 0000000..d8ddc25 --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java @@ -0,0 +1,8 @@ +package com.backendboard.domain.user.service; + +import com.backendboard.domain.user.dto.JoinRequest; +import com.backendboard.domain.user.dto.JoinResponse; + +public interface UserService { + JoinResponse joinProcess(JoinRequest request); +} diff --git a/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java b/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java new file mode 100644 index 0000000..6d8eaaa --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java @@ -0,0 +1,51 @@ +package com.backendboard.domain.user.service; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.backendboard.domain.user.dto.JoinRequest; +import com.backendboard.domain.user.dto.JoinResponse; +import com.backendboard.domain.user.entitiy.AuthUser; +import com.backendboard.domain.user.entitiy.User; +import com.backendboard.domain.user.entitiy.type.UserRole; +import com.backendboard.domain.user.repository.AuthUserRepository; +import com.backendboard.domain.user.repository.UserRepository; +import com.backendboard.global.error.CustomError; +import com.backendboard.global.error.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + private final UserRepository userRepository; + private final AuthUserRepository authUserRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + @Transactional + @Override + public JoinResponse joinProcess(JoinRequest request) { + validateDuplicationId(request.getLoginId()); + validateDuplicationNickname(request.getNickname()); + + AuthUser authUser = new AuthUser(request.getLoginId(), + bCryptPasswordEncoder.encode(request.getPassword()), UserRole.USER); + User user = authUser.createUser(request.getUsername(), request.getNickname()); + authUserRepository.save(authUser); + userRepository.save(user); + return JoinResponse.toDto(user); + } + + public void validateDuplicationNickname(String nickname) { + if (userRepository.existsByNickname(nickname)) { + throw new CustomException(CustomError.USER_DUPLICATION_NICKNAME); + } + } + + public void validateDuplicationId(String loginId) { + if (authUserRepository.existsByUsername(loginId)) { + throw new CustomException(CustomError.USER_DUPLICATION_ID); + } + } +} diff --git a/backend-board/src/main/java/com/backendboard/global/config/SecurityConfig.java b/backend-board/src/main/java/com/backendboard/global/config/SecurityConfig.java new file mode 100644 index 0000000..e14fa6f --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/global/config/SecurityConfig.java @@ -0,0 +1,47 @@ +package com.backendboard.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final AuthenticationConfiguration authenticationConfiguration; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .authorizeHttpRequests((auth) -> auth + .requestMatchers("/login", "/join", "/swagger-ui/**", "/swagger-resources/**", + "/v3/api-docs/**") + .permitAll() + .anyRequest() + .authenticated()) + .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .build(); + } + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } +} diff --git a/backend-board/src/main/java/com/backendboard/global/config/SwaggerConfig.java b/backend-board/src/main/java/com/backendboard/global/config/SwaggerConfig.java new file mode 100644 index 0000000..6c0a6b2 --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/global/config/SwaggerConfig.java @@ -0,0 +1,29 @@ +package com.backendboard.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + + return new OpenAPI() + .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) + .info(new Info() + .title("μƒŒλ“œλ°•μŠ€ API λ¬Έμ„œ") + .description("μƒŒλ“œλ°•μŠ€ API λͺ…μ„Έμ„œ") + .version("1.0.0")); + } +} diff --git a/backend-board/src/main/java/com/backendboard/global/entity/BaseEntity.java b/backend-board/src/main/java/com/backendboard/global/entity/BaseEntity.java new file mode 100644 index 0000000..5617c54 --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/global/entity/BaseEntity.java @@ -0,0 +1,20 @@ +package com.backendboard.global.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime lastModifiedAt; +} diff --git a/backend-board/src/main/java/com/backendboard/global/error/CustomError.java b/backend-board/src/main/java/com/backendboard/global/error/CustomError.java new file mode 100644 index 0000000..45794eb --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/global/error/CustomError.java @@ -0,0 +1,21 @@ +package com.backendboard.global.error; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CustomError { + //인증 μœ μ € μ—λŸ¬ + AUTH_USER_NOT_FOUND_ID(HttpStatus.NOT_FOUND, "AU100", "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 아이디 μž…λ‹ˆλ‹€."), + + //μœ μ € μ—λŸ¬ + USER_DUPLICATION_ID(HttpStatus.FORBIDDEN, "UR100", "μ€‘λ³΅λœ 아이디 μž…λ‹ˆλ‹€."), + USER_DUPLICATION_NICKNAME(HttpStatus.FORBIDDEN, "UR101", "μ€‘λ³΅λœ λ‹‰λ„€μž„ μž…λ‹ˆλ‹€."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/backend-board/src/main/java/com/backendboard/global/error/CustomException.java b/backend-board/src/main/java/com/backendboard/global/error/CustomException.java new file mode 100644 index 0000000..5b6f68b --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/global/error/CustomException.java @@ -0,0 +1,10 @@ +package com.backendboard.global.error; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CustomException extends RuntimeException { + private final CustomError error; +} diff --git a/backend-board/src/main/java/com/backendboard/global/error/controller/CustomExceptionHandler.java b/backend-board/src/main/java/com/backendboard/global/error/controller/CustomExceptionHandler.java new file mode 100644 index 0000000..2e7c7b5 --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/global/error/controller/CustomExceptionHandler.java @@ -0,0 +1,16 @@ +package com.backendboard.global.error.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import com.backendboard.global.error.CustomException; +import com.backendboard.global.error.dto.ErrorResponse; + +@ControllerAdvice +public class CustomExceptionHandler { + @ExceptionHandler(CustomException.class) + protected ResponseEntity handleCustomException(final CustomException exception) { + return ErrorResponse.toResponseEntity(exception); + } +} diff --git a/backend-board/src/main/java/com/backendboard/global/error/dto/ErrorResponse.java b/backend-board/src/main/java/com/backendboard/global/error/dto/ErrorResponse.java new file mode 100644 index 0000000..34ee24e --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/global/error/dto/ErrorResponse.java @@ -0,0 +1,22 @@ +package com.backendboard.global.error.dto; + +import org.springframework.http.ResponseEntity; + +import com.backendboard.global.error.CustomException; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Schema(description = "μ—λŸ¬ 응닡 DTO") +@Getter +@AllArgsConstructor +public class ErrorResponse { + @Schema(description = "μ—λŸ¬ λ©”μ‹œμ§€", example = "μ—λŸ¬κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.") + private final String message; + + public static ResponseEntity toResponseEntity(final CustomException exception) { + return ResponseEntity.status(exception.getError().getStatus()) + .body(new ErrorResponse(exception.getError().getMessage())); + } +} diff --git a/backend-board/src/test/java/com/backendboard/domain/user/UserMockData.java b/backend-board/src/test/java/com/backendboard/domain/user/UserMockData.java new file mode 100644 index 0000000..6cd18f4 --- /dev/null +++ b/backend-board/src/test/java/com/backendboard/domain/user/UserMockData.java @@ -0,0 +1,29 @@ +package com.backendboard.domain.user; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +import com.backendboard.domain.user.dto.JoinRequest; +import com.backendboard.domain.user.entitiy.AuthUser; +import com.backendboard.domain.user.entitiy.User; +import com.backendboard.domain.user.entitiy.type.UserRole; +import com.backendboard.domain.user.repository.AuthUserRepository; +import com.backendboard.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class UserMockData { + private final AuthUserRepository authUserRepository; + private final UserRepository userRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + public User createUserAndAuthUser(JoinRequest request) { + AuthUser authUser = new AuthUser(request.getLoginId(), + bCryptPasswordEncoder.encode(request.getPassword()), UserRole.USER); + User user = authUser.createUser(request.getUsername(), request.getNickname()); + authUserRepository.save(authUser); + return userRepository.save(user); + } +} diff --git a/backend-board/src/test/java/com/backendboard/domain/user/controller/UserControllerTest.java b/backend-board/src/test/java/com/backendboard/domain/user/controller/UserControllerTest.java new file mode 100644 index 0000000..fc82887 --- /dev/null +++ b/backend-board/src/test/java/com/backendboard/domain/user/controller/UserControllerTest.java @@ -0,0 +1,94 @@ +package com.backendboard.domain.user.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.backendboard.domain.user.UserMockData; +import com.backendboard.domain.user.dto.JoinRequest; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest +@AutoConfigureMockMvc +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserMockData userMockData; + + @Nested + @DisplayName("νšŒμ›κ°€μž… API ν…ŒμŠ€νŠΈ") + class JoinTest { + private JoinRequest joinRequest; + + @BeforeEach + void setUp() { + joinRequest = JoinRequest.builder() + .loginId("testId") + .password("1234") + .username("potato") + .nickname("testNick") + .build(); + } + + @Transactional + @Test + @DisplayName("성곡 201") + void success() throws Exception { + // given + + // when & then + mockMvc.perform(post("/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(joinRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.loginId").value("testId")) + .andExpect(jsonPath("$.username").value("potato")) + .andExpect(jsonPath("$.nickname").value("testNick")); + } + + @Transactional + @Test + @DisplayName("아이디 쀑볡 μ‹€νŒ¨ 403") + void duplicateIdFailure() throws Exception { + // given + userMockData.createUserAndAuthUser(joinRequest); + + // when & then + mockMvc.perform(post("/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(joinRequest))) + .andExpect(status().isForbidden()); + } + + @Transactional + @Test + @DisplayName("λ‹‰λ„€μž„ 쀑볡 μ‹€νŒ¨ 403") + void duplicateNicknameFailure() throws Exception { + // given + userMockData.createUserAndAuthUser(joinRequest); + + // when & then + mockMvc.perform(post("/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(joinRequest))) + .andExpect(status().isForbidden()); + } + + } +} diff --git a/backend-board/src/test/java/com/backendboard/domain/user/repository/AuthUserRepositoryTest.java b/backend-board/src/test/java/com/backendboard/domain/user/repository/AuthUserRepositoryTest.java new file mode 100644 index 0000000..5240208 --- /dev/null +++ b/backend-board/src/test/java/com/backendboard/domain/user/repository/AuthUserRepositoryTest.java @@ -0,0 +1,39 @@ +package com.backendboard.domain.user.repository; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.backendboard.domain.user.entitiy.AuthUser; +import com.backendboard.domain.user.entitiy.type.UserRole; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class AuthUserRepositoryTest { + @Autowired + AuthUserRepository authUserRepository; + + @Nested + @DisplayName("existsByUsername λ©”μ„œλ“œ ν…ŒμŠ€νŠΈ") + class ExistsByUsernameMethodTest { + @Test + @DisplayName("username이 쀑볡이면 true λ°˜ν™˜") + void returnsTrueWhenUsernameExists() { + //given + String username = "potato"; + AuthUser authUser = new AuthUser(username, "1234", UserRole.USER); + authUserRepository.save(authUser); + + //when + boolean exists = authUserRepository.existsByUsername(username); + + //then + Assertions.assertThat(exists).isTrue(); + } + } + +} diff --git a/backend-board/src/test/java/com/backendboard/domain/user/service/UserServiceImplTest.java b/backend-board/src/test/java/com/backendboard/domain/user/service/UserServiceImplTest.java new file mode 100644 index 0000000..76dd8c8 --- /dev/null +++ b/backend-board/src/test/java/com/backendboard/domain/user/service/UserServiceImplTest.java @@ -0,0 +1,153 @@ +package com.backendboard.domain.user.service; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.bcrypt.BCryptPasswordEncoder; + +import com.backendboard.domain.user.dto.JoinRequest; +import com.backendboard.domain.user.dto.JoinResponse; +import com.backendboard.domain.user.entitiy.AuthUser; +import com.backendboard.domain.user.entitiy.User; +import com.backendboard.domain.user.entitiy.type.UserRole; +import com.backendboard.domain.user.repository.AuthUserRepository; +import com.backendboard.domain.user.repository.UserRepository; +import com.backendboard.global.error.CustomError; +import com.backendboard.global.error.CustomException; + +@ExtendWith(MockitoExtension.class) +class UserServiceImplTest { + @Mock + private UserRepository userRepository; + + @Mock + private AuthUserRepository authUserRepository; + + @Mock + private BCryptPasswordEncoder bCryptPasswordEncoder; + + @InjectMocks + private UserServiceImpl userService; + + @Nested + @DisplayName("νšŒμ›κ°€μž… ν”„λ‘œμ„ΈμŠ€ ν…ŒμŠ€νŠΈ") + class JoinProcessTest { + + private JoinRequest joinRequest; + private User user; + private AuthUser authUser; + + @BeforeEach + void setUp() { + joinRequest = JoinRequest.builder() + .loginId("testId") + .password("1234") + .username("potato") + .nickname("testNick") + .build(); + authUser = new AuthUser("potato", "encoded_password", UserRole.USER); + user = authUser.createUser("testId", "testNick"); + } + + @Test + @DisplayName("νšŒμ›κ°€μž… μš”μ²­ 성곡") + void success() { + // given + when(userRepository.existsByNickname(anyString())).thenReturn(false); + when(authUserRepository.existsByUsername(anyString())).thenReturn(false); + when(bCryptPasswordEncoder.encode(anyString())).thenReturn("encoded_password"); + + when(userRepository.save(any(User.class))).thenReturn(user); + when(authUserRepository.save(any(AuthUser.class))).thenReturn(authUser); + + // when + JoinResponse response = userService.joinProcess(joinRequest); + + // then + verify(userRepository).existsByNickname("testNick"); + verify(authUserRepository).existsByUsername("testId"); + verify(bCryptPasswordEncoder).encode("1234"); + verify(userRepository).save(any(User.class)); + verify(authUserRepository).save(any(AuthUser.class)); + + assertThat(response).isNotNull(); + } + + @Test + @DisplayName("μ€‘λ³΅λœ 아이디가 있으면 μ˜ˆμ™Έ λ°œμƒ") + void throwsExceptionWhenIdDuplicated() { + // given + when(authUserRepository.existsByUsername(anyString())).thenReturn(true); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> userService.joinProcess(joinRequest)); + + assertThat(exception.getError()).isEqualTo(CustomError.USER_DUPLICATION_ID); + verify(authUserRepository).existsByUsername("testId"); + verify(userRepository, never()).existsByNickname(anyString()); + verify(authUserRepository, never()).save(any()); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("μ€‘λ³΅λœ λ‹‰λ„€μž„μ΄ 있으면 μ˜ˆμ™Έ λ°œμƒ") + void throwsExceptionWhenNicknameDuplicated() { + // given + when(authUserRepository.existsByUsername(anyString())).thenReturn(false); + when(userRepository.existsByNickname(anyString())).thenReturn(true); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> userService.joinProcess(joinRequest)); + + assertThat(exception.getError()).isEqualTo(CustomError.USER_DUPLICATION_NICKNAME); + verify(authUserRepository).existsByUsername("testId"); + verify(userRepository).existsByNickname("testNick"); + verify(authUserRepository, never()).save(any()); + verify(userRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("검증 ν…ŒμŠ€νŠΈ") + class ValidationTest { + + @Test + @DisplayName("μ€‘λ³΅λœ λ‹‰λ„€μž„ 검증 μ‹œ μ˜ˆμ™Έ λ°œμƒ") + void validateDuplicationNickname() { + // given + String nickname = "testNick"; + when(userRepository.existsByNickname(nickname)).thenReturn(true); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> userService.validateDuplicationNickname(nickname)); + + assertThat(exception.getError()).isEqualTo(CustomError.USER_DUPLICATION_NICKNAME); + } + + @Test + @DisplayName("μ€‘λ³΅λœ 아이디 검증 μ‹œ μ˜ˆμ™Έ λ°œμƒ") + void validateDuplicationId() { + // given + String loginId = "testId"; + when(authUserRepository.existsByUsername(loginId)).thenReturn(true); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> userService.validateDuplicationId(loginId)); + + assertThat(exception.getError()).isEqualTo(CustomError.USER_DUPLICATION_ID); + } + } +}