diff --git a/build.gradle.kts b/build.gradle.kts index ae23f76..8dfb323 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("org.springframework.boot") version "3.4.2" id("io.spring.dependency-management") version "1.1.7" id("org.jetbrains.kotlin.plugin.jpa") version "1.9.25" + kotlin("kapt") version "1.9.25" } group = "com.ssak3" @@ -19,6 +20,8 @@ repositories { mavenCentral() } +val querydslVersion: String by project + dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-security") @@ -30,11 +33,17 @@ dependencies { implementation("org.springframework.cloud:spring-cloud-starter-openfeign:4.2.0") + + // QueryDSL 추가 + implementation("com.querydsl:querydsl-jpa:$querydslVersion:jakarta") + kapt("com.querydsl:querydsl-apt:$querydslVersion:jakarta") + implementation("io.jsonwebtoken:jjwt-api:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") runtimeOnly("com.mysql:mysql-connector-j") + runtimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..d442802 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +querydslVersion=5.0.0 \ No newline at end of file diff --git a/src/main/kotlin/com/ssak3/timeattack/common/config/QueryDslConfig.kt b/src/main/kotlin/com/ssak3/timeattack/common/config/QueryDslConfig.kt new file mode 100644 index 0000000..6af79ca --- /dev/null +++ b/src/main/kotlin/com/ssak3/timeattack/common/config/QueryDslConfig.kt @@ -0,0 +1,19 @@ +package com.ssak3.timeattack.common.config + +import com.querydsl.jpa.impl.JPAQueryFactory +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class QueryDslConfig { + + @PersistenceContext + lateinit var entityManager: EntityManager + + @Bean + fun jpaQueryFactory(): JPAQueryFactory { + return JPAQueryFactory(entityManager) + } +} diff --git a/src/main/kotlin/com/ssak3/timeattack/member/infrastructure/MemberCustomRepository.kt b/src/main/kotlin/com/ssak3/timeattack/member/infrastructure/MemberCustomRepository.kt new file mode 100644 index 0000000..e14ea59 --- /dev/null +++ b/src/main/kotlin/com/ssak3/timeattack/member/infrastructure/MemberCustomRepository.kt @@ -0,0 +1,10 @@ +package com.ssak3.timeattack.member.infrastructure + +import com.ssak3.timeattack.member.domain.Member +import com.ssak3.timeattack.member.domain.OAuthProvider + +interface MemberCustomRepository { + fun findByProviderAndSubject(oauthProvider: OAuthProvider, subject: String): Member? + + fun findByIdOrThrow(id: Long): Member +} diff --git a/src/main/kotlin/com/ssak3/timeattack/member/infrastructure/MemberCustomRepositoryImpl.kt b/src/main/kotlin/com/ssak3/timeattack/member/infrastructure/MemberCustomRepositoryImpl.kt new file mode 100644 index 0000000..03a192b --- /dev/null +++ b/src/main/kotlin/com/ssak3/timeattack/member/infrastructure/MemberCustomRepositoryImpl.kt @@ -0,0 +1,37 @@ +package com.ssak3.timeattack.member.infrastructure + +import com.querydsl.jpa.impl.JPAQueryFactory +import com.ssak3.timeattack.global.exception.ApplicationException +import com.ssak3.timeattack.global.exception.ApplicationExceptionType +import com.ssak3.timeattack.member.domain.Member +import com.ssak3.timeattack.member.domain.OAuthProvider +import com.ssak3.timeattack.member.domain.QMember +import org.springframework.stereotype.Repository + +@Repository +class MemberCustomRepositoryImpl( + private val queryFactory: JPAQueryFactory, +) : MemberCustomRepository { + + override fun findByProviderAndSubject(oauthProvider: OAuthProvider, subject: String): Member? { + val qMember = QMember.member + return queryFactory + .select(qMember) + .from(qMember) + .where( + qMember.oAuthProviderInfo.oauthProvider.eq(oauthProvider), + qMember.oAuthProviderInfo.subject.eq(subject) + ) + .fetchOne() + } + + override fun findByIdOrThrow(id: Long): Member { + val qMember = QMember.member + return queryFactory + .select(qMember) + .from(qMember) + .where(qMember.id.eq(id)) + .fetchOne() + ?: throw ApplicationException(ApplicationExceptionType.MEMBER_NOT_FOUND_BY_ID) + } +} diff --git a/src/main/kotlin/com/ssak3/timeattack/member/infrastructure/MemberRepository.kt b/src/main/kotlin/com/ssak3/timeattack/member/infrastructure/MemberRepository.kt new file mode 100644 index 0000000..7435ed3 --- /dev/null +++ b/src/main/kotlin/com/ssak3/timeattack/member/infrastructure/MemberRepository.kt @@ -0,0 +1,6 @@ +package com.ssak3.timeattack.member.infrastructure + +import com.ssak3.timeattack.member.domain.Member +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberRepository : JpaRepository, MemberCustomRepository diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..7ce3647 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,21 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: 'jdbc:h2:mem:timeattack' + username: 'user' + password: '' + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true + h2: + console: + enabled: true + path: '/h2-console' + + data: + redis: + port: 6379 diff --git a/src/test/kotlin/com/ssak3/timeattack/member/infrastructure/MemberRepositoryTest.kt b/src/test/kotlin/com/ssak3/timeattack/member/infrastructure/MemberRepositoryTest.kt new file mode 100644 index 0000000..2f563c0 --- /dev/null +++ b/src/test/kotlin/com/ssak3/timeattack/member/infrastructure/MemberRepositoryTest.kt @@ -0,0 +1,71 @@ +package com.ssak3.timeattack.member.infrastructure + +import com.ssak3.timeattack.common.config.QueryDslConfig +import com.ssak3.timeattack.member.domain.Member +import com.ssak3.timeattack.member.domain.OAuthProvider +import com.ssak3.timeattack.member.domain.OAuthProviderInfo +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +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.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles + +@DataJpaTest +@Import(QueryDslConfig::class) +@ActiveProfiles("test") +class MemberRepositoryTest @Autowired constructor( + private val memberRepository: MemberRepository +) { + private lateinit var member: Member + + @BeforeEach + fun setMember() { + // given + val provider = OAuthProvider.KAKAO + val subject = "1234567890" + val nickname = "testUser" + member = Member( + nickname = nickname, + email = "test@test.com", + profileImageUrl = "https://test.com", + oAuthProviderInfo = OAuthProviderInfo(oauthProvider = provider, subject = subject), + ) + } + + @Test + @DisplayName("주어진 subject, provider와 일치하는 유저가 존재하면 해당 유저 반환") + fun test_findByProviderAndSubject_ShouldReturnExpectedMember() { + // given + // saveAndFlush() << 즉시 DB에 반영 -> 조회 시 영속성 컨텍스트 캐시가 아닌 실제 DB 상태를 보장하기 위해서 + memberRepository.saveAndFlush(member) + + // when + val findMember = memberRepository.findByProviderAndSubject( + member.oAuthProviderInfo.oauthProvider, + member.oAuthProviderInfo.subject + ) + + // then + assertThat(findMember).isNotNull + assertThat(findMember?.nickname).isEqualTo(member.nickname) + } + + @Test + @DisplayName("주어진 subject, provider와 일치하는 유저가 없으면 null 반환") + fun test_findByProviderAndSubject_ShouldReturnNull() { + // given + memberRepository.saveAndFlush(member) + + // when + val findMember = memberRepository.findByProviderAndSubject( + member.oAuthProviderInfo.oauthProvider, + "different subject" + ) + + // then + assertThat(findMember).isNull() + } +}