From ba4738a575c740c6f840b96df76ec14e83e68a49 Mon Sep 17 00:00:00 2001 From: can019 Date: Tue, 9 Sep 2025 20:57:18 +0900 Subject: [PATCH 1/4] test: User login test --- apps/user-service/build.gradle | 47 +++++++++++++ .../config/security/SecurityConfig.java | 2 +- .../icebang/config/E2eTestConfiguration.java | 5 ++ .../icebang/config/RestDocsConfiguration.java | 29 ++++++++ .../controller/AuthControllerE2eTest.java | 70 +++++++++++++++++++ .../icebang/support/E2eTestSupport.java | 38 +++++++++- .../icebang/support/E2eTestSupportTest.java | 13 ---- 7 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 apps/user-service/src/test/java/com/gltkorea/icebang/config/RestDocsConfiguration.java create mode 100644 apps/user-service/src/test/java/com/gltkorea/icebang/domain/auth/controller/AuthControllerE2eTest.java diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 145af4f6..3cd2c4eb 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -3,6 +3,7 @@ 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' } group = 'com.gltkorea' @@ -23,6 +24,8 @@ configurations { all { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' } + // AsciiDoctor Extension for REST Docs + asciidoctorExt } repositories { @@ -74,9 +77,20 @@ 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' +} + +// REST Docs 스니펫 디렉토리 설정 +ext { + snippetsDir = file('build/generated-snippets') } tasks.named('test') { + outputs.dir snippetsDir useJUnitPlatform { // 기본적으로는 e2e 태그 제외하고 실행 excludeTags 'e2e' @@ -86,6 +100,7 @@ tasks.named('test') { // E2E 테스트 전용 task 추가 tasks.register('e2eTest', Test) { + outputs.dir snippetsDir useJUnitPlatform { includeTags 'e2e' } @@ -98,9 +113,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') 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..ebaf6313 --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/domain/auth/controller/AuthControllerE2eTest.java @@ -0,0 +1,70 @@ +package com.gltkorea.icebang.domain.auth.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +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.gltkorea.icebang.support.E2eTestSupport; + +// @Rollback +@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 생성 + 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()), + requestHeaders( + headerWithName("Content-Type").description("요청 컨텐츠 타입"), + headerWithName("Origin").description("요청 Origin (CORS)").optional(), + headerWithName("Referer").description("요청 Referer").optional()), + requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("사용자 이메일 주소"), + fieldWithPath("password").type(JsonFieldType.STRING).description("사용자 비밀번호")), + responseHeaders(headerWithName("Content-Type").description("응답 컨텐츠 타입").optional()), + 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 상태")))); + } +} 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..d1b51254 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 @@ -13,17 +13,4 @@ void shouldStartWithRandomPort() { 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가 있다면 사용, 없으면 간단한 컨트롤러 만들어서 테스트 - } } From 1a39ccdc972e63b49c36e430cbf9395648145dcf Mon Sep 17 00:00:00 2001 From: can019 Date: Tue, 9 Sep 2025 21:15:18 +0900 Subject: [PATCH 2/4] test: User login test with api docs --- apps/user-service/build.gradle | 10 ++++ .../controller/AuthControllerE2eTest.java | 48 ++++++++++++------- .../icebang/support/E2eTestSupportTest.java | 1 - 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 3cd2c4eb..45abf367 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -4,6 +4,7 @@ plugins { 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' @@ -82,6 +83,7 @@ dependencies { 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 스니펫 디렉토리 설정 @@ -165,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/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 index ebaf6313..c5b184fd 100644 --- 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 @@ -1,8 +1,9 @@ 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.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; @@ -18,9 +19,9 @@ 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; -// @Rollback @Sql("classpath:sql/01-insert-internal-users.sql") @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class AuthControllerE2eTest extends E2eTestSupport { @@ -33,7 +34,7 @@ void login_success() throws Exception { loginRequest.put("email", "admin@icebang.site"); loginRequest.put("password", "qwer1234!A"); - // MockMvc로 REST Docs 생성 + // MockMvc로 REST Docs + OpenAPI 생성 mockMvc .perform( post(getApiUrlForDocs("/v0/auth/login")) @@ -51,20 +52,31 @@ void login_success() throws Exception { "auth-login", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - requestHeaders( - headerWithName("Content-Type").description("요청 컨텐츠 타입"), - headerWithName("Origin").description("요청 Origin (CORS)").optional(), - headerWithName("Referer").description("요청 Referer").optional()), - requestFields( - fieldWithPath("email").type(JsonFieldType.STRING).description("사용자 이메일 주소"), - fieldWithPath("password").type(JsonFieldType.STRING).description("사용자 비밀번호")), - responseHeaders(headerWithName("Content-Type").description("응답 컨텐츠 타입").optional()), - 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 상태")))); + 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/E2eTestSupportTest.java b/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupportTest.java index d1b51254..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,6 +11,5 @@ void shouldStartWithRandomPort() { // 포트가 제대로 할당되었는지 확인 assertThat(port).isGreaterThan(0); assertThat(getBaseUrl()).startsWith("http://localhost:"); - assertThat(getApiUrl("/test")).contains("/api/test"); } } From 253dd45ca835bfbf3171b185ab8e05ed9798f490 Mon Sep 17 00:00:00 2001 From: can019 Date: Tue, 9 Sep 2025 21:26:50 +0900 Subject: [PATCH 3/4] chore: swagger github action (experimental) --- .github/workflows/ci-java.yml | 39 ++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index 3f1a3b76..ca89dc67 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,32 @@ 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: 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: ./swagger-ui \ No newline at end of file From acb062198546f8f74ef11a8bb7c3f4faa3a1d4f3 Mon Sep 17 00:00:00 2001 From: can019 Date: Tue, 9 Sep 2025 21:33:04 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20Spring=20swagger=20yml=20target=20?= =?UTF-8?q?dir=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-java.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index ca89dc67..91e57996 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -159,11 +159,12 @@ jobs: - name: Generate Swagger UI uses: Legion2/swagger-ui-action@v1 with: - output: swagger-ui + 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: ./swagger-ui \ No newline at end of file + publish_dir: ./user-service-docs + destination_dir: user-service \ No newline at end of file