diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..794b49827 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI with Gradle + +on: + pull_request: + branches: [ "develop", "release", "master" ] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew build + + - name: Publish Test Report + uses: mikepenz/action-junit-report@v5 + if: success() || failure() + with: + report_paths: '**/build/test-results/test/TEST-*.xml' diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java index 2de0b7291..31228e5d3 100644 --- a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java @@ -9,20 +9,17 @@ import org.apache.tomcat.util.codec.binary.Base64; import org.springframework.stereotype.Component; -import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Date; -import java.util.stream.Collectors; import static com.example.solidconnection.custom.exception.ErrorCode.FAILED_TO_READ_APPLE_PRIVATE_KEY; /* - * 애플 OAuth 에 필요하 클라이언트 시크릿은 매번 동적으로 생성해야 한다. + * 애플 OAuth 에 필요한 클라이언트 시크릿은 매번 동적으로 생성해야 한다. * 클라이언트 시크릿은 애플 개발자 계정에서 발급받은 개인키(*.p8)를 사용하여 JWT 를 생성한다. * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret * */ @@ -32,14 +29,13 @@ public class AppleOAuthClientSecretProvider { private static final String KEY_ID_HEADER = "kid"; private static final long TOKEN_DURATION = 1000 * 60 * 10; // 10min - private static final String SECRET_KEY_PATH = "secret/AppleOAuthKey.p8"; private final AppleOAuthClientProperties appleOAuthClientProperties; private PrivateKey privateKey; @PostConstruct private void initPrivateKey() { - privateKey = readPrivateKey(); + privateKey = loadPrivateKey(); } public String generateClientSecret() { @@ -57,16 +53,14 @@ public String generateClientSecret() { .compact(); } - private PrivateKey readPrivateKey() { - try (InputStream is = getClass().getClassLoader().getResourceAsStream(SECRET_KEY_PATH); - BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { - - String secretKey = reader.lines().collect(Collectors.joining("\n")); + private PrivateKey loadPrivateKey() { + try { + String secretKey = appleOAuthClientProperties.secretKey(); byte[] encoded = Base64.decodeBase64(secretKey); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); KeyFactory keyFactory = KeyFactory.getInstance("EC"); return keyFactory.generatePrivate(keySpec); - } catch (Exception e) { + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { throw new CustomException(FAILED_TO_READ_APPLE_PRIVATE_KEY); } } diff --git a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java index 609e9ee89..c04908583 100644 --- a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java +++ b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java @@ -10,6 +10,7 @@ public record AppleOAuthClientProperties( String publicKeyUrl, String clientId, String teamId, - String keyId + String keyId, + String secretKey ) { } diff --git a/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java b/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java index 27493f1be..508953f88 100644 --- a/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java +++ b/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java @@ -9,9 +9,11 @@ import org.hibernate.annotations.DynamicUpdate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import java.time.ZoneId; import java.time.ZonedDateTime; +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoUnit.MICROS; + @MappedSuperclass @EntityListeners(AuditingEntityListener.class) @Getter @@ -24,12 +26,12 @@ public abstract class BaseEntity { @PrePersist public void onPrePersist() { - this.createdAt = ZonedDateTime.now(ZoneId.of("UTC")); + this.createdAt = ZonedDateTime.now(UTC).truncatedTo(MICROS); // 나노초 6자리 까지만 저장 this.updatedAt = this.createdAt; } @PreUpdate public void onPreUpdate() { - this.updatedAt = ZonedDateTime.now(ZoneId.of("UTC")); + this.updatedAt = ZonedDateTime.now(UTC).truncatedTo(MICROS); } } diff --git a/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java index d156cf485..ca3c64c7a 100644 --- a/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java +++ b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java @@ -8,7 +8,6 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.ActiveProfiles; import java.sql.DatabaseMetaData; import java.sql.SQLException; @@ -20,7 +19,6 @@ @Disabled @AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2, replace = AutoConfigureTestDatabase.Replace.ANY) -@ActiveProfiles("test") @DataJpaTest class DatabaseConnectionTest { diff --git a/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java b/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java index 69fcedaef..527ae7e07 100644 --- a/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java +++ b/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java @@ -6,12 +6,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; @Disabled -@ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class RedisConnectionTest { diff --git a/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java b/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java index 868eac179..03d130542 100644 --- a/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java +++ b/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java @@ -105,7 +105,7 @@ public void setUpUserAndToken() { ApplicationsResponse response = RestAssured.given().log().all() .header("Authorization", "Bearer " + accessToken) .when().log().all() - .get("/applications") + .get("/applications/competitors") .then().log().all() .statusCode(200) .extract().as(ApplicationsResponse.class); @@ -119,30 +119,24 @@ public void setUpUserAndToken() { List.of(ApplicantResponse.of(사용자1_지원정보, false))), UniversityApplicantsResponse.of(괌대학_B_지원_정보, List.of(ApplicantResponse.of(나의_지원정보, true))), - UniversityApplicantsResponse.of(메이지대학_지원_정보, - List.of(ApplicantResponse.of(사용자2_지원정보, false))), - UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, - List.of(ApplicantResponse.of(사용자3_지원정보, false))) + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, + List.of()) )); assertThat(secondChoiceApplicants).containsAnyElementsOf(List.of( UniversityApplicantsResponse.of(괌대학_A_지원_정보, - List.of(ApplicantResponse.of(나의_지원정보, true))), + List.of(ApplicantResponse.of(나의_지원정보, false))), UniversityApplicantsResponse.of(괌대학_B_지원_정보, - List.of(ApplicantResponse.of(사용자1_지원정보, false))), - UniversityApplicantsResponse.of(메이지대학_지원_정보, - List.of(ApplicantResponse.of(사용자3_지원정보, false))), - UniversityApplicantsResponse.of(그라츠대학_지원_정보, - List.of(ApplicantResponse.of(사용자2_지원정보, false))) + List.of(ApplicantResponse.of(사용자1_지원정보, true))), + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, + List.of()) )); assertThat(thirdChoiceApplicants).containsAnyElementsOf(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of()), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of()), UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, - List.of(ApplicantResponse.of(나의_지원정보, true))), - UniversityApplicantsResponse.of(서던덴마크대학교_지원_정보, - List.of(ApplicantResponse.of(사용자2_지원정보, false))), - UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, - List.of(ApplicantResponse.of(사용자1_지원정보, false))), - UniversityApplicantsResponse.of(메이지대학_지원_정보, - List.of(ApplicantResponse.of(사용자3_지원정보, false))) + List.of(ApplicantResponse.of(나의_지원정보, true))) )); } @@ -151,7 +145,7 @@ public void setUpUserAndToken() { ApplicationsResponse response = RestAssured.given().log().all() .header("Authorization", "Bearer " + accessToken) .when().log().all() - .get("/applications?region=" + 영미권.getCode()) + .get("/applications/competitors?region=" + 영미권.getCode()) .then().log().all() .statusCode(200) .extract().as(ApplicationsResponse.class); @@ -163,60 +157,14 @@ public void setUpUserAndToken() { UniversityApplicantsResponse.of(괌대학_A_지원_정보, List.of(ApplicantResponse.of(사용자1_지원정보, false))), UniversityApplicantsResponse.of(괌대학_B_지원_정보, - List.of(ApplicantResponse.of(나의_지원정보, true))), - UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, - List.of(ApplicantResponse.of(사용자3_지원정보, false))))); + List.of(ApplicantResponse.of(나의_지원정보, true))) + )); assertThat(secondChoiceApplicants).containsAnyElementsOf(List.of( UniversityApplicantsResponse.of(괌대학_A_지원_정보, List.of(ApplicantResponse.of(나의_지원정보, true))), UniversityApplicantsResponse.of(괌대학_B_지원_정보, - List.of(ApplicantResponse.of(사용자1_지원정보, false))))); - } - - @Test - void 대학_국문_이름으로_필터링해서_지원자를_조회한다() { - ApplicationsResponse response = RestAssured.given().log().all() - .header("Authorization", "Bearer " + accessToken) - .when().log().all() - .get("/applications?keyword=라") - .then().log().all() - .statusCode(200) - .extract().as(ApplicationsResponse.class); - - List firstChoiceApplicants = response.firstChoice(); - List secondChoiceApplicants = response.secondChoice(); - - assertThat(firstChoiceApplicants).containsExactlyInAnyOrder( - UniversityApplicantsResponse.of(그라츠대학_지원_정보, List.of()), - UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, List.of()), - UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, - List.of(ApplicantResponse.of(사용자3_지원정보, false)))); - assertThat(secondChoiceApplicants).containsAnyElementsOf(List.of( - UniversityApplicantsResponse.of(그라츠대학_지원_정보, - List.of(ApplicantResponse.of(사용자2_지원정보, false))), - UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, - List.of(ApplicantResponse.of(사용자3_지원정보, false))), - UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, List.of()))); - } - - @Test - void 국가_국문_이름으로_필터링해서_지원자를_조회한다() { - ApplicationsResponse response = RestAssured.given().log().all() - .header("Authorization", "Bearer " + accessToken) - .when().log().all() - .get("/applications?keyword=일본") - .then().log().all() - .statusCode(200) - .extract().as(ApplicationsResponse.class); - - List firstChoiceApplicants = response.firstChoice(); - List secondChoiceApplicants = response.secondChoice(); - - assertThat(firstChoiceApplicants).containsExactlyInAnyOrder( - UniversityApplicantsResponse.of(메이지대학_지원_정보, - List.of(ApplicantResponse.of(사용자2_지원정보, false)))); - assertThat(secondChoiceApplicants).containsExactlyInAnyOrder( - UniversityApplicantsResponse.of(메이지대학_지원_정보, List.of())); + List.of(ApplicantResponse.of(사용자1_지원정보, false))) + )); } @Test @@ -224,7 +172,7 @@ public void setUpUserAndToken() { ApplicationsResponse response = RestAssured.given().log().all() .header("Authorization", "Bearer " + accessToken) .when().log().all() - .get("/applications") + .get("/applications/competitors") .then().log().all() .statusCode(200) .extract().as(ApplicationsResponse.class); diff --git a/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java b/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java index bb77f82f2..aee6a2bc6 100644 --- a/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java +++ b/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java @@ -5,13 +5,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; -import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Objects; -@ActiveProfiles("test") @Component public class DatabaseCleaner { diff --git a/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java b/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java index 0256fec13..d4e25ccde 100644 --- a/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java +++ b/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java @@ -1,34 +1,24 @@ package com.example.solidconnection.support; -import jakarta.annotation.PostConstruct; -import org.springframework.boot.jdbc.DataSourceBuilder; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import javax.sql.DataSource; +public class MySQLTestContainer implements ApplicationContextInitializer { -@TestConfiguration -public class MySQLTestContainer { - - @Container private static final MySQLContainer CONTAINER = new MySQLContainer<>("mysql:8.0"); - @Bean - public DataSource dataSource() { - return DataSourceBuilder.create() - .url(CONTAINER.getJdbcUrl()) - .username(CONTAINER.getUsername()) - .password(CONTAINER.getPassword()) - .driverClassName(CONTAINER.getDriverClassName()) - .build(); + static { + CONTAINER.start(); } - @PostConstruct - void startContainer() { - if (!CONTAINER.isRunning()) { - CONTAINER.start(); - } + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + TestPropertyValues.of( + "spring.datasource.url=" + CONTAINER.getJdbcUrl(), + "spring.datasource.username=" + CONTAINER.getUsername(), + "spring.datasource.password=" + CONTAINER.getPassword() + ).applyTo(applicationContext.getEnvironment()); } } diff --git a/src/test/java/com/example/solidconnection/support/RedisTestContainer.java b/src/test/java/com/example/solidconnection/support/RedisTestContainer.java index 39f35c2d5..212499361 100644 --- a/src/test/java/com/example/solidconnection/support/RedisTestContainer.java +++ b/src/test/java/com/example/solidconnection/support/RedisTestContainer.java @@ -1,28 +1,25 @@ package com.example.solidconnection.support; -import jakarta.annotation.PostConstruct; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -@TestConfiguration -public class RedisTestContainer { +public class RedisTestContainer implements ApplicationContextInitializer { - @Container - private static final GenericContainer CONTAINER = new GenericContainer<>("redis:7.0"); + private static final int ORIGINAL_PORT = 6379; + private static final GenericContainer CONTAINER = new GenericContainer<>("redis:7.0") + .withExposedPorts(ORIGINAL_PORT); - @DynamicPropertySource - static void redisProperties(DynamicPropertyRegistry registry) { - registry.add("spring.redis.host", CONTAINER::getHost); - registry.add("spring.redis.port", CONTAINER::getFirstMappedPort); + static { + CONTAINER.start(); } - @PostConstruct - void startContainer() { - if (!CONTAINER.isRunning()) { - CONTAINER.start(); - } + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + TestPropertyValues.of( + "spring.data.redis.host=" + CONTAINER.getHost(), + "spring.data.redis.port=" + CONTAINER.getMappedPort(ORIGINAL_PORT) + ).applyTo(applicationContext.getEnvironment()); } } diff --git a/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java b/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java index 339672e60..415b21e78 100644 --- a/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java +++ b/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java @@ -2,8 +2,7 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; import org.testcontainers.junit.jupiter.Testcontainers; import java.lang.annotation.ElementType; @@ -13,9 +12,8 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@ActiveProfiles("test") @Testcontainers -@Import(MySQLTestContainer.class) +@ContextConfiguration(initializers = MySQLTestContainer.class) @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface TestContainerDataJpaTest { diff --git a/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java index fe9b74f60..5c5c93742 100644 --- a/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java +++ b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java @@ -3,8 +3,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; import org.testcontainers.junit.jupiter.Testcontainers; import java.lang.annotation.ElementType; @@ -13,11 +12,10 @@ import java.lang.annotation.Target; @ExtendWith({DatabaseClearExtension.class}) +@ContextConfiguration(initializers = {RedisTestContainer.class, MySQLTestContainer.class}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@ActiveProfiles("test") @Testcontainers -@Import({MySQLTestContainer.class, RedisTestContainer.class}) @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface TestContainerSpringBootTest { diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 000000000..7c6f83171 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,73 @@ +spring: + +# db + data: + redis: + host: localhost + port: 6379 + jpa: + hibernate: + ddl-auto: create + generate-ddl: true + show-sql: true + database: mysql + properties: + hibernate: + format_sql: true + flyway: + enabled: false + +# cloud +cloud: + aws: + credentials: + access-key: access-key + secret-key: access-key + region: + static: ap-northeast-2 + stack: + auto: false + s3: + bucket: solid-connection-uploaded + url: + default: default-url + uploaded: uploaded-url + cloudFront: + url: + default: default-url + uploaded: uploaded-url + +# variable +view: + count: + scheduling: + delay: 3000 +oauth: + apple: + token-url: "https://appleid.apple.com/auth/token" + client-secret-audience-url: "https://appleid.apple.com" + public-key-url: "https://appleid.apple.com/auth/keys" + client-id: client-id + team-id: team-id + key-id: key-id + redirect-url: "https://localhost:8080/auth/apple" + secret-key: MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCAfGIQ3TtNYAZG7i3m72odmdhfymkM9wAFg2rEL2RKUEA== # base64 encoded 된 임의의 값 +kakao: + redirect-url: "http://localhost:8080/auth/kakao" + client-id: client-id + token-url: "https://kauth.kakao.com/oauth/token" + user-info_url: "https://kapi.kakao.com/v2/user/me" +sentry: + environment: test + dsn: "https://test-public-key@sentry.test-domain.io/123456" + send-default-pii: true + traces-sample-rate: 1.0 + exception-resolver-order: -2147483647 +university: + term: 2024-1 +jwt: + secret: + 1234567-1234-1234-1234-12345678901 +cors: + allowed-origins: + - "http://localhost:8080"