diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7737fbf..34b9ff9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,15 +5,14 @@ on:
branches:
- main
- develop
-
pull_request:
branches:
- main
- develop
jobs:
- build:
- name: Build & Test
+ verify:
+ name: Unit and Integration Verification
runs-on: ubuntu-latest
steps:
@@ -30,35 +29,8 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- - name: Build with Gradle
- run: ./gradlew build -x test
-
- name: Run unit tests
run: ./gradlew test
- name: Run integration tests
run: ./gradlew integrationTest
-
- - name: Upload unit test results
- uses: actions/upload-artifact@v4
- if: always()
- with:
- name: unit-test-results
- path: build/reports/tests/test/
- retention-days: 7
-
- - name: Upload integration test results
- uses: actions/upload-artifact@v4
- if: always()
- with:
- name: integration-test-results
- path: build/reports/tests/integrationTest/
- retention-days: 7
-
- - name: Upload build artifacts
- uses: actions/upload-artifact@v4
- if: success()
- with:
- name: jar-artifact
- path: build/libs/*.jar
- retention-days: 7
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 699ee4b..d27cb68 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -12,9 +12,34 @@ env:
IMAGE_NAME: git-ranker
jobs:
+ verify:
+ name: Pre-deploy Verification
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Java 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'gradle'
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Run unit tests
+ run: ./gradlew test
+
+ - name: Run integration tests
+ run: ./gradlew integrationTest
+
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
+ needs: verify
steps:
- name: Checkout code
diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml
deleted file mode 100644
index 5363ca7..0000000
--- a/.github/workflows/quality-gate.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-name: Quality Gate
-
-on:
- pull_request:
- branches:
- - main
- - develop
-
-jobs:
- coverage:
- name: Coverage Verification
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup Java 21
- uses: actions/setup-java@v4
- with:
- java-version: '21'
- distribution: 'temurin'
- cache: 'gradle'
-
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
-
- - name: Run unit tests with coverage verification
- run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification
-
- - name: Upload JaCoCo HTML report
- uses: actions/upload-artifact@v4
- if: always()
- with:
- name: jacoco-html-report
- path: build/reports/jacoco/test/html/
- retention-days: 7
-
- - name: Upload JaCoCo XML report
- uses: actions/upload-artifact@v4
- if: always()
- with:
- name: jacoco-xml-report
- path: build/reports/jacoco/test/jacocoTestReport.xml
- retention-days: 7
diff --git a/README.md b/README.md
index fb02290..d5c97b5 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,6 @@
Badge •
Data Refresh •
FAQ •
- Development Verification •
Contributing •
Roadmap •
License •
@@ -283,20 +282,6 @@ GitHub 프로필(`README.md`)에 동적 배지를 삽입해, 현재 티어와
---
-
-## 🧪 Development Verification
-
-로컬 변경 후 backend 검증은 아래 순서로 실행합니다.
-
-- `./gradlew test jacocoTestCoverageVerification`: 단위 테스트와 커버리지 검증을 실행합니다. Docker가 없어도 동작합니다.
-- `./gradlew verifyDockerAvailable`: Testcontainers 기반 통합 테스트 전에 Docker CLI와 daemon 연결 가능 여부를 fail-fast로 확인합니다.
-- `./gradlew integrationTest`: `verifyDockerAvailable`를 먼저 실행한 뒤 `*IT` 통합 테스트를 수행합니다. Docker가 준비되지 않았으면 환경 문제로 즉시 실패합니다.
-
-> [!TIP]
-> `verifyDockerAvailable`가 실패하면 먼저 `docker version`이 성공하는지 확인한 뒤 `./gradlew integrationTest`를 다시 실행하세요.
-
----
-
## 🤝 Contributing
diff --git a/build.gradle b/build.gradle
index f0dd51c..42e938a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,7 +2,6 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0'
id 'io.spring.dependency-management' version '1.1.6'
- id 'jacoco'
}
group = 'com.gitranker'
@@ -55,7 +54,6 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
- testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1'
testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
@@ -67,26 +65,11 @@ tasks.named('test') {
exclude '**/*IT.class'
}
-tasks.named('processResources') {
- filteringCharset = 'UTF-8'
- filesMatching('static/swagger-ui/index.html') {
- expand(swaggerUiVersion: swaggerUiVersion)
- }
-}
-
-tasks.register('verifyDockerAvailable', JavaExec) {
- group = 'verification'
- description = 'Fails fast when Docker is unavailable for Testcontainers-based integration tests.'
- mainClass = 'com.gitranker.api.testsupport.DockerPreflightMain'
- classpath = sourceSets.test.runtimeClasspath
-
- dependsOn tasks.named('testClasses')
-}
-
-// 통합 테스트: *IT.java만 실행 (Docker/Testcontainers 필요)
-// check 라이프사이클에 포함하지 않음 — Docker 없는 환경에서 build가 실패하지 않도록 의도적 제외
-// CI에서는 별도 단계로 명시적 실행: ./gradlew integrationTest
+// 통합 테스트: Testcontainers 기반 *IT.java만 별도 lane에서 실행
+// check 라이프사이클에는 연결하지 않고 CI/deploy verification이 명시적으로 호출한다.
tasks.register('integrationTest', Test) {
+ group = 'verification'
+ description = 'Runs Testcontainers-backed integration tests.'
useJUnitPlatform()
include '**/*IT.class'
@@ -95,50 +78,13 @@ tasks.register('integrationTest', Test) {
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
- dependsOn tasks.named('verifyDockerAvailable')
-
// Docker 29+는 최소 API 1.44를 요구하지만 docker-java 3.4.0은 기본값 1.32로 요청함
systemProperty 'api.version', '1.44'
}
-jacoco {
- toolVersion = '0.8.12'
-}
-
-tasks.named('jacocoTestReport') {
- dependsOn tasks.named('test')
- reports {
- xml.required = true
- html.required = true
- csv.required = false
- }
-}
-
-tasks.named('jacocoTestCoverageVerification') {
- dependsOn tasks.named('test')
- violationRules {
- rule {
- element = 'BUNDLE'
- limit {
- counter = 'LINE'
- value = 'COVEREDRATIO'
- minimum = 0.45
- }
- }
+tasks.named('processResources') {
+ filteringCharset = 'UTF-8'
+ filesMatching('static/swagger-ui/index.html') {
+ expand(swaggerUiVersion: swaggerUiVersion)
}
}
-
-def trackedOpenApiSpec = layout.projectDirectory.file('docs/openapi/openapi.json')
-
-tasks.register('generateOpenApiSpec', Test) {
- group = 'documentation'
- description = 'Generates the tracked OpenAPI specification snapshot.'
- useJUnitPlatform()
- include '**/OpenApiDocsTest.class'
- testClassesDirs = sourceSets.test.output.classesDirs
- classpath = sourceSets.test.runtimeClasspath
- systemProperty 'openapi.output', trackedOpenApiSpec.asFile.absolutePath
- outputs.file(trackedOpenApiSpec)
-
- dependsOn tasks.named('testClasses')
-}
diff --git a/docs/openapi/README.md b/docs/openapi/README.md
deleted file mode 100644
index fe7ab82..0000000
--- a/docs/openapi/README.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# OpenAPI Contract
-
-This directory stores the tracked OpenAPI contract for `git-ranker`.
-
-## Files
-
-- `openapi.json`: generated baseline contract for the public `/api/v1/**` API surface
-
-## Regeneration
-
-Run the following command from the repository root:
-
-```bash
-./gradlew generateOpenApiSpec
-```
-
-The task runs the OpenAPI test slice with the `openapi` profile and writes the latest contract to `docs/openapi/openapi.json`.
-
-## Runtime Endpoints
-
-- OpenAPI JSON: `/v3/api-docs`
-- Swagger UI: `/swagger-ui/index.html`
-
-## Auth Notes
-
-- Protected endpoints accept either `Authorization: Bearer ` or the `accessToken` cookie.
-- `/api/v1/auth/refresh` uses the `refreshToken` cookie.
-- The initial GitHub OAuth2 login flow is handled by Spring Security outside `/api/v1/**`.
diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json
deleted file mode 100644
index 6833fa3..0000000
--- a/docs/openapi/openapi.json
+++ /dev/null
@@ -1 +0,0 @@
-{"openapi":"3.1.0","info":{"title":"Git Ranker API","description":"Machine-readable contract for Git Ranker's public `/api/v1/**` endpoints.\n\nAuthentication model:\n- Protected endpoints accept either an `Authorization: Bearer ` header or the `accessToken` cookie.\n- `/api/v1/auth/refresh` uses the `refreshToken` cookie.\n- Initial sign-in starts with the GitHub OAuth2 redirect flow exposed by Spring Security outside `/api/v1/**`.\n","version":"v1"},"servers":[{"url":"https://www.git-ranker.com","description":"Production"},{"url":"http://localhost:8080","description":"Local development"}],"paths":{"/api/v1/users/{username}/refresh":{"post":{"tags":["Users"],"summary":"Refresh the authenticated user's score","description":"Recalculates the caller's own profile. The authenticated user must match the path username.","operationId":"refreshUser","parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string","pattern":"^(?=.{1,39}$)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseRegisterUserResponse"}}}}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}},"/api/v1/auth/refresh":{"post":{"tags":["Auth"],"summary":"Refresh access and refresh tokens","description":"Requires the refreshToken cookie and rotates the active session tokens.","operationId":"refreshToken","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseVoid"}}}}},"security":[{"refreshTokenCookie":[]}]}},"/api/v1/auth/logout":{"post":{"tags":["Auth"],"summary":"Log out the current session","description":"Requires an authenticated session and the refreshToken cookie to invalidate the current login.","operationId":"logout","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseVoid"}}}}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}},"/api/v1/auth/logout/all":{"post":{"tags":["Auth"],"summary":"Log out every session for the current user","description":"Revokes all refresh tokens for the authenticated user.","operationId":"logoutAll","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseVoid"}}}}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}},"/api/v1/users/{username}":{"get":{"tags":["Users"],"summary":"Get a user's profile","description":"Returns the public Git Ranker profile for a GitHub username.","operationId":"getUser","parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string","pattern":"^(?=.{1,39}$)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseRegisterUserResponse"}}}}}}},"/api/v1/ranking":{"get":{"tags":["Ranking"],"summary":"List ranking entries","description":"Returns paginated ranking results with an optional tier filter.","operationId":"getRankings","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"tier","in":"query","required":false,"schema":{"type":"string","enum":["CHALLENGER","MASTER","DIAMOND","EMERALD","PLATINUM","GOLD","SILVER","BRONZE","IRON"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseRankingList"}}}}}}},"/api/v1/badges/{tier}/badge":{"get":{"tags":["Badges"],"summary":"Render a tier badge","description":"Returns an SVG badge template for the requested tier.","operationId":"getBadgeByTier","parameters":[{"name":"tier","in":"path","required":true,"schema":{"type":"string","enum":["CHALLENGER","MASTER","DIAMOND","EMERALD","PLATINUM","GOLD","SILVER","BRONZE","IRON"]}}],"responses":{"200":{"description":"OK","content":{"image/svg+xml":{"schema":{"type":"string"}}}}}}},"/api/v1/badges/{nodeId}":{"get":{"tags":["Badges"],"summary":"Render a badge for a GitHub node id","description":"Returns an SVG badge for a user's current Git Ranker profile.","operationId":"getBadge","parameters":[{"name":"nodeId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"image/svg+xml":{"schema":{"type":"string"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["Auth"],"summary":"Get the current authenticated user","description":"Returns the current session user resolved from the access token.","operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAuthMeResponse"}}}}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}},"/api/v1/users/me":{"delete":{"tags":["Users"],"summary":"Delete the authenticated user's account","description":"Deletes the current account and clears authentication cookies.","operationId":"deleteMyAccount","responses":{"204":{"description":"No Content"}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}}},"components":{"schemas":{"ApiResponseRegisterUserResponse":{"type":"object","properties":{"result":{"type":"string","enum":["SUCCESS","ERROR"]},"data":{"$ref":"#/components/schemas/RegisterUserResponse"},"error":{"$ref":"#/components/schemas/ErrorMessage"}}},"ErrorMessage":{"type":"object","properties":{"type":{"type":"string"},"message":{"type":"string"},"data":{}}},"RegisterUserResponse":{"type":"object","properties":{"userId":{"type":"integer","format":"int64"},"githubId":{"type":"integer","format":"int64"},"nodeId":{"type":"string"},"username":{"type":"string"},"email":{"type":"string"},"profileImage":{"type":"string"},"role":{"type":"string","enum":["GUEST","USER","ADMIN"]},"updatedAt":{"type":"string","format":"date-time"},"lastFullScanAt":{"type":"string","format":"date-time"},"totalScore":{"type":"integer","format":"int32"},"ranking":{"type":"integer","format":"int32"},"tier":{"type":"string","enum":["CHALLENGER","MASTER","DIAMOND","EMERALD","PLATINUM","GOLD","SILVER","BRONZE","IRON"]},"percentile":{"type":"number","format":"double"},"commitCount":{"type":"integer","format":"int32"},"issueCount":{"type":"integer","format":"int32"},"prCount":{"type":"integer","format":"int32"},"mergedPrCount":{"type":"integer","format":"int32"},"reviewCount":{"type":"integer","format":"int32"},"diffCommitCount":{"type":"integer","format":"int32"},"diffIssueCount":{"type":"integer","format":"int32"},"diffPrCount":{"type":"integer","format":"int32"},"diffMergedPrCount":{"type":"integer","format":"int32"},"diffReviewCount":{"type":"integer","format":"int32"},"isNewUser":{"type":"boolean"}}},"ApiResponseVoid":{"type":"object","properties":{"result":{"type":"string","enum":["SUCCESS","ERROR"]},"data":{},"error":{"$ref":"#/components/schemas/ErrorMessage"}}},"ApiResponseRankingList":{"type":"object","properties":{"result":{"type":"string","enum":["SUCCESS","ERROR"]},"data":{"$ref":"#/components/schemas/RankingList"},"error":{"$ref":"#/components/schemas/ErrorMessage"}}},"PageInfo":{"type":"object","properties":{"currentPage":{"type":"integer","format":"int32"},"pageSize":{"type":"integer","format":"int32"},"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"isFirst":{"type":"boolean"},"isLast":{"type":"boolean"}}},"RankingList":{"type":"object","properties":{"rankings":{"type":"array","items":{"$ref":"#/components/schemas/UserInfo"}},"pageInfo":{"$ref":"#/components/schemas/PageInfo"}}},"UserInfo":{"type":"object","properties":{"username":{"type":"string"},"profileImage":{"type":"string"},"ranking":{"type":"integer","format":"int64"},"totalScore":{"type":"integer","format":"int32"},"tier":{"type":"string","enum":["CHALLENGER","MASTER","DIAMOND","EMERALD","PLATINUM","GOLD","SILVER","BRONZE","IRON"]}}},"ApiResponseAuthMeResponse":{"type":"object","properties":{"result":{"type":"string","enum":["SUCCESS","ERROR"]},"data":{"$ref":"#/components/schemas/AuthMeResponse"},"error":{"$ref":"#/components/schemas/ErrorMessage"}}},"AuthMeResponse":{"type":"object","properties":{"username":{"type":"string"},"profileImage":{"type":"string"},"role":{"type":"string","enum":["GUEST","USER","ADMIN"]}}}},"securitySchemes":{"bearerAuth":{"type":"http","description":"Send `Authorization: Bearer ` for protected API calls.","scheme":"bearer","bearerFormat":"JWT"},"accessTokenCookie":{"type":"apiKey","description":"Browser session alternative to bearerAuth.","name":"accessToken","in":"cookie"},"refreshTokenCookie":{"type":"apiKey","description":"Required by refresh and logout flows that rotate or revoke session tokens.","name":"refreshToken","in":"cookie"}}}}
\ No newline at end of file
diff --git a/src/test/java/com/gitranker/api/architecture/ArchitectureGuardrailTest.java b/src/test/java/com/gitranker/api/architecture/ArchitectureGuardrailTest.java
deleted file mode 100644
index 6fc430c..0000000
--- a/src/test/java/com/gitranker/api/architecture/ArchitectureGuardrailTest.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.gitranker.api.architecture;
-
-import com.tngtech.archunit.core.importer.ImportOption;
-import com.tngtech.archunit.junit.AnalyzeClasses;
-import com.tngtech.archunit.junit.ArchTest;
-import com.tngtech.archunit.lang.ArchRule;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.bind.annotation.RestControllerAdvice;
-
-import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
-import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
-
-@AnalyzeClasses(
- packages = "com.gitranker.api",
- importOptions = ImportOption.DoNotIncludeTests.class
-)
-class ArchitectureGuardrailTest {
-
- @ArchTest
- static final ArchRule domain_should_not_depend_on_batch =
- noClasses()
- .that().resideInAPackage("..domain..")
- .should().dependOnClassesThat().resideInAPackage("..batch..");
-
- @ArchTest
- static final ArchRule infrastructure_should_not_depend_on_batch =
- noClasses()
- .that().resideInAPackage("..infrastructure..")
- .should().dependOnClassesThat().resideInAPackage("..batch..");
-
- @ArchTest
- static final ArchRule global_should_not_depend_on_batch =
- noClasses()
- .that().resideInAPackage("..global..")
- .should().dependOnClassesThat().resideInAPackage("..batch..");
-
- @ArchTest
- static final ArchRule rest_controllers_should_reside_in_domain_package =
- classes()
- .that().areAnnotatedWith(RestController.class)
- .should().resideInAPackage("..domain..");
-
- @ArchTest
- static final ArchRule controller_advices_should_reside_in_global_package =
- classes()
- .that().areAnnotatedWith(RestControllerAdvice.class)
- .should().resideInAPackage("..global..");
-}
diff --git a/src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java b/src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java
deleted file mode 100644
index ec71411..0000000
--- a/src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java
+++ /dev/null
@@ -1,85 +0,0 @@
-package com.gitranker.api.docs;
-
-import org.junit.jupiter.api.DisplayName;
-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.context.ActiveProfiles;
-import org.springframework.test.web.servlet.MockMvc;
-import org.springframework.test.web.servlet.MvcResult;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
-@AutoConfigureMockMvc
-@ActiveProfiles("openapi")
-class OpenApiDocsTest {
-
- private static final String OPENAPI_OUTPUT_PROPERTY = "openapi.output";
-
- @Autowired
- private MockMvc mockMvc;
-
- @Test
- @DisplayName("OpenAPI JSON은 공개 /api/v1 계약과 보안 스키마를 노출한다")
- void shouldExposeOpenApiJsonForPublicApi() throws Exception {
- MvcResult result = mockMvc.perform(get("/v3/api-docs"))
- .andExpect(status().isOk())
- .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
- .andExpect(jsonPath("$.paths['/api/v1/ranking'].get").exists())
- .andExpect(jsonPath("$.paths['/api/v1/users/{username}'].get").exists())
- .andExpect(jsonPath("$.paths['/api/v1/users/{username}/refresh'].post").exists())
- .andExpect(jsonPath("$.paths['/api/v1/users/me'].delete").exists())
- .andExpect(jsonPath("$.paths['/api/v1/auth/me'].get").exists())
- .andExpect(jsonPath("$.paths['/api/v1/auth/refresh'].post").exists())
- .andExpect(jsonPath("$.paths['/api/v1/auth/logout'].post").exists())
- .andExpect(jsonPath("$.paths['/api/v1/auth/logout/all'].post").exists())
- .andExpect(jsonPath("$.paths['/api/v1/badges/{nodeId}'].get").exists())
- .andExpect(jsonPath("$.paths['/api/v1/badges/{tier}/badge'].get").exists())
- .andExpect(jsonPath("$.paths['/api/v1/users/me'].delete.responses['204']").exists())
- .andExpect(jsonPath("$.components.securitySchemes.bearerAuth").exists())
- .andExpect(jsonPath("$.components.securitySchemes.accessTokenCookie").exists())
- .andExpect(jsonPath("$.components.securitySchemes.refreshTokenCookie").exists())
- .andExpect(jsonPath("$.servers[0].url").value("https://www.git-ranker.com"))
- .andExpect(jsonPath("$.servers[1].url").value("http://localhost:8080"))
- .andReturn();
-
- String responseBody = result.getResponse().getContentAsString();
- assertThat(responseBody).doesNotContain("\"/actuator/health\"");
-
- persistIfRequested(responseBody);
- }
-
- @Test
- @DisplayName("Swagger UI는 브라우저 경로에서 노출된다")
- void shouldExposeSwaggerUi() throws Exception {
- mockMvc.perform(get("/swagger-ui/index.html"))
- .andExpect(status().isOk())
- .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
- .andExpect(content().string(org.hamcrest.Matchers.containsString("swagger-ui")));
- }
-
- private void persistIfRequested(String responseBody) throws IOException {
- String outputPath = System.getProperty(OPENAPI_OUTPUT_PROPERTY);
- if (outputPath == null || outputPath.isBlank()) {
- return;
- }
-
- Path outputFile = Path.of(outputPath);
- Path parent = outputFile.getParent();
- if (parent != null) {
- Files.createDirectories(parent);
- }
- Files.writeString(outputFile, responseBody);
- }
-}
diff --git a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java
deleted file mode 100644
index b537c52..0000000
--- a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java
+++ /dev/null
@@ -1,167 +0,0 @@
-package com.gitranker.api.testsupport;
-
-import java.io.InputStream;
-import java.io.IOException;
-import java.io.UncheckedIOException;
-import java.nio.charset.StandardCharsets;
-import java.util.concurrent.CompletableFuture;
-import java.util.List;
-import java.util.Objects;
-
-public final class DockerPreflightCheck {
-
- private static final List DOCKER_CONTEXT_COMMAND = List.of("docker", "context", "show");
- private static final List DOCKER_VERSION_COMMAND =
- List.of("docker", "version", "--format", "{{.Server.APIVersion}}");
-
- private final CommandRunner commandRunner;
-
- public DockerPreflightCheck(CommandRunner commandRunner) {
- this.commandRunner = Objects.requireNonNull(commandRunner);
- }
-
- public DockerPreflightResult run() {
- CommandResult contextResult = commandRunner.run(DOCKER_CONTEXT_COMMAND);
- String context = normalizedOrFallback(contextResult.stdout(), "unavailable");
-
- CommandResult versionResult = commandRunner.run(DOCKER_VERSION_COMMAND);
- if (versionResult.isSuccess()) {
- String apiVersion = normalizedOrFallback(versionResult.stdout(), "unknown");
- return DockerPreflightResult.success(
- "Docker preflight passed. context=%s serverApiVersion=%s".formatted(context, apiVersion));
- }
-
- return DockerPreflightResult.failure(buildFailureMessage(context, versionResult));
- }
-
- private String buildFailureMessage(String context, CommandResult versionResult) {
- String diagnostic = versionResult.primaryDiagnostic();
- String cause = determineCause(versionResult);
- String firstStep = isDockerCliMissing(versionResult)
- ? "- Install Docker Desktop, OrbStack, or another compatible Docker runtime."
- : "- Start Docker Desktop, OrbStack, or another compatible Docker runtime and confirm `docker version` succeeds.";
-
- return """
- Docker preflight failed for Testcontainers integration tests.
- Cause: %s
- Context: %s
- Details: %s
- Next steps:
- %s
- - If you only need code-level verification, run `./gradlew test jacocoTestCoverageVerification`.
- - Re-run `./gradlew integrationTest` after Docker is available.
- """.formatted(cause, context, diagnostic, firstStep);
- }
-
- private String determineCause(CommandResult versionResult) {
- if (isDockerCliMissing(versionResult)) {
- return "Docker CLI is not installed or not available on PATH.";
- }
- if (isDockerDaemonUnavailable(versionResult)) {
- return "Docker daemon is not reachable from the current shell.";
- }
- return "Docker returned a non-zero exit code before Testcontainers could start.";
- }
-
- private boolean isDockerCliMissing(CommandResult versionResult) {
- String diagnostic = versionResult.primaryDiagnostic().toLowerCase();
- return versionResult.exitCode() == 127
- || diagnostic.contains("command not found")
- || diagnostic.contains("no such file or directory");
- }
-
- private boolean isDockerDaemonUnavailable(CommandResult versionResult) {
- String diagnostic = versionResult.primaryDiagnostic().toLowerCase();
- return diagnostic.contains("cannot connect to the docker daemon")
- || diagnostic.contains("is the docker daemon running")
- || diagnostic.contains("error during connect");
- }
-
- private String normalizedOrFallback(String value, String fallback) {
- String normalized = value == null ? "" : value.trim();
- return normalized.isEmpty() ? fallback : normalized;
- }
-}
-
-@FunctionalInterface
-interface CommandRunner {
-
- CommandResult run(List command);
-}
-
-record CommandResult(int exitCode, String stdout, String stderr) {
-
- static CommandResult success(String stdout, String stderr) {
- return new CommandResult(0, stdout, stderr);
- }
-
- static CommandResult failure(int exitCode, String stdout, String stderr) {
- if (exitCode == 0) {
- throw new IllegalArgumentException("Failure result must have non-zero exit code");
- }
- return new CommandResult(exitCode, stdout, stderr);
- }
-
- boolean isSuccess() {
- return exitCode == 0;
- }
-
- String primaryDiagnostic() {
- String stderrText = stderr == null ? "" : stderr.trim();
- if (!stderrText.isEmpty()) {
- return stderrText;
- }
-
- String stdoutText = stdout == null ? "" : stdout.trim();
- if (!stdoutText.isEmpty()) {
- return stdoutText;
- }
-
- return "No additional docker diagnostic output was produced.";
- }
-}
-
-record DockerPreflightResult(boolean isSuccess, String message) {
-
- static DockerPreflightResult success(String message) {
- return new DockerPreflightResult(true, message);
- }
-
- static DockerPreflightResult failure(String message) {
- return new DockerPreflightResult(false, message);
- }
-}
-
-final class ProcessCommandRunner implements CommandRunner {
-
- @Override
- public CommandResult run(List command) {
- ProcessBuilder processBuilder = new ProcessBuilder(command);
- processBuilder.redirectErrorStream(false);
-
- try {
- Process process = processBuilder.start();
- CompletableFuture stdoutFuture =
- CompletableFuture.supplyAsync(() -> readStream(process.getInputStream()));
- CompletableFuture stderrFuture =
- CompletableFuture.supplyAsync(() -> readStream(process.getErrorStream()));
- int exitCode = process.waitFor();
- String stdout = stdoutFuture.join();
- String stderr = stderrFuture.join();
- return new CommandResult(exitCode, stdout, stderr);
- } catch (IOException exception) {
- return CommandResult.failure(127, "", exception.getMessage());
- } catch (InterruptedException exception) {
- Thread.currentThread().interrupt();
- return CommandResult.failure(130, "", "Interrupted while running command: " + String.join(" ", command));
- }
- }
-
- private String readStream(InputStream inputStream) {
- try {
- return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
- } catch (IOException exception) {
- throw new UncheckedIOException(exception);
- }
- }
-}
diff --git a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java
deleted file mode 100644
index fa043e0..0000000
--- a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java
+++ /dev/null
@@ -1,88 +0,0 @@
-package com.gitranker.api.testsupport;
-
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
-import java.util.List;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-class DockerPreflightCheckTest {
-
- @Test
- @DisplayName("docker daemon이 reachable이면 성공 결과를 반환한다")
- void should_returnSuccess_when_dockerDaemonIsReachable() {
- DockerPreflightCheck check = new DockerPreflightCheck(command -> {
- if (command.equals(List.of("docker", "context", "show"))) {
- return CommandResult.success("orbstack\n", "");
- }
- if (command.equals(List.of("docker", "version", "--format", "{{.Server.APIVersion}}"))) {
- return CommandResult.success("1.51\n", "");
- }
- throw new IllegalArgumentException("Unexpected command: " + command);
- });
-
- DockerPreflightResult result = check.run();
-
- assertThat(result.isSuccess()).isTrue();
- assertThat(result.message())
- .isEqualTo("Docker preflight passed. context=orbstack serverApiVersion=1.51");
- }
-
- @Test
- @DisplayName("docker daemon에 연결할 수 없으면 환경 문제를 설명하는 실패 결과를 반환한다")
- void should_returnHelpfulFailure_when_dockerDaemonIsUnavailable() {
- DockerPreflightCheck check = new DockerPreflightCheck(command -> {
- if (command.equals(List.of("docker", "context", "show"))) {
- return CommandResult.success("orbstack\n", "");
- }
- if (command.equals(List.of("docker", "version", "--format", "{{.Server.APIVersion}}"))) {
- return CommandResult.failure(
- 1,
- "",
- "Cannot connect to the Docker daemon at unix:///tmp/docker.sock. Is the docker daemon running?");
- }
- throw new IllegalArgumentException("Unexpected command: " + command);
- });
-
- DockerPreflightResult result = check.run();
-
- assertThat(result.isSuccess()).isFalse();
- assertThat(result.message()).contains("Docker preflight failed for Testcontainers integration tests.");
- assertThat(result.message()).contains("Cause: Docker daemon is not reachable from the current shell.");
- assertThat(result.message()).contains("Context: orbstack");
- assertThat(result.message()).contains("docker version");
- assertThat(result.message()).contains("./gradlew test jacocoTestCoverageVerification");
- assertThat(result.message()).contains("./gradlew integrationTest");
- }
-
- @Test
- @DisplayName("docker CLI가 없으면 설치 전제조건을 안내하는 실패 결과를 반환한다")
- void should_returnHelpfulFailure_when_dockerCliIsMissing() {
- DockerPreflightCheck check = new DockerPreflightCheck(command -> {
- if (command.equals(List.of("docker", "context", "show"))) {
- return CommandResult.failure(127, "", "docker: command not found");
- }
- if (command.equals(List.of("docker", "version", "--format", "{{.Server.APIVersion}}"))) {
- return CommandResult.failure(127, "", "docker: command not found");
- }
- throw new IllegalArgumentException("Unexpected command: " + command);
- });
-
- DockerPreflightResult result = check.run();
-
- assertThat(result.isSuccess()).isFalse();
- assertThat(result.message()).contains("Cause: Docker CLI is not installed or not available on PATH.");
- assertThat(result.message()).contains("Context: unavailable");
- assertThat(result.message()).contains("Install Docker Desktop, OrbStack, or another compatible Docker runtime.");
- }
-
- @Test
- @DisplayName("failure result는 zero exit code를 허용하지 않는다")
- void should_rejectZeroExitCode_when_creatingFailureResult() {
- assertThatThrownBy(() -> CommandResult.failure(0, "", "unexpected"))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Failure result must have non-zero exit code");
- }
-}
diff --git a/src/test/java/com/gitranker/api/testsupport/DockerPreflightMain.java b/src/test/java/com/gitranker/api/testsupport/DockerPreflightMain.java
deleted file mode 100644
index 7a3e217..0000000
--- a/src/test/java/com/gitranker/api/testsupport/DockerPreflightMain.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.gitranker.api.testsupport;
-
-public final class DockerPreflightMain {
-
- private DockerPreflightMain() {
- }
-
- public static void main(String[] args) {
- DockerPreflightResult result = new DockerPreflightCheck(new ProcessCommandRunner()).run();
- if (result.isSuccess()) {
- System.out.println(result.message());
- return;
- }
-
- System.err.println(result.message());
- System.exit(1);
- }
-}