diff --git a/.github/workflows/cd-workflow.yml b/.github/workflows/cd-workflow.yml index 4f28fef..eef0fa4 100644 --- a/.github/workflows/cd-workflow.yml +++ b/.github/workflows/cd-workflow.yml @@ -5,10 +5,6 @@ on: branches: - 'develop' - 'main' - pull_request: - branches: - - 'develop' - - 'main' permissions: contents: read diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index e60baa7..42769e1 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -1,10 +1,6 @@ -name: CI with Gradle +name: CI with Gradle & Sonar on: - push: - branches: - - 'main' - - 'develop' pull_request: branches: - 'main' @@ -12,27 +8,57 @@ on: permissions: contents: read + pull-requests: read jobs: - build: + build-and-analyze: runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Sonar 분석 정확도 향상 및 PR 데코레이션을 위해 전체 이력 필요 + - name: Set up JDK 21 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - java-version: '21' distribution: 'temurin' - server-id: github - settings-path: ${{ github.workspace }} # location for the settings.xml file + java-version: '21' + cache: 'gradle' + + - name: Cache SonarQube packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar - name: Setup Gradle - uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + cache-read-only: false + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew - - name: 👏🏻 grant execute permission for gradlew - run: chmod +x gradlew -# working-directory: ./U2E + - name: PR Analysis with SonarQube + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # PR 데코레이션용(자동 제공, Secrets에 별도 추가 불필요) + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} # SonarCloud/SonarQube 인증 토큰(Secrets 필요) + run: | + ./gradlew clean test jacocoTestReport sonarqube \ + -Dsonar.login=${SONAR_TOKEN} \ + -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} \ + -Dsonar.pullrequest.branch=${{ github.head_ref }} \ + -Dsonar.pullrequest.base=${{ github.base_ref }} \ + --info --stacktrace - - name: 🐘 build with Gradle (without test) - run: ./gradlew clean build -x test --stacktrace -# working-directory: ./U2E \ No newline at end of file + - name: Upload JaCoCo HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-html + path: build/reports/test/jacocoTestReportHtml + if-no-files-found: ignore diff --git a/build.gradle b/build.gradle index 78f4417..6255c92 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.3' id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' + id 'org.sonarqube' version '5.0.0.4638' } group = 'Konkuk' @@ -23,6 +25,17 @@ repositories { mavenCentral() } +sonarqube { + properties { + property "sonar.projectKey", "Us2Earth_U2E-Server" + property "sonar.host.url", "https://sonarcloud.io" + property "sonar.organization", "us2earth" + property "sonar.java.coveragePlugin", "jacoco" + property "sonar.coverage.jacoco.xmlReportPaths", + layout.buildDirectory.file("reports/test/jacocoTestReport.xml").get().asFile.absolutePath + } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -34,24 +47,120 @@ dependencies { runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - // jwt + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' - // WebClient - implementation 'org.springframework.boot:spring-boot-starter-webflux' - implementation 'com.fasterxml.jackson.core:jackson-databind' - implementation 'com.fasterxml.jackson.core:jackson-annotations' - implementation 'com.fasterxml.jackson.core:jackson-core' - - // 헬스 체크 api 를 사용하기 위한 Actuator 의존성 추가 - implementation 'org.springframework.boot:spring-boot-starter-actuator' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testImplementation 'io.rest-assured:spring-mock-mvc:5.4.0' + testImplementation 'org.mockito:mockito-core:5.12.0' + testImplementation 'org.assertj:assertj-core:3.26.0' + + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.core:jackson-annotations' + implementation 'com.fasterxml.jackson.core:jackson-core' + + // 헬스 체크 api 를 사용하기 위한 Actuator 의존성 추가 + implementation 'org.springframework.boot:spring-boot-starter-actuator' +} + +jacoco { + toolVersion = "0.8.12" } tasks.named('test') { useJUnitPlatform() + finalizedBy tasks.jacocoTestReport +} + +tasks.named('jacocoTestReport', JacocoReport) { + dependsOn(tasks.test) + + def mainClasses = files(sourceSets.main.output).asFileTree.matching { + exclude( + "**/generated/**", + "**/build/**", + "**/*application*", + "**/*config*", + "**/*dto*", + "**/*request*", + "**/*response*", + "**/generated/querydsl/**", + "**/Q*.*" + ) + } + + additionalSourceDirs.from files(sourceSets.main.allSource.srcDirs) + sourceDirectories.from files(sourceSets.main.allSource.srcDirs) + classDirectories.setFrom(mainClasses) + + executionData.from fileTree(project.rootDir) { + include "**/build/jacoco/*.exec" + } + + reports { + html.required.set(true) + html.outputLocation.set(layout.buildDirectory.dir("reports/test/jacocoTestReportHtml")) + + xml.required.set(true) + xml.outputLocation.set(layout.buildDirectory.file("reports/test/jacocoTestReport.xml")) + + csv.required.set(false) + } +} + +tasks.named('jacocoTestCoverageVerification', JacocoCoverageVerification) { + dependsOn(tasks.test) + + classDirectories.setFrom( + files(sourceSets.main.output).asFileTree.matching { + exclude( + "**/generated/**", + "**/build/**", + "**/*application*", + "**/*config*", + "**/*dto*", + "**/*request*", + "**/*response*", + "**/generated/querydsl/**", + "**/Q*.*" + ) + } + ) + + violationRules { + rule { + element = 'CLASS' + limit { + counter = 'INSTRUCTION' + value = 'COVEREDRATIO' + minimum = 0.00 + } + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.00 + } + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.00 + } + } + } +} + +tasks.named('check') { + dependsOn tasks.named('jacocoTestCoverageVerification') +} + +tasks.named('sonarqube') { + dependsOn tasks.jacocoTestReport } diff --git a/src/main/java/Konkuk/U2E/domain/user/domain/User.java b/src/main/java/Konkuk/U2E/domain/user/domain/User.java index cf26514..7f03799 100644 --- a/src/main/java/Konkuk/U2E/domain/user/domain/User.java +++ b/src/main/java/Konkuk/U2E/domain/user/domain/User.java @@ -2,14 +2,13 @@ import Konkuk.U2E.global.entity.BaseEntity; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Table(name = "users") @Getter +@Builder +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseEntity { diff --git a/src/test/java/Konkuk/U2E/U2EApplicationTests.java b/src/test/java/Konkuk/U2E/U2EApplicationTests.java index 17c216a..e385aae 100644 --- a/src/test/java/Konkuk/U2E/U2EApplicationTests.java +++ b/src/test/java/Konkuk/U2E/U2EApplicationTests.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class U2EApplicationTests { diff --git a/src/test/java/Konkuk/U2E/domain/user/controller/UserControllerTest.java b/src/test/java/Konkuk/U2E/domain/user/controller/UserControllerTest.java new file mode 100644 index 0000000..defd0a6 --- /dev/null +++ b/src/test/java/Konkuk/U2E/domain/user/controller/UserControllerTest.java @@ -0,0 +1,69 @@ +package Konkuk.U2E.domain.user.controller; + +import Konkuk.U2E.domain.user.dto.request.PostUserLoginRequest; +import Konkuk.U2E.domain.user.dto.response.PostUserLoginResponse; +import Konkuk.U2E.domain.user.service.UserService; +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +class UserControllerTest { + + private UserService userService; // mock + private UserController userController; + + @BeforeEach + void setUp() { + userService = Mockito.mock(UserService.class); + userController = new UserController(userService); + RestAssuredMockMvc.standaloneSetup(userController); + } + + @AfterEach + void tearDown() { + RestAssuredMockMvc.reset(); + } + + @Test + @DisplayName("POST /user/login 성공 - BaseResponse 포맷(success, code, message, data) 검증") + void login_success() { + // given + long userId = 1L; + String token = "mock-jwt-token"; + when(userService.signupAndLogin(any(PostUserLoginRequest.class))) + .thenReturn(PostUserLoginResponse.of(userId, token)); + + // when & then + RestAssuredMockMvc + .given() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(""" + { + "name": "alice", + "password": "password123" + } + """) + .when() + .post("/user/login") + .then() + .statusCode(200) + // BaseResponse 공통 필드 + .body("success", Matchers.is(true)) + .body("code", Matchers.notNullValue()) + .body("message", Matchers.notNullValue()) + // data 내부 필드 + .body("data", Matchers.notNullValue()) + .body("data.userId", Matchers.equalTo((int) userId)) + .body("data.token", Matchers.equalTo(token)); + } +} \ No newline at end of file diff --git a/src/test/java/Konkuk/U2E/domain/user/service/UserServiceTest.java b/src/test/java/Konkuk/U2E/domain/user/service/UserServiceTest.java new file mode 100644 index 0000000..02ed37a --- /dev/null +++ b/src/test/java/Konkuk/U2E/domain/user/service/UserServiceTest.java @@ -0,0 +1,74 @@ +package Konkuk.U2E.domain.user.service; + +import Konkuk.U2E.domain.user.domain.User; +import Konkuk.U2E.domain.user.dto.request.PostUserLoginRequest; +import Konkuk.U2E.domain.user.dto.response.PostUserLoginResponse; +import Konkuk.U2E.domain.user.exception.DuplicateUserException; +import Konkuk.U2E.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import static Konkuk.U2E.global.response.status.BaseExceptionResponseStatus.DUPLICATE_USER; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ActiveProfiles("test") +class UserServiceTest { + + @Test + @DisplayName("회원 미존재 시 생성 후 토큰 발급") + void signupAndLogin_createsAndIssuesToken() { + // given + UserRepository repo = mock(UserRepository.class); + JwtUtil jwt = mock(JwtUtil.class); + UserService sut = new UserService(repo, jwt); + + PostUserLoginRequest req = new PostUserLoginRequest("alice", "password123"); + + when(repo.findUserByName("alice")).thenReturn(null); + User saved = User.builder() + .userId(1L) + .name("alice") + .password("password123") + .build(); + when(repo.save(any(User.class))).thenReturn(saved); + when(jwt.generateAccessToken("alice")).thenReturn("mock-jwt-token"); + + // when + PostUserLoginResponse res = sut.signupAndLogin(req); + + // then + assertThat(res).isNotNull(); + assertThat(res.userId()).isEqualTo(1L); + assertThat(res.token()).isEqualTo("mock-jwt-token"); + + verify(repo).findUserByName("alice"); + verify(repo).save(any(User.class)); + verify(jwt).generateAccessToken("alice"); + } + + @Test + @DisplayName("중복 사용자 존재 시 DuplicateUserException 발생") + void signupAndLogin_duplicateUser_throws() { + // given + UserRepository repo = mock(UserRepository.class); + JwtUtil jwt = mock(JwtUtil.class); + UserService sut = new UserService(repo, jwt); + + PostUserLoginRequest req = new PostUserLoginRequest("alice", "pw"); + + when(repo.findUserByName("alice")) + .thenReturn(User.builder().userId(99L).name("alice").password("pw").build()); + + // when & then + assertThatThrownBy(() -> sut.signupAndLogin(req)) + .isInstanceOf(DuplicateUserException.class) + .hasFieldOrPropertyWithValue("message", DUPLICATE_USER.getMessage()); + + verify(repo).findUserByName("alice"); + verify(repo, never()).save(any(User.class)); + verifyNoInteractions(jwt); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..dfb0959 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,30 @@ +spring: + application: + name: U2E + +jwt: + secret: abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz + expiration: 3600000 +--- +spring: + datasource: + url: jdbc:h2:mem:U2EApplication;MODE=MySQL; + username: sa + password: + driver-class-name: org.h2.Driver + + h2: + console: + enabled: true + path: /h2-console + + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + +