diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 00000000..3876c81d --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,123 @@ +name: PR CI (main) + +on: + pull_request: + branches: [ "main" ] # main으로 향하는 PR만 검사 + types: [ opened, synchronize, reopened, ready_for_review ] # PR 열림/커밋추가/다시열기/드래프트해제 시 실행 + paths-ignore: + - '**.md' + - 'docs/**' + +jobs: + test: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 15 # CI가 너무 오래 걸릴 때 무한 대기 방지 (필요시 늘려도 됨) + strategy: + matrix: + dbmode: [ mysql, postgres ] # DB 의존 로직 호환성 점검을 위해 H2를 두 모드로 테스트 + fail-fast: false # 한 모드가 실패해도 나머지 모드는 계속 실행(진단에 유리) + + steps: + # 1) 코드 체크아웃 + - name: Checkout code + uses: actions/checkout@v4 + + # 2) JDK 설치 (프로젝트 toolchain=21과 일치) + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: gradle # Gradle 캐시 자동화( wrapper/caches ) + + # 3) gradlew 실행권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # 4) Gradle 래퍼 유효성 체크(선택이지만 문제 파악에 도움) + - name: Validate Gradle wrapper + run: ./gradlew --version + + # 5) 업로드 경로 더미 생성 (upload.path 값이 필요한 빈 대비) + - name: Prepare upload dir + run: mkdir -p /tmp/uploads + + # 6) 테스트 + - name: Run tests (H2 via ENV) + run: ./gradlew clean test --no-daemon --stacktrace --info + env: + # (스프링) test 프로필로 기동 — 실제 파일 없어도 ENV가 모든 값을 오버라이드 + SPRING_PROFILES_ACTIVE: test + + # (DB) H2 메모리 DB URL — dbmode 매트릭스 값에 따라 MySQL/Postgre 모드 전환 + SPRING_DATASOURCE_URL: ${{ matrix.dbmode == 'mysql' + && 'jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE;DB_CLOSE_ON_EXIT=FALSE' + || 'jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE;DB_CLOSE_ON_EXIT=FALSE' }} + SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.h2.Driver + SPRING_DATASOURCE_USERNAME: sa + SPRING_DATASOURCE_PASSWORD: "" + + # (JPA) 테스트 중 스키마 자동 생성/삭제 — 외부 스키마 의존 제거 + SPRING_JPA_HIBERNATE_DDL_AUTO: create-drop + SPRING_SQL_INIT_MODE: never + + # 외부 리소스 자동설정 차단(네트워크 시도 방지) + SPRING_AUTOCONFIGURE_EXCLUDE: > + org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration, + org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration, + org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration, + org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration, + org.springframework.boot.autoconfigure.session.SessionAutoConfiguration, + org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration + + # 세션 저장소 비활성화 + SPRING_SESSION_STORE_TYPE: none + + # GCP Secret Manager 무력화 + SPRING_CLOUD_GCP_SECRET_MANAGER_ENABLED: "false" + SPRING_CONFIG_IMPORT: "" + + # ---- 자리값 더미(컨텍스트 부팅용) --- + # 파일 업로드 경로 (핵심) + UPLOAD_PATH: /tmp/uploads # upload.path와 자동 매핑 (relaxed binding) + + # JWT / Mail + JWT_SECRET: dummy + SMTP_USERNAME: dummy@example.com + SMTP_PASSWORD: dummy + + # OAuth2 + GOOGLE_CLIENT_ID: dummy + GOOGLE_CLIENT_SECRET: dummy + GOOGLE_REDIRECT_URI: http://localhost/login/oauth2/code/google + + # Redis (값만 읽는 코드 대비) + SPRING_DATA_REDIS_HOST: localhost + SPRING_DATA_REDIS_PORT: "6379" + SPRING_DATA_REDIS_USERNAME: default + SPRING_DATA_REDIS_PASSWORD: dummy + SPRING_DATA_REDIS_SSL_ENABLED: "false" + + # 도메인 기본값 + FRONT_SERVER_DOMAIN: http://localhost:3000 + APP_DOMAIN: http://localhost:80 + + # LangChain/Gemini + GEMINI_API_KEY: "dummy" + + # CI 로그 노이즈 감소 — 필요시 조정 + SPRING_JPA_SHOW_SQL: "false" + SPRING_JPA_PROPERTIES_HIBERNATE_FORMAT_SQL: "false" + LOGGING_LEVEL_ROOT: "warn" + LOGGING_LEVEL_ORG_SPRINGFRAMEWORK: "warn" + + # 6) 테스트 리포트 업로드 — 실패 시에도 항상 업로드하여 원인 파악 + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-report-${{ matrix.dbmode }} + path: | + build/reports/tests/test + retention-days: 30 diff --git a/.gitignore b/.gitignore index 810c7770..54c034c8 100644 --- a/.gitignore +++ b/.gitignore @@ -85,7 +85,11 @@ replay_pid* # === Spring Boot 환경설정 파일 === application.properties -application-*.properties +application-local.properties +application-test.properties +application-prod.properties +application-prod-aws.properties +application-prod-gcp.properties # === 기타 === HELP.md diff --git a/src/main/resources/application-template.properties b/src/main/resources/application-template.properties new file mode 100644 index 00000000..9ed30d47 --- /dev/null +++ b/src/main/resources/application-template.properties @@ -0,0 +1,157 @@ +# ???????????????????????????????????????????????????????????????????????????????????????????? +# ??????????????????????? Application Template (DO NOT PUT SECRETS) ??????????????????????? +# ?? ?? ????/??? ???? ?????. +# - GCP: ${sm://secret-name} ?? ?? +# - AWS: ${ENV_NAME} ????/Secrets Manager ?? +# ????????????????????????????????????????????????????????????????????????????????????????? +# ???????????????????????????????????????????????????????????????????????????????????????????? + +# ---- Profile ---- +# ?? ??/?? ? -Dspring.profiles.active=prod-gcp ?? prod-aws ? ?? +spring.profiles.active= + +# ---- Server ---- +server.port=80 +server.servlet.encoding.charset=UTF-8 +server.servlet.encoding.enabled=true +server.servlet.encoding.force=true + +# ---- Domain / CORS (???? ?? ???) ---- +front-server.domain=${FRONT_SERVER_DOMAIN:https://example-frontend.com} +app.domain=${APP_DOMAIN:https://example-backend.com} + +# ---- File Upload / Multipart ---- +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB + +# ---- Cache ---- +spring.cache.type=caffeine +spring.cache.cache-names=authority +spring.cache.caffeine.spec=expireAfterWrite=10s + +# ---- Logging ?? ---- +logging.level.root=info +logging.level.org.hibernate.SQL=info +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=info +logging.level.org.hibernate.type.descriptor.sql=info +logging.level.org.springframework.cloud.openfeign=info +logging.level.org.springframework.security=info +logging.level.org.springframework.web.client.RestTemplate=info +logging.level.org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider=info +logging.level.org.springframework.security.oauth2.core.http.converter=info +spring.cloud.openfeign.client.config.default.logger-level=full + +# ---- JPA ?? ---- +spring.jpa.open-in-view=false +spring.sql.init.mode=never +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.defer-datasource-initialization=true +spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults=false +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true +spring.sql.init.schema-locations=classpath:schema.sql +spring.sql.init.data-locations=classpath:data.sql + +# ?????????????????????????????????????????????????????????????????????????????????????????? +# ???????????????????????????? ??? ?? ??? ???? ?? ??????????????????????????? +# 1) GCP ?? (Secret Manager ?? ?) +# - spring.config.import=sm:// ? ??, ?? ?? ??? sm://? ?? +# 2) AWS ?? (ENV/Secrets Manager) +# - ${ENV_NAME} ??? ????, ECS/EC2?? ????? ?? +# ????????????????????????????????????????????????????????????????????????????????????????? +# ?????????????????????????????????????????????????????????????????????????????????????????? + +# [prod-gcp] ?? ========================================================================== +# spring.profiles: prod-gcp ?? ? ??? +# ?? ??: -Dspring.profiles.active=prod-gcp +# ========================================================================================== +# spring.config.import=sm:// # GCP Secret Manager ?? ? ?? + +# spring.cloud.gcp.secretmanager.enabled=true +# google.cloud.storage.bucket=YOUR_GCS_BUCKET +# google.cloud.storage.project-id=YOUR_GCP_PROJECT + +# ---- DB ---- +#spring.datasource.url=${sm://db-url} +#spring.datasource.username=${sm://db-username} +#spring.datasource.password=${sm://db-password} +#spring.datasource.driver-class-name=org.postgresql.Driver +#spring.datasource.hikari.maximum-pool-size=5 + +# ---- Redis ---- +#spring.data.redis.host=${sm://redis-host} +#spring.data.redis.port=${sm://redis-port} +#spring.data.redis.username=${sm://redis-username} +#spring.data.redis.password=${sm://redis-password} +#spring.data.redis.ssl.enabled=true + +# ---- JWT ---- +#jwt.expiration=${sm://jwt-expiration} +#jwt.refresh-expiration=${sm://jwt-refresh-expiration} +#jwt.secret=${sm://jwt-secret} + +# ---- Mail ---- +#spring.mail.host=smtp.gmail.com +#spring.mail.port=587 +#spring.mail.username=${sm://smtp-username} +#spring.mail.password=${sm://smtp-password} +#spring.mail.properties.mail.smtp.auth=true +#spring.mail.properties.mail.smtp.starttls.enable=true + +# ---- Google OAuth ---- +#spring.security.oauth2.client.registration.google.client-id=${sm://google-client-id} +#spring.security.oauth2.client.registration.google.client-secret=${sm://google-secret} +#spring.security.oauth2.client.registration.google.redirect-uri=${sm://google-redirect-uri} +#spring.security.oauth2.client.registration.google.scope=profile,email +#spring.security.oauth2.client.registration.google.client-name=Google + +# ---- LangChain/Gemini ---- +#langchain4j.google-ai-gemini.chat-model.model-name=${sm://gemini-chat-model-name} +#langchain4j.google-ai-gemini.chat-model.api-key=${sm://gemini-chat-model} +#langchain4j.google-ai-gemini.chat-model.log-requests-and-responses=true +#langchain4j.google-ai-gemini.chat-model.max-output-tokens=500 + +# [prod-aws] ?? ========================================================================== +# spring.profiles: prod-aws ?? ? ??? +# ?? ??: -Dspring.profiles.active=prod-aws +# ========================================================================================== + +# ---- DB ---- +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.datasource.hikari.maximum-pool-size=5 + +# ---- Redis ---- +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} +spring.data.redis.username=${REDIS_USERNAME:default} +spring.data.redis.password=${REDIS_PASSWORD} +spring.data.redis.ssl.enabled=true + +# ---- JWT ---- +jwt.secret=${JWT_SECRET} +jwt.expiration=${JWT_EXPIRATION:31536000000} +jwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000} + +# ---- Mail ---- +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${SMTP_USERNAME} +spring.mail.password=${SMTP_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# ---- Google OAuth ---- +spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} +spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} +spring.security.oauth2.client.registration.google.redirect-uri=${GOOGLE_REDIRECT_URI} +spring.security.oauth2.client.registration.google.scope=profile,email +spring.security.oauth2.client.registration.google.client-name=Google + +# ---- LangChain/Gemini ---- +langchain4j.google-ai-gemini.chat-model.model-name=${GEMINI_MODEL_NAME:gemini-2.0-flash-lite} +langchain4j.google-ai-gemini.chat-model.api-key=${GEMINI_API_KEY} +langchain4j.google-ai-gemini.chat-model.log-requests-and-responses=true +langchain4j.google-ai-gemini.chat-model.max-output-tokens=500 diff --git a/src/test/java/grep/neogulcoder/domain/IntegrationTestSupport.java b/src/test/java/grep/neogulcoder/domain/IntegrationTestSupport.java index 21eae885..36eadbb8 100644 --- a/src/test/java/grep/neogulcoder/domain/IntegrationTestSupport.java +++ b/src/test/java/grep/neogulcoder/domain/IntegrationTestSupport.java @@ -1,22 +1,46 @@ package grep.neogulcoder.domain; -import grep.neogulcoder.domain.review.service.ReviewService; import grep.neogulcoder.domain.review.repository.MyReviewTagRepository; import grep.neogulcoder.domain.review.repository.ReviewRepository; import grep.neogulcoder.domain.review.repository.ReviewTagRepository; +import grep.neogulcoder.domain.review.service.ReviewService; import grep.neogulcoder.domain.study.repository.StudyMemberRepository; import grep.neogulcoder.domain.study.repository.StudyRepository; import grep.neogulcoder.domain.users.repository.UserRepository; -import org.springframework.beans.factory.annotation.Autowired; +import grep.neogulcoder.global.auth.jwt.JwtAuthenticationFilter; +import grep.neogulcoder.global.auth.jwt.JwtExceptionFilter; +import grep.neogulcoder.global.auth.repository.UserBlackListRepository; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.transaction.annotation.Transactional; +import org.springframework.beans.factory.annotation.Autowired; @Transactional @ActiveProfiles("test") +@ExtendWith(SpringExtension.class) @SpringBootTest +@Import(IntegrationTestSupport.SecurityMocks.class) public abstract class IntegrationTestSupport { + @TestConfiguration + static class SecurityMocks { + @Bean JwtAuthenticationFilter jwtAuthenticationFilter() { + return Mockito.mock(JwtAuthenticationFilter.class); + } + @Bean JwtExceptionFilter jwtExceptionFilter() { + return Mockito.mock(JwtExceptionFilter.class); + } + @Bean UserBlackListRepository userBlackListRepository() { + return Mockito.mock(UserBlackListRepository.class); + } + } + @Autowired private UserRepository userRepository;