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 @@ BadgeData RefreshFAQ • - Development VerificationContributingRoadmapLicense • @@ -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); - } -}