diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f9bfb43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,117 @@ +name: CI + +on: + pull_request: + branches: [ "develop", "main" ] + types: [ opened, synchronize, reopened, ready_for_review ] + workflow_dispatch: {} + +permissions: + contents: read + checks: write + pull-requests: write + issues: write + +concurrency: + group: pr-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: { distribution: temurin, java-version: 17 } + - uses: gradle/actions/setup-gradle@v3 + if: ${{ !env.ACT }} + with: + cache-read-only: ${{ github.event_name == 'pull_request' }} + gradle-home-cache-cleanup: true + + - name: Create dummy .env for CI + working-directory: gdgoc + run: echo "# ci dummy" > .env + + - name: Gradle build (skip tests) + id: assemble + working-directory: gdgoc + env: { GRADLE_OPTS: "-Dorg.gradle.vfs.watch=false" } + run: | + chmod +x ./gradlew + ./gradlew build -x test --no-daemon --stacktrace --info --no-watch-fs | tee ../build.log + + - name: Gradle test (H2 in-memory) + id: test + working-directory: gdgoc + env: + GRADLE_OPTS: "-Dorg.gradle.vfs.watch=false" + SPRING_PROFILES_ACTIVE: test + SPRING_DATASOURCE_URL: jdbc:h2:mem:ci;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + SPRING_DATASOURCE_USERNAME: sa + SPRING_DATASOURCE_PASSWORD: "" + SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.h2.Driver + SPRING_JPA_DATABASE_PLATFORM: org.hibernate.dialect.H2Dialect + SPRING_JPA_HIBERNATE_DDL_AUTO: create-drop + SPRING_FLYWAY_ENABLED: "false" + run: ./gradlew test --no-daemon --stacktrace --info --no-watch-fs | tee ../test.log + + - name: Publish unit-test results (check UI) + id: publish + if: ${{ always() && !env.ACT && hashFiles('**/build/test-results/test/**/*.xml') != '' }} + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + files: "**/build/test-results/test/**/*.xml" + comment_mode: off + check_run: ${{ !github.event.pull_request.head.repo.fork }} + job_summary: true + + - name: Compose message + id: status + if: ${{ always() }} + run: | + if [ "${{ steps.assemble.outcome }}" = "success" ]; then + MSG="✅ Assemble 성공" + else + MSG="❌ Assemble 실패" + fi + + if [ "${{ steps.test.outcome }}" = "success" ]; then + MSG="$MSG"$'\n'"✅ Test 성공" + else + MSG="$MSG"$'\n'"❌ Test 실패" + fi + + if [ "${{ steps.publish.outcome }}" = "failure" ]; then + MSG="$MSG"$'\n'"⚠️ 테스트 리포트 게시 실패(권한/토큰 문제일 수 있음)" + fi + + printf "message<> "$GITHUB_OUTPUT" + + - name: Find existing CI status comment + id: find-comment + if: ${{ always() && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && !env.ACT }} + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: 'CI status' + + - name: Create or update PR comment + if: ${{ always() && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && !env.ACT }} + uses: peter-evans/create-or-update-comment@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + **CI status** + ${{ steps.status.outputs.message }} + + + - name: Fail if any failed + if: ${{ always() && (steps.assemble.outcome != 'success' || steps.test.outcome != 'success') }} + run: exit 1 \ No newline at end of file diff --git a/gdgoc/build.gradle b/gdgoc/build.gradle index 8db760d..1158cb5 100644 --- a/gdgoc/build.gradle +++ b/gdgoc/build.gradle @@ -7,70 +7,80 @@ plugins { group = 'inha' version = '0.0.1-SNAPSHOT' +/* ===== Java Toolchain ===== */ java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } +/* ===== Configurations ===== */ configurations { compileOnly { extendsFrom annotationProcessor } } +/* ===== Repositories ===== */ repositories { mavenCentral() } +/* ===== Dependencies ===== */ dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // --- Spring Boot Starters --- implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'com.vladmihalcea:hibernate-types-60:2.21.1' - implementation group: 'org.postgresql', name: 'postgresql', version: '42.7.3' - implementation 'io.github.cdimascio:java-dotenv:5.2.2' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-mail' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' - annotationProcessor 'org.projectlombok:lombok' + // --- DB & JPA Utils --- + implementation 'com.vladmihalcea:hibernate-types-60:2.21.1' + implementation 'org.postgresql:postgresql:42.7.3' + runtimeOnly 'com.h2database:h2' // 테스트/로컬용 인메모리 DB - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // --- QueryDSL --- + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' - testImplementation 'org.mockito:mockito-core:5.6.0' - testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0' + // --- JWT (JJWT 0.9.x, 레거시 패키지) --- + implementation 'io.jsonwebtoken:jjwt:0.9.1' - testImplementation 'org.assertj:assertj-core:3.24.2' + // --- AWS (S3) --- + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' - //querydsl - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' - annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" - annotationProcessor "jakarta.annotation:jakarta.annotation-api" - annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // --- Flyway (DB Migration) --- + implementation "org.flywaydb:flyway-core:10.21.0" + implementation "org.flywaydb:flyway-database-postgresql:10.21.0" - // OAuth - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // --- 환경변수(.env) --- + implementation 'io.github.cdimascio:java-dotenv:5.2.2' - // jwt - implementation 'io.jsonwebtoken:jjwt:0.9.1' + // --- Lombok --- + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + // --- Test --- + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.mockito:mockito-core:5.6.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0' + testImplementation 'org.assertj:assertj-core:3.24.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} - // aws s3 - implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' +/* ===== Tasks ===== */ - // mail sender - implementation 'org.springframework.boot:spring-boot-starter-mail' +// 테스트: 프로필과 JUnit 플랫폼 한 곳에서 설정 +tasks.test { + useJUnitPlatform() + systemProperty "spring.profiles.active", "test" } +// QueryDSL 생성물 경로 고정 tasks.withType(JavaCompile).configureEach { options.annotationProcessorGeneratedSourcesDirectory = file("build/generated/sources/annotationProcessor/java/main") } -tasks.named('compileJava') { - dependsOn 'clean' -} - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/gdgoc/src/main/java/inha/gdgoc/config/DotenvLoader.java b/gdgoc/src/main/java/inha/gdgoc/config/DotenvLoader.java index 6762bb6..2194bf6 100644 --- a/gdgoc/src/main/java/inha/gdgoc/config/DotenvLoader.java +++ b/gdgoc/src/main/java/inha/gdgoc/config/DotenvLoader.java @@ -2,8 +2,10 @@ import io.github.cdimascio.dotenv.Dotenv; import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +@Profile("!test") @Component public class DotenvLoader { diff --git a/gdgoc/src/main/java/inha/gdgoc/domain/user/entity/User.java b/gdgoc/src/main/java/inha/gdgoc/domain/user/entity/User.java index b3aa0c9..97a8a85 100644 --- a/gdgoc/src/main/java/inha/gdgoc/domain/user/entity/User.java +++ b/gdgoc/src/main/java/inha/gdgoc/domain/user/entity/User.java @@ -9,6 +9,8 @@ import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -58,6 +60,7 @@ public class User extends BaseEntity { @Column(name = "password", nullable = false) private String password; + @Enumerated(EnumType.STRING) @Column(name = "user_role", nullable = false) private UserRole userRole; diff --git a/gdgoc/src/main/java/inha/gdgoc/domain/user/enums/UserRole.java b/gdgoc/src/main/java/inha/gdgoc/domain/user/enums/UserRole.java index 1ae5ac9..be8e39c 100644 --- a/gdgoc/src/main/java/inha/gdgoc/domain/user/enums/UserRole.java +++ b/gdgoc/src/main/java/inha/gdgoc/domain/user/enums/UserRole.java @@ -4,8 +4,8 @@ @Getter public enum UserRole { - GUEST("Guest"), - MEMBER("Member"), + GUEST("GUEST"), + MEMBER("MEMBER"), ADMIN("ADMIN"); private final String role; diff --git a/gdgoc/src/main/resources/application-local.yml b/gdgoc/src/main/resources/application-local.yml index ce30e9d..740ebf2 100644 --- a/gdgoc/src/main/resources/application-local.yml +++ b/gdgoc/src/main/resources/application-local.yml @@ -15,12 +15,20 @@ spring: jpa: database: postgresql hibernate: - ddl-auto: update + ddl-auto: none properties: hibernate: default_batch_fetch_size: 100 jdbc: time_zone: Asia/Seoul + flyway: + enabled: true + locations: classpath:db/migration + schemas: public + baseline-on-migrate: true # ★ 기존 스키마를 기준선으로 등록 + baseline-version: 1 # 기준선 버전(임의, 보통 1) + baseline-description: "Baseline existing schema" + mail: host: smtp.gmail.com port: 587 diff --git a/gdgoc/src/main/resources/application-prod.yml b/gdgoc/src/main/resources/application-prod.yml index b0f551b..a4ff42a 100644 --- a/gdgoc/src/main/resources/application-prod.yml +++ b/gdgoc/src/main/resources/application-prod.yml @@ -21,8 +21,14 @@ spring: hibernate: default_batch_fetch_size: 100 time_zone: Asia/Seoul - show-sql: true + show-sql: false database-platform: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: true + baseline-on-migrate: true # 변경 확인 후 제거 예정 + clean-disabled: true + validate-migration-naming: true + locations: classpath:db/migration mail: host: smtp.gmail.com port: 587 @@ -38,7 +44,7 @@ spring: logging: level: org.hibernate.SQL: debug - org.hibername.type: trace + org.hibernate.type: off google: diff --git a/gdgoc/src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql b/gdgoc/src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql new file mode 100644 index 0000000..8dff968 --- /dev/null +++ b/gdgoc/src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql @@ -0,0 +1,38 @@ +DO $$ +DECLARE r record; +BEGIN + FOR r IN + SELECT conname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE t.relname = 'users' + AND n.nspname = 'public' + AND c.contype = 'c' + AND pg_get_constraintdef(c.oid) ILIKE '%user_role%' -- user_role 관련 CHECK + LOOP + EXECUTE format('ALTER TABLE public.users DROP CONSTRAINT %I', r.conname); + END LOOP; +END $$; + +ALTER TABLE public.users ALTER COLUMN user_role DROP DEFAULT; + +ALTER TABLE public.users + ALTER COLUMN user_role TYPE varchar(32) + USING user_role::text; + + +UPDATE public.users +SET user_role = CASE user_role + WHEN '0' THEN 'GUEST' + WHEN '1' THEN 'MEMBER' + WHEN '2' THEN 'ADMIN' + ELSE user_role + END; + +-- 5) 새 디폴트/체크 제약조건 설정 +ALTER TABLE public.users ALTER COLUMN user_role SET DEFAULT 'GUEST'; + +ALTER TABLE public.users + ADD CONSTRAINT users_user_role_check + CHECK (user_role IN ('GUEST','MEMBER','ADMIN')); diff --git a/gdgoc/src/test/java/inha/gdgoc/GdgocApplicationTests.java b/gdgoc/src/test/java/inha/gdgoc/GdgocApplicationTests.java index 0c94ea2..9046d93 100644 --- a/gdgoc/src/test/java/inha/gdgoc/GdgocApplicationTests.java +++ b/gdgoc/src/test/java/inha/gdgoc/GdgocApplicationTests.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class GdgocApplicationTests { diff --git a/gdgoc/src/test/resources/application-test.yml b/gdgoc/src/test/resources/application-test.yml new file mode 100644 index 0000000..f9473ec --- /dev/null +++ b/gdgoc/src/test/resources/application-test.yml @@ -0,0 +1,72 @@ +server: + forward-headers-strategy: none + +spring: + jackson: + time-zone: Asia/Seoul + + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:gdgoc-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + + jpa: + database: h2 + hibernate: + ddl-auto: create-drop + properties: + hibernate: + default_batch_fetch_size: 100 + format_sql: true + show_sql: false + time_zone: Asia/Seoul + database-platform: org.hibernate.dialect.H2Dialect + show-sql: false + + flyway: + enabled: false + + mail: + host: localhost + port: 2525 + username: test + password: test + properties: + mail: + smtp: + auth: false + starttls: + enable: false + main: + allow-bean-definition-overriding: true + +logging: + level: + org.hibernate.SQL: warn + org.hibernate.type: warn + +google: + client-id: test-client-id + client-secret: test-client-secret + redirect-uri: http://localhost/redirect + +jwt: + googleIssuer: test-google-issuer + selfIssuer: test-self-issuer + secretKey: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + +cloud: + aws: + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + s3: + bucket: test-bucket