diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index 3f1a3b76..91e57996 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -21,6 +21,8 @@ permissions: security-events: write checks: write pull-requests: write + pages: write # GitHub Pages 배포를 위해 추가 + id-token: write # GitHub Pages 배포를 위해 추가 jobs: spotless-check: @@ -90,6 +92,13 @@ jobs: name: build-artifacts path: apps/user-service/build/libs/ + - name: Upload OpenAPI spec artifacts + if: matrix.java-version == '21' && github.ref == 'refs/heads/main' && github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: openapi-spec + path: apps/user-service/build/api-spec/ + docker: name: Build Spring Boot Docker Image and push to registry runs-on: ubuntu-latest @@ -129,4 +138,33 @@ jobs: - name: Analyze image layers run: | echo "=== Image Layer Analysis ===" - docker history ghcr.io/${{ env.REPO_LC }}/user-service:latest --human --no-trunc \ No newline at end of file + docker history ghcr.io/${{ env.REPO_LC }}/user-service:latest --human --no-trunc + + swagger-docs: + name: Deploy Swagger Documentation + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + needs: build + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download OpenAPI spec artifacts + uses: actions/download-artifact@v4 + with: + name: openapi-spec + path: ./openapi-spec + + - name: Generate Swagger UI + uses: Legion2/swagger-ui-action@v1 + with: + output: user-service-swagger-ui + spec-file: openapi-spec/openapi3.yaml + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./user-service-docs + destination_dir: user-service \ No newline at end of file diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 145af4f6..45abf367 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -3,6 +3,8 @@ plugins { id 'org.springframework.boot' version '3.5.4' id 'io.spring.dependency-management' version '1.1.7' id 'com.diffplug.spotless' version '7.2.1' + id 'org.asciidoctor.jvm.convert' version '3.3.2' + id 'com.epages.restdocs-api-spec' version '0.18.2' } group = 'com.gltkorea' @@ -23,6 +25,8 @@ configurations { all { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' } + // AsciiDoctor Extension for REST Docs + asciidoctorExt } repositories { @@ -74,9 +78,21 @@ dependencies { testImplementation 'org.testcontainers:mariadb' testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Spring REST Docs + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'org.springframework.restdocs:spring-restdocs-webtestclient' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.2' +} + +// REST Docs 스니펫 디렉토리 설정 +ext { + snippetsDir = file('build/generated-snippets') } tasks.named('test') { + outputs.dir snippetsDir useJUnitPlatform { // 기본적으로는 e2e 태그 제외하고 실행 excludeTags 'e2e' @@ -86,6 +102,7 @@ tasks.named('test') { // E2E 테스트 전용 task 추가 tasks.register('e2eTest', Test) { + outputs.dir snippetsDir useJUnitPlatform { includeTags 'e2e' } @@ -98,9 +115,41 @@ tasks.register('e2eTest', Test) { // 모든 테스트 실행 task tasks.register('allTests', Test) { + outputs.dir snippetsDir useJUnitPlatform() } +// AsciiDoctor 설정 (REST Docs 문서 생성) +asciidoctor { + inputs.dir snippetsDir + configurations 'asciidoctorExt' + dependsOn test + + baseDirFollowsSourceDir() + + attributes( + 'snippets': snippetsDir, + 'source-highlighter': 'coderay', + 'toc': 'left', + 'toclevels': '3', + 'sectlinks': 'true', + 'operation-curl-request-title': 'Example request', + 'operation-http-response-title': 'Example response' + ) +} + +asciidoctor.doFirst { + delete file('src/docs/asciidoc') +} + +// JAR에 생성된 문서 포함 +bootJar { + dependsOn asciidoctor + from ("${asciidoctor.outputDir}/html5") { + into 'static/docs' + } +} + spotless { java { googleJavaFormat('1.17.0') @@ -118,3 +167,11 @@ spotless { endWithNewline() } } + +openapi3 { + server = 'http://localhost:8080' + title = 'IceBang API' + description = 'IceBang API Documentation' + version = '0.0.1-alpha-snapshot' + format = 'yaml' +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java index 69ee1bf0..bde09f6e 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java @@ -105,7 +105,7 @@ public PasswordEncoder bCryptPasswordEncoder() { String[] activeProfiles = environment.getActiveProfiles(); for (String profile : activeProfiles) { - if ("develop".equals(profile) || "test".equals(profile)) { + if ("develop".equals(profile) || profile.contains("test")) { return NoOpPasswordEncoder.getInstance(); } } diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/config/E2eTestConfiguration.java b/apps/user-service/src/test/java/com/gltkorea/icebang/config/E2eTestConfiguration.java index 054360b1..5b1c5ce9 100644 --- a/apps/user-service/src/test/java/com/gltkorea/icebang/config/E2eTestConfiguration.java +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/config/E2eTestConfiguration.java @@ -6,9 +6,14 @@ import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; @TestConfiguration(proxyBeanMethods = false) public class E2eTestConfiguration { + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } @Bean @ServiceConnection diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/config/RestDocsConfiguration.java b/apps/user-service/src/test/java/com/gltkorea/icebang/config/RestDocsConfiguration.java new file mode 100644 index 00000000..bdacc10d --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/config/RestDocsConfiguration.java @@ -0,0 +1,29 @@ +package com.gltkorea.icebang.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.operation.preprocess.Preprocessors; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@TestConfiguration +public class RestDocsConfiguration { + + @Bean + public RestDocumentationResultHandler restDocumentationResultHandler() { + return MockMvcRestDocumentation.document( + "{class-name}/{method-name}", + Preprocessors.preprocessRequest( + Preprocessors.removeHeaders("Host", "Content-Length"), Preprocessors.prettyPrint()), + Preprocessors.preprocessResponse( + Preprocessors.removeHeaders("Content-Length", "Date", "Keep-Alive", "Connection"), + Preprocessors.prettyPrint())); + } + + @Bean + public ObjectMapper testObjectMapper() { + return new ObjectMapper(); + } +} diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/domain/auth/controller/AuthControllerE2eTest.java b/apps/user-service/src/test/java/com/gltkorea/icebang/domain/auth/controller/AuthControllerE2eTest.java new file mode 100644 index 00000000..c5b184fd --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/domain/auth/controller/AuthControllerE2eTest.java @@ -0,0 +1,82 @@ +package com.gltkorea.icebang.domain.auth.controller; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.*; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.jdbc.Sql; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.gltkorea.icebang.support.E2eTestSupport; + +@Sql("classpath:sql/01-insert-internal-users.sql") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AuthControllerE2eTest extends E2eTestSupport { + + @Test + @DisplayName("사용자 로그인 성공") + void login_success() throws Exception { + // given + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + // MockMvc로 REST Docs + OpenAPI 생성 + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/login")) + .contentType(MediaType.APPLICATION_JSON) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data").isEmpty()) + .andDo( + document( + "auth-login", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 로그인") + .description("이메일과 비밀번호로 사용자 인증을 수행합니다") + .requestFields( + fieldWithPath("email") + .type(JsonFieldType.STRING) + .description("사용자 이메일 주소"), + fieldWithPath("password") + .type(JsonFieldType.STRING) + .description("사용자 비밀번호")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data") + .type(JsonFieldType.NULL) + .description("응답 데이터 (로그인 성공 시 null)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } +} diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupport.java b/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupport.java index ddb3afd9..36156a83 100644 --- a/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupport.java +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupport.java @@ -1,28 +1,60 @@ package com.gltkorea.icebang.support; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Import; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; import com.gltkorea.icebang.annotation.E2eTest; import com.gltkorea.icebang.config.E2eTestConfiguration; @Import(E2eTestConfiguration.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ExtendWith(RestDocumentationExtension.class) @E2eTest public abstract class E2eTestSupport { + @Autowired protected ObjectMapper objectMapper; @LocalServerPort protected int port; - @Autowired protected TestRestTemplate restTemplate; + @Autowired protected WebApplicationContext webApplicationContext; + + protected MockMvc mockMvc; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + // MockMvc 설정 (MockMvc 기반 테스트용) + this.mockMvc = + MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply( + documentationConfiguration(restDocumentation) + .operationPreprocessors() + .withRequestDefaults(prettyPrint()) + .withResponseDefaults(prettyPrint())) + .build(); + } protected String getBaseUrl() { return "http://localhost:" + port; } protected String getApiUrl(String path) { - return getBaseUrl() + "/api" + path; + return getBaseUrl() + path; + } + + /** REST Docs용 API URL 생성 (path parameter 포함) */ + protected String getApiUrlForDocs(String path) { + return path; } } diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupportTest.java b/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupportTest.java index bad5a2ba..8b9da9b8 100644 --- a/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupportTest.java +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupportTest.java @@ -11,19 +11,5 @@ void shouldStartWithRandomPort() { // 포트가 제대로 할당되었는지 확인 assertThat(port).isGreaterThan(0); assertThat(getBaseUrl()).startsWith("http://localhost:"); - assertThat(getApiUrl("/test")).contains("/api/test"); - } - - @Test - void shouldHaveRestTemplate() { - // RestTemplate이 주입되었는지 확인 - assertThat(restTemplate).isNotNull(); - } - - @Test - void shouldConnectToMariaDBContainer() { - // 실제 DB 연결 확인 - String response = restTemplate.getForObject(getApiUrl("/health"), String.class); - // health check endpoint가 있다면 사용, 없으면 간단한 컨트롤러 만들어서 테스트 } }