Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion .github/workflows/ci-java.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
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
57 changes: 57 additions & 0 deletions apps/user-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -23,6 +25,8 @@ configurations {
all {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
// AsciiDoctor Extension for REST Docs
asciidoctorExt
}

repositories {
Expand Down Expand Up @@ -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'
Expand All @@ -86,6 +102,7 @@ tasks.named('test') {

// E2E 테스트 전용 task 추가
tasks.register('e2eTest', Test) {
outputs.dir snippetsDir
useJUnitPlatform {
includeTags 'e2e'
}
Expand All @@ -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')
Expand All @@ -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'
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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())));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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가 있다면 사용, 없으면 간단한 컨트롤러 만들어서 테스트
}
}
Loading