diff --git a/.deploy/Dockerfile b/.deploy/Dockerfile index 0d5c38aa..a2c1510c 100644 --- a/.deploy/Dockerfile +++ b/.deploy/Dockerfile @@ -1,6 +1,6 @@ FROM amazoncorretto:17-alpine-jdk -ARG TARGET_JAR=/app/build/libs/Bootstrap-Module.jar +ARG TARGET_JAR=/Bootstrap-Module/build/libs/Bootstrap-Module.jar COPY ${TARGET_JAR} /application.jar diff --git a/Application-Module/README.md b/Application-Module/README.md new file mode 100644 index 00000000..ffca6969 --- /dev/null +++ b/Application-Module/README.md @@ -0,0 +1,27 @@ +# Application 모듈 + +## 역할 + +* `Lettering` 서비스의 핵심 비즈니스 로직을 처리한다. + + +## 패키지 구조 + +```markdown +. +└── {domain}/ + ├── port/ + │ ├── in + │ └── out + ├── service + ├── vo + └── exception +``` + +* `{domain}`: 도메인 이름을 의미합니다. 예를 들어, `user` 등이 될 수 있습니다. +* `port`: 외부와의 통신을 위한 인터페이스를 정의합니다. + * `in`: 외부에서 들어오는 요청을 처리하는 인터페이스를 정의합니다. + * `out`: 외부로 나가는 응답을 처리하는 인터페이스를 정의합니다. +* `service`: 비즈니스 로직을 처리하는 구현체입니다. +* `exception`: 비즈니스 로직에서 발생하는 예외를 정의합니다. +* `vo`: 비즈니스 로직에서 사용되는 값 객체를 정의합니다. \ No newline at end of file diff --git a/Application-Module/build.gradle.kts b/Application-Module/build.gradle.kts new file mode 100644 index 00000000..7d3eb078 --- /dev/null +++ b/Application-Module/build.gradle.kts @@ -0,0 +1,4 @@ +dependencies{ + implementation(project(":Domain-Module")) + implementation(project(":Common-Module")) +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/ApplicationConfig.kt b/Application-Module/src/main/kotlin/com/asap/application/ApplicationConfig.kt new file mode 100644 index 00000000..918e40ac --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/ApplicationConfig.kt @@ -0,0 +1,10 @@ +package com.asap.application + +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration + + +@Configuration +@ComponentScan("com.asap.application") +class ApplicationConfig { +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/exception/UserException.kt b/Application-Module/src/main/kotlin/com/asap/application/user/exception/UserException.kt new file mode 100644 index 00000000..6fa45378 --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/exception/UserException.kt @@ -0,0 +1,33 @@ +package com.asap.application.user.exception + +import com.asap.common.exception.BusinessException + +sealed class UserException( + codePrefix: String = CODE_PREFIX, + errorCode: Int, + httpStatus: Int = 400, + message: String = DEFAULT_ERROR_MESSAGE +): BusinessException(codePrefix, errorCode, httpStatus, message) { + + class UserAlreadyRegisteredException: UserException( + errorCode = 1, + message = "이미 가입된 사용자입니다." + ) + + + class UserAuthNotFoundException: UserException( + errorCode = 2, + message = "사용자 인증 정보를 찾을 수 없습니다." + ) + + class UserNotFoundException: UserException( + errorCode = 3, + message = "사용자를 찾을 수 없습니다." + ) + + + companion object{ + const val CODE_PREFIX = "USER" + const val DEFAULT_ERROR_MESSAGE = "사용자와 관련된 예외가 발생했습니다." + } +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/in/RegisterUserUsecase.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/in/RegisterUserUsecase.kt new file mode 100644 index 00000000..5a02e5db --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/in/RegisterUserUsecase.kt @@ -0,0 +1,22 @@ +package com.asap.application.user.port.`in` + +import java.time.LocalDate + +interface RegisterUserUsecase { + + fun registerUser(command: Command): Response + + + data class Command( + val registerToken: String, + val servicePermission: Boolean, + val privatePermission: Boolean, + val marketingPermission: Boolean, + val birthday: LocalDate? + ) + + data class Response( + val accessToken: String, + val refreshToken: String + ) +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/in/SocialLoginUsecase.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/in/SocialLoginUsecase.kt new file mode 100644 index 00000000..ecc65ee0 --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/in/SocialLoginUsecase.kt @@ -0,0 +1,22 @@ +package com.asap.application.user.port.`in` + +interface SocialLoginUsecase { + + fun login(command: Command): Response + + data class Command( + val provider: String, + val accessToken: String, + ) + + sealed class Response { + } + data class Success( + val accessToken: String, + val refreshToken: String + ) : Response() + + data class NonRegistered( + val registerToken: String + ) : Response() +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/AuthInfoRetrievePort.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/AuthInfoRetrievePort.kt new file mode 100644 index 00000000..98255536 --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/AuthInfoRetrievePort.kt @@ -0,0 +1,11 @@ +package com.asap.application.user.port.out + +import com.asap.application.user.exception.UserException +import com.asap.application.user.vo.AuthInfo +import com.asap.domain.user.enums.SocialLoginProvider + +interface AuthInfoRetrievePort { + + @Throws(UserException.UserAuthNotFoundException::class) + fun getAuthInfo(provider: SocialLoginProvider, accessToken: String): AuthInfo +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserAuthManagementPort.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserAuthManagementPort.kt new file mode 100644 index 00000000..4cee2d32 --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserAuthManagementPort.kt @@ -0,0 +1,24 @@ +package com.asap.application.user.port.out + +import com.asap.domain.user.entity.UserAuth +import com.asap.domain.user.enums.SocialLoginProvider + +interface UserAuthManagementPort { + + fun getUserAuth( + socialId: String, + socialLoginProvider: SocialLoginProvider + ): UserAuth? + + + fun isExistsUserAuth( + socialId: String, + socialLoginProvider: SocialLoginProvider + ): Boolean + + + fun saveUserAuth( + userAuth: UserAuth + ): UserAuth + +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserManagementPort.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserManagementPort.kt new file mode 100644 index 00000000..c0f876ae --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserManagementPort.kt @@ -0,0 +1,10 @@ +package com.asap.application.user.port.out + +import com.asap.domain.common.DomainId +import com.asap.domain.user.entity.User + +interface UserManagementPort { + fun saveUser(user: User): User + + fun getUser(userId: DomainId): User? +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserTokenManagementPort.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserTokenManagementPort.kt new file mode 100644 index 00000000..f32c57fa --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserTokenManagementPort.kt @@ -0,0 +1,18 @@ +package com.asap.application.user.port.out + +import com.asap.application.user.vo.UserClaims +import com.asap.domain.user.entity.User + +interface UserTokenManagementPort { + fun resolveRegisterToken(token: String): UserClaims.Register + + fun generateRegisterToken( + socialId: String, + socialLoginProvider: String, + username: String + ): String + + fun generateAccessToken(user: User): String + + fun generateRefreshToken(user: User): String +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryAuthInfoRetrieveAdapter.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryAuthInfoRetrieveAdapter.kt new file mode 100644 index 00000000..205c1b9d --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryAuthInfoRetrieveAdapter.kt @@ -0,0 +1,22 @@ +package com.asap.application.user.port.out.memory + +import com.asap.application.user.exception.UserException +import com.asap.application.user.port.out.AuthInfoRetrievePort +import com.asap.application.user.vo.AuthInfo +import com.asap.domain.user.enums.SocialLoginProvider +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Component + +@Component +@Primary +class MemoryAuthInfoRetrieveAdapter: AuthInfoRetrievePort { + + private val authInfos = mutableMapOf, AuthInfo>().apply { + put(Pair("registered", SocialLoginProvider.KAKAO), AuthInfo(SocialLoginProvider.KAKAO, "socialId", "username")) + put(Pair("nonRegistered", SocialLoginProvider.KAKAO), AuthInfo(SocialLoginProvider.KAKAO, "nonRegisteredId", "username")) + } + + override fun getAuthInfo(provider: SocialLoginProvider, accessToken: String): AuthInfo { + return authInfos[Pair(accessToken, provider)] ?: throw UserException.UserAuthNotFoundException() + } +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryAuthManagementAdapter.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryAuthManagementAdapter.kt new file mode 100644 index 00000000..d760c433 --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryAuthManagementAdapter.kt @@ -0,0 +1,30 @@ +package com.asap.application.user.port.out.memory + +import com.asap.application.user.port.out.UserAuthManagementPort +import com.asap.domain.common.DomainId +import com.asap.domain.user.entity.UserAuth +import com.asap.domain.user.enums.SocialLoginProvider +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Component + +@Component +@Primary +class MemoryAuthManagementAdapter: UserAuthManagementPort{ + + private val userAuths = mutableMapOf, UserAuth>().apply { + put(Pair("socialId", SocialLoginProvider.KAKAO), UserAuth(userId = DomainId("registered"), socialId = "socialId", socialLoginProvider = SocialLoginProvider.KAKAO)) + } + + override fun getUserAuth(socialId: String, socialLoginProvider: SocialLoginProvider): UserAuth? { + return userAuths[Pair(socialId, socialLoginProvider)] + } + + override fun isExistsUserAuth(socialId: String, socialLoginProvider: SocialLoginProvider): Boolean { + return socialId == "duplicate" + } + + override fun saveUserAuth(userAuth: UserAuth): UserAuth { + userAuths[Pair(userAuth.socialId, userAuth.socialLoginProvider)] = userAuth + return userAuth + } +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserManagementAdapter.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserManagementAdapter.kt new file mode 100644 index 00000000..e1f55df6 --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserManagementAdapter.kt @@ -0,0 +1,25 @@ +package com.asap.application.user.port.out.memory + +import com.asap.application.user.port.out.UserManagementPort +import com.asap.domain.common.DomainId +import com.asap.domain.user.entity.User +import com.asap.domain.user.vo.UserPermission +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Component + +@Component +@Primary +class MemoryUserManagementAdapter: UserManagementPort { + private val users = mutableMapOf().apply { + put(DomainId("registered"), User(DomainId("registered"), "username", UserPermission(true,true,true))) + } + + override fun saveUser(user: User): User { + users[user.id] = user + return user + } + + override fun getUser(userId: DomainId): User? { + return users[userId] + } +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserTokenManagementAdapter.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserTokenManagementAdapter.kt new file mode 100644 index 00000000..0bfde7c7 --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserTokenManagementAdapter.kt @@ -0,0 +1,43 @@ +package com.asap.application.user.port.out.memory + +import com.asap.application.user.port.out.UserTokenManagementPort +import com.asap.application.user.vo.UserClaims +import com.asap.common.exception.DefaultException +import com.asap.domain.user.entity.User +import com.asap.domain.user.enums.SocialLoginProvider +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Component + +@Component +@Primary +class MemoryUserTokenManagementAdapter( + +) : UserTokenManagementPort{ + override fun resolveRegisterToken(token: String): UserClaims.Register { + return when(token){ + "valid" -> UserClaims.Register( + socialId = "123", + socialLoginProvider = SocialLoginProvider.KAKAO, + username = "test" + ) + "duplicate" -> UserClaims.Register( + socialId = "duplicate", + socialLoginProvider = SocialLoginProvider.KAKAO, + username = "test" + ) + else -> throw DefaultException.InvalidArgumentException() // TODO: jwt 구현할 때 수정 + } + } + + override fun generateRegisterToken(socialId: String, socialLoginProvider: String, username: String): String { + return "registerToken" + } + + override fun generateAccessToken(user: User): String { + return "accessToken" + } + + override fun generateRefreshToken(user: User): String { + return "refreshToken" + } +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/service/RegisterUserService.kt b/Application-Module/src/main/kotlin/com/asap/application/user/service/RegisterUserService.kt new file mode 100644 index 00000000..350c8c72 --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/service/RegisterUserService.kt @@ -0,0 +1,54 @@ +package com.asap.application.user.service + +import com.asap.application.user.exception.UserException +import com.asap.application.user.port.`in`.RegisterUserUsecase +import com.asap.application.user.port.out.UserAuthManagementPort +import com.asap.application.user.port.out.UserManagementPort +import com.asap.application.user.port.out.UserTokenManagementPort +import com.asap.domain.user.entity.User +import com.asap.domain.user.entity.UserAuth +import com.asap.domain.user.vo.UserPermission +import org.springframework.stereotype.Service + +@Service +class RegisterUserService( + private val userTokenManagementPort: UserTokenManagementPort, + private val userAuthManagementPort: UserAuthManagementPort, + private val userManagementPort: UserManagementPort +) : RegisterUserUsecase { + + /** + * 1. register token으로부터 사용자 정보 추출 -> 토큰이 이미 사용됐으면 에러 + * 2. 추출한 사용자가 이미 존재하는지 확인 -> 이미 존재하면 에러 + * 3. 추출한 사용자 정보와 함께 사용자 동의 검증 -> 동의하지 않으면 에러 + * 4. 사용자 정보 저장 및 jwt 토큰 반환 + */ + + override fun registerUser(command: RegisterUserUsecase.Command): RegisterUserUsecase.Response { + val userClaims = userTokenManagementPort.resolveRegisterToken(command.registerToken) + if (userAuthManagementPort.isExistsUserAuth(userClaims.socialId, userClaims.socialLoginProvider)) { + throw UserException.UserAlreadyRegisteredException() + } + val registerUser = User( + nickname = userClaims.username, + permission = UserPermission( + command.servicePermission, + command.privatePermission, + command.marketingPermission + ) + ) + val userAuth = UserAuth( + userId = registerUser.id, + socialId = userClaims.socialId, + socialLoginProvider = userClaims.socialLoginProvider + ) + + userManagementPort.saveUser(registerUser) + userAuthManagementPort.saveUserAuth(userAuth) + + return RegisterUserUsecase.Response( + userTokenManagementPort.generateAccessToken(registerUser), + userTokenManagementPort.generateRefreshToken(registerUser) + ) + } +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/service/SocialLoginService.kt b/Application-Module/src/main/kotlin/com/asap/application/user/service/SocialLoginService.kt new file mode 100644 index 00000000..7e03800a --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/service/SocialLoginService.kt @@ -0,0 +1,44 @@ +package com.asap.application.user.service + +import com.asap.application.user.port.`in`.SocialLoginUsecase +import com.asap.application.user.port.out.AuthInfoRetrievePort +import com.asap.application.user.port.out.UserAuthManagementPort +import com.asap.application.user.port.out.UserManagementPort +import com.asap.application.user.port.out.UserTokenManagementPort +import com.asap.common.exception.DefaultException +import com.asap.domain.user.enums.SocialLoginProvider +import org.springframework.stereotype.Service + + +@Service +class SocialLoginService( + private val userAuthManagementPort: UserAuthManagementPort, + private val authInfoRetrievePort: AuthInfoRetrievePort, + private val userTokenManagementPort: UserTokenManagementPort, + private val userManagementPort: UserManagementPort +) : SocialLoginUsecase { + + override fun login(command: SocialLoginUsecase.Command): SocialLoginUsecase.Response { + val authInfo = + authInfoRetrievePort.getAuthInfo(SocialLoginProvider.parse(command.provider), command.accessToken) + val userAuth = userAuthManagementPort.getUserAuth(authInfo.socialId, authInfo.socialLoginProvider) + return userAuth?.let { + userManagementPort.getUser(userAuth.userId)?.let { + SocialLoginUsecase.Success( + userTokenManagementPort.generateAccessToken(it), + userTokenManagementPort.generateRefreshToken(it) + ) + } ?: run { + throw DefaultException.InvalidStateException("사용자 인증정보만 존재합니다. - ${userAuth.userId}") + } + } ?: run { + val registerToken = userTokenManagementPort.generateRegisterToken( + authInfo.socialId, + authInfo.socialLoginProvider.name, + authInfo.username + ) + SocialLoginUsecase.NonRegistered(registerToken) + } + } + +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/vo/AuthInfo.kt b/Application-Module/src/main/kotlin/com/asap/application/user/vo/AuthInfo.kt new file mode 100644 index 00000000..3beb0904 --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/vo/AuthInfo.kt @@ -0,0 +1,10 @@ +package com.asap.application.user.vo + +import com.asap.domain.user.enums.SocialLoginProvider + +data class AuthInfo( + val socialLoginProvider: SocialLoginProvider, + val socialId: String, + val username: String +) { +} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/vo/UserClaims.kt b/Application-Module/src/main/kotlin/com/asap/application/user/vo/UserClaims.kt new file mode 100644 index 00000000..c9c348fd --- /dev/null +++ b/Application-Module/src/main/kotlin/com/asap/application/user/vo/UserClaims.kt @@ -0,0 +1,13 @@ +package com.asap.application.user.vo + +import com.asap.domain.user.enums.SocialLoginProvider + +class UserClaims { + + data class Register( + val socialId: String, + val socialLoginProvider: SocialLoginProvider, + val username: String + ) + +} \ No newline at end of file diff --git a/Application-Module/src/test/kotlin/com/asap/application/user/service/RegisterUserServiceTest.kt b/Application-Module/src/test/kotlin/com/asap/application/user/service/RegisterUserServiceTest.kt new file mode 100644 index 00000000..e6a67580 --- /dev/null +++ b/Application-Module/src/test/kotlin/com/asap/application/user/service/RegisterUserServiceTest.kt @@ -0,0 +1,103 @@ +package com.asap.application.user.service + +import com.asap.application.user.exception.UserException +import com.asap.application.user.port.`in`.RegisterUserUsecase +import com.asap.application.user.port.out.UserAuthManagementPort +import com.asap.application.user.port.out.UserManagementPort +import com.asap.application.user.port.out.UserTokenManagementPort +import com.asap.application.user.vo.UserClaims +import com.asap.common.exception.DefaultException +import com.asap.domain.user.enums.SocialLoginProvider +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDate + + +class RegisterUserServiceTest: BehaviorSpec({ + + val mockUserManagementPort = mockk(relaxed = true) + val mockUserAuthManagementPort = mockk(relaxed=true) + val mockUserTokenManagementPort = mockk() + + + val registerUserService = RegisterUserService(mockUserTokenManagementPort, mockUserAuthManagementPort, mockUserManagementPort) + + + given("회원 가입 요청이 들어왔을 때") { + val successCommand = RegisterUserUsecase.Command("valid", true, true, true, LocalDate.now()) + every { mockUserTokenManagementPort.resolveRegisterToken("valid") } returns UserClaims.Register( + socialId = "123", + socialLoginProvider = SocialLoginProvider.KAKAO, + username = "test" + ) + every { mockUserAuthManagementPort.isExistsUserAuth("123", SocialLoginProvider.KAKAO) } returns false + every { mockUserTokenManagementPort.generateAccessToken(any()) } returns "accessToken" + every { mockUserTokenManagementPort.generateRefreshToken(any()) } returns "refreshToken" + `when`("회원 가입이 성공하면") { + val response = registerUserService.registerUser(successCommand) + then("access token과 refresh token을 반환한다.") { + response.accessToken.isNotEmpty() shouldBe true + response.refreshToken.isNotEmpty() shouldBe true + verify { mockUserManagementPort.saveUser(any()) } + } + } + + + every { mockUserTokenManagementPort.resolveRegisterToken("duplicate") } returns UserClaims.Register( + socialId = "duplicate", + socialLoginProvider = SocialLoginProvider.KAKAO, + username = "test" + ) + every { mockUserAuthManagementPort.isExistsUserAuth("duplicate", SocialLoginProvider.KAKAO) } returns true + `when`("중복 가입 요청이 들어왔을 때") { + val failCommand = RegisterUserUsecase.Command("duplicate", true, true, true, LocalDate.now()) + then("UserAlreadyRegisteredException 예외가 발생한다.") { + shouldThrow { + registerUserService.registerUser(failCommand) + } + } + } + + every { mockUserTokenManagementPort.resolveRegisterToken("invalid") } throws IllegalArgumentException("Invalid token") + `when`("register token이 유요하지 않다면") { + val failCommandWithoutRegisterToken = + RegisterUserUsecase.Command("invalid", true, true, true, LocalDate.now()) + then("예외가 발생한다.") { + shouldThrow { + registerUserService.registerUser(failCommandWithoutRegisterToken) + } + } + } + + + every { mockUserTokenManagementPort.resolveRegisterToken("valid") } returns UserClaims.Register( + socialId = "123", + socialLoginProvider = SocialLoginProvider.KAKAO, + username = "test" + ) + every{ mockUserAuthManagementPort.isExistsUserAuth("123", SocialLoginProvider.KAKAO) } returns false + `when`("서비스 동의를 하지 않았다면") { + val failCommandWithoutServicePermission = RegisterUserUsecase.Command("valid", false, true, true, LocalDate.now()) + then("InvalidPropertyException 예외가 발생한다.") { + shouldThrow { + registerUserService.registerUser(failCommandWithoutServicePermission) + } + } + } + + `when`("개인정보 동의를 하지 않았다면") { + val failCommandWithoutPrivatePermission = RegisterUserUsecase.Command("valid", true, false, true, LocalDate.now()) + then("InvalidPropertyException 예외가 발생한다.") { + shouldThrow { + registerUserService.registerUser(failCommandWithoutPrivatePermission) + } + } + } + } + +}) { +} \ No newline at end of file diff --git a/Application-Module/src/test/kotlin/com/asap/application/user/service/SocialLoginServiceTest.kt b/Application-Module/src/test/kotlin/com/asap/application/user/service/SocialLoginServiceTest.kt new file mode 100644 index 00000000..4b6ea76f --- /dev/null +++ b/Application-Module/src/test/kotlin/com/asap/application/user/service/SocialLoginServiceTest.kt @@ -0,0 +1,90 @@ +package com.asap.application.user.service + +import com.asap.application.user.port.`in`.SocialLoginUsecase +import com.asap.application.user.port.out.AuthInfoRetrievePort +import com.asap.application.user.port.out.UserAuthManagementPort +import com.asap.application.user.port.out.UserManagementPort +import com.asap.application.user.port.out.UserTokenManagementPort +import com.asap.application.user.vo.AuthInfo +import com.asap.common.exception.DefaultException +import com.asap.domain.common.DomainId +import com.asap.domain.user.entity.User +import com.asap.domain.user.entity.UserAuth +import com.asap.domain.user.enums.SocialLoginProvider +import com.asap.domain.user.vo.UserPermission +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk + +class SocialLoginServiceTest : BehaviorSpec({ + + val mockUserAuthManagementPort = mockk() + val mockAuthInfoRetrievePort = mockk() + val mockUserManagementPort = mockk() + val mockUserTokenManagementPort = mockk() + + val socialLoginService = SocialLoginService( + mockUserAuthManagementPort, + mockAuthInfoRetrievePort, + mockUserTokenManagementPort, + mockUserManagementPort + ) + + given("소셜 로그인에서 요청한 사용자가") { + var command = SocialLoginUsecase.Command(SocialLoginProvider.KAKAO.name, "registered") + val authInfo = AuthInfo(SocialLoginProvider.KAKAO, "socialId", "name") + val getUserAuth = UserAuth( + userId = DomainId.generate(), + socialId = "socialId", + socialLoginProvider = SocialLoginProvider.KAKAO + ) + val getUser = User( + id = getUserAuth.userId, + nickname = authInfo.username, + permission = UserPermission(true, true, true) + ) + every { mockAuthInfoRetrievePort.getAuthInfo(SocialLoginProvider.KAKAO, "registered") } returns authInfo + every { + mockUserAuthManagementPort.getUserAuth( + authInfo.socialId, + authInfo.socialLoginProvider + ) + } returns getUserAuth + every{ mockUserManagementPort.getUser(any()) } returns getUser + every { mockUserTokenManagementPort.generateAccessToken(getUser) } returns "accessToken" + every { mockUserTokenManagementPort.generateRefreshToken(getUser) } returns "refreshToken" + `when`("기존에 존재한다면") { + val response = socialLoginService.login(command) + then("access token과 refresh token을 반환하는 success 인스턴스를 반환한다.") { + response.shouldBeInstanceOf() + response.accessToken.isNotEmpty() shouldBe true + response.refreshToken.isNotEmpty() shouldBe true + } + } + + every { mockUserManagementPort.getUser(any()) } returns null + `when`("인증 정보만 존재하면 존재하고 사용자 정보가 없다면") { + + then("InvalidStateException 예외가 발생한다.") { + shouldThrow { + socialLoginService.login(command) + } + } + } + + command = SocialLoginUsecase.Command(SocialLoginProvider.KAKAO.name, "nonRegistered") + every { mockAuthInfoRetrievePort.getAuthInfo(SocialLoginProvider.KAKAO, "nonRegistered") } returns authInfo + every { mockUserAuthManagementPort.getUserAuth(authInfo.socialId, authInfo.socialLoginProvider) } returns null + every { mockUserTokenManagementPort.generateRegisterToken(authInfo.socialId, authInfo.socialLoginProvider.name, authInfo.username) } returns "registerToken" + `when`("가입되지 않았다면") { + val response = socialLoginService.login(command) + then("register token을 반환하는 nonRegistered 인스턴스를 반환한다.") { + response.shouldBeInstanceOf() + response.registerToken.isNotEmpty() shouldBe true + } + } + } +}) \ No newline at end of file diff --git a/Bootstrap-Module/README.md b/Bootstrap-Module/README.md index a166f62f..a9de4fa9 100644 --- a/Bootstrap-Module/README.md +++ b/Bootstrap-Module/README.md @@ -9,13 +9,16 @@ ```markdown . -└── {domain}/ - ├── api - ├── controller - └── dto +├── {domain}/ +│ ├── api +│ ├── controller +│ └── dto +└── common ``` -* `{domain}`: 도메인 이름을 의미합니다. 예를 들어, `auth`, `user` 등이 될 수 있습니다. +* `{domain}`: 도메인 이름을 의미합니다. 예를 들어, `auth`, `user` 등이 될 수 있습니다. * `api`: API 스팩을 정의합니다. * `controller`: api 스팩에 대한 구현체입니다. * `dto`: 요청간 전달되는 데이터를 정의합니다. +* `common`: 공통으로 사용되는 클래스를 정의합니다. + diff --git a/Bootstrap-Module/build.gradle.kts b/Bootstrap-Module/build.gradle.kts index 1e30d5c3..65da8c9d 100644 --- a/Bootstrap-Module/build.gradle.kts +++ b/Bootstrap-Module/build.gradle.kts @@ -2,4 +2,8 @@ dependencies{ implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0") + + + implementation(project(":Application-Module")) + implementation(project(":Common-Module")) } \ No newline at end of file diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/AppApplication.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/AppApplication.kt deleted file mode 100644 index a358180e..00000000 --- a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/AppApplication.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.asap.bootstrap - -import org.springframework.boot.SpringApplication -import org.springframework.boot.autoconfigure.SpringBootApplication - -@SpringBootApplication -class AppApplication { - -} - -fun main(args: Array) { - SpringApplication.run(AppApplication::class.java, *args) -} diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/BootstrapApplication.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/BootstrapApplication.kt new file mode 100644 index 00000000..d31feca4 --- /dev/null +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/BootstrapApplication.kt @@ -0,0 +1,18 @@ +package com.asap.bootstrap + +import com.asap.application.ApplicationConfig +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.Import + +@SpringBootApplication +@Import( + value = [ + ApplicationConfig::class, + ] +) +class BootstrapApplication {} + +fun main(args: Array) { + SpringApplication.run(BootstrapApplication::class.java, *args) +} \ No newline at end of file diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/auth/controller/AuthController.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/auth/controller/AuthController.kt index f39430b9..bc34455b 100644 --- a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/auth/controller/AuthController.kt +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/auth/controller/AuthController.kt @@ -1,5 +1,6 @@ package com.asap.bootstrap.auth.controller +import com.asap.application.user.port.`in`.SocialLoginUsecase import com.asap.bootstrap.auth.api.AuthApi import com.asap.bootstrap.auth.dto.SocialLoginRequest import com.asap.bootstrap.auth.dto.SocialLoginResponse @@ -9,20 +10,27 @@ import org.springframework.web.bind.annotation.RestController @RestController class AuthController( + private val socialLoginUsecase: SocialLoginUsecase ) : AuthApi { override fun socialLogin( provider: String, request: SocialLoginRequest ): ResponseEntity { - when (request.accessToken) { - "nonRegistered" -> return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(SocialLoginResponse.NonRegistered("registerToken")) + val command = SocialLoginUsecase.Command( + provider = provider, + accessToken = request.accessToken + ) + return when (val response = socialLoginUsecase.login(command)) { + is SocialLoginUsecase.Success -> ResponseEntity.ok( + SocialLoginResponse.Success( + response.accessToken, + response.refreshToken + ) + ) - "registered" -> return ResponseEntity - .ok(SocialLoginResponse.Success("accessToken", "refreshToken")) - - else -> return ResponseEntity.badRequest().build() + is SocialLoginUsecase.NonRegistered -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(SocialLoginResponse.NonRegistered(response.registerToken)) } } } \ No newline at end of file diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/ExceptionResponse.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/ExceptionResponse.kt new file mode 100644 index 00000000..5da3bce4 --- /dev/null +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/ExceptionResponse.kt @@ -0,0 +1,16 @@ +package com.asap.bootstrap.common + +import com.asap.common.exception.BusinessException + + +data class ExceptionResponse( + val message: String, + val code: String +) { + + companion object{ + fun of(businessException: BusinessException): ExceptionResponse{ + return ExceptionResponse(businessException.message, businessException.code) + } + } +} \ No newline at end of file diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/GlobalExceptionHandler.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/GlobalExceptionHandler.kt new file mode 100644 index 00000000..02bf6cf2 --- /dev/null +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/GlobalExceptionHandler.kt @@ -0,0 +1,21 @@ +package com.asap.bootstrap.common + +import com.asap.common.exception.BusinessException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.message) + } + + @ExceptionHandler(BusinessException::class) + fun handleBusinessException(e: BusinessException): ResponseEntity { + return ResponseEntity.status(e.httpStatus).body(ExceptionResponse.of(e)) + } +} \ No newline at end of file diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/WebConfig.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/WebConfig.kt new file mode 100644 index 00000000..944ebb9f --- /dev/null +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/WebConfig.kt @@ -0,0 +1,17 @@ +package com.asap.bootstrap.common + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebConfig : WebMvcConfigurer { + + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE") + .allowedHeaders("*") + } + +} \ No newline at end of file diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/user/controller/UserController.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/user/controller/UserController.kt index 9be896ea..067e8992 100644 --- a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/user/controller/UserController.kt +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/user/controller/UserController.kt @@ -1,5 +1,6 @@ package com.asap.bootstrap.user.controller +import com.asap.application.user.port.`in`.RegisterUserUsecase import com.asap.bootstrap.user.api.UserApi import com.asap.bootstrap.user.dto.RegisterUserRequest import com.asap.bootstrap.user.dto.RegisterUserResponse @@ -8,13 +9,18 @@ import org.springframework.web.bind.annotation.RestController @RestController class UserController( - + private val registerUserUsecase: RegisterUserUsecase, ) : UserApi{ override fun registerUser(request: RegisterUserRequest): ResponseEntity { - when(request.registerToken){ - "register" -> return ResponseEntity.ok(RegisterUserResponse("accessToken", "refreshToken")) - else -> return ResponseEntity.badRequest().build() - } + val response = registerUserUsecase.registerUser( + RegisterUserUsecase.Command( + request.registerToken, + request.servicePermission, + request.privatePermission, + request.marketingPermission, + request.birthday + )) + return ResponseEntity.ok(RegisterUserResponse(response.accessToken, response.refreshToken)) } } \ No newline at end of file diff --git a/Bootstrap-Module/src/test/java/com/asap/bootstrap/acceptance/auth/controller/AuthControllerTest.kt b/Bootstrap-Module/src/test/java/com/asap/bootstrap/acceptance/auth/controller/AuthControllerTest.kt new file mode 100644 index 00000000..75e8ee64 --- /dev/null +++ b/Bootstrap-Module/src/test/java/com/asap/bootstrap/acceptance/auth/controller/AuthControllerTest.kt @@ -0,0 +1,84 @@ +package com.asap.bootstrap.acceptance.auth.controller + +import com.asap.application.user.port.`in`.SocialLoginUsecase +import com.asap.bootstrap.auth.controller.AuthController +import com.asap.bootstrap.auth.dto.SocialLoginRequest +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Test +import org.mockito.BDDMockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.post + +@WebMvcTest(AuthController::class) +@AutoConfigureMockMvc +class AuthControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @MockBean + private lateinit var socialLoginUsecase: SocialLoginUsecase + + private val objectMapper: ObjectMapper = ObjectMapper() + + + @Test + fun socialLoginSuccessTest() { + // given + val request = SocialLoginRequest("registered") + val command = SocialLoginUsecase.Command("KAKAO","registered") + BDDMockito.given(socialLoginUsecase.login(command)) + .willReturn(SocialLoginUsecase.Success("accessToken", "refreshToken")) + // when + val response = mockMvc.post("/api/v1/auth/login/{provider}", "KAKAO") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + + // then + response.andExpect { + status { isOk() } + jsonPath("$.accessToken") { + exists() + isString() + isNotEmpty() + } + jsonPath("$.refreshToken") { + exists() + isString() + isNotEmpty() + } + } + } + + @Test + fun socialLoginNonRegisteredTest() { + // given + val request = SocialLoginRequest("nonRegistered") + val command = SocialLoginUsecase.Command("KAKAO","nonRegistered") + BDDMockito.given(socialLoginUsecase.login(command)) + .willReturn(SocialLoginUsecase.NonRegistered("registerToken")) + // when + val response = mockMvc.post("/api/v1/auth/login/{provider}", "KAKAO") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + + // then + response.andExpect { + status { isUnauthorized() } + jsonPath("$.registerToken") { + exists() + isString() + isNotEmpty() + } + } + } + + +} \ No newline at end of file diff --git a/Bootstrap-Module/src/test/java/com/asap/bootstrap/user/controller/UserControllerTest.kt b/Bootstrap-Module/src/test/java/com/asap/bootstrap/acceptance/user/controller/UserControllerTest.kt similarity index 70% rename from Bootstrap-Module/src/test/java/com/asap/bootstrap/user/controller/UserControllerTest.kt rename to Bootstrap-Module/src/test/java/com/asap/bootstrap/acceptance/user/controller/UserControllerTest.kt index d754270a..88340cbe 100644 --- a/Bootstrap-Module/src/test/java/com/asap/bootstrap/user/controller/UserControllerTest.kt +++ b/Bootstrap-Module/src/test/java/com/asap/bootstrap/acceptance/user/controller/UserControllerTest.kt @@ -1,12 +1,16 @@ -package com.asap.bootstrap.user.controller +package com.asap.bootstrap.acceptance.user.controller +import com.asap.application.user.port.`in`.RegisterUserUsecase +import com.asap.bootstrap.user.controller.UserController import com.asap.bootstrap.user.dto.RegisterUserRequest import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import org.junit.jupiter.api.Test +import org.mockito.BDDMockito.given import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.post @@ -19,12 +23,17 @@ class UserControllerTest { @Autowired private lateinit var mockMvc: MockMvc + @MockBean + private lateinit var registerUserUsecase: RegisterUserUsecase + private val objectMapper: ObjectMapper = ObjectMapper().registerModules(JavaTimeModule()) @Test fun registerUserTest(){ // given val request = RegisterUserRequest("register", true, true, true, LocalDate.now()) + val command = RegisterUserUsecase.Command(request.registerToken, request.servicePermission, request.privatePermission, request.marketingPermission, request.birthday) + given(registerUserUsecase.registerUser(command)).willReturn(RegisterUserUsecase.Response("accessToken", "refreshToken")) // when val response = mockMvc.post("/api/v1/users") { contentType = MediaType.APPLICATION_JSON @@ -46,18 +55,4 @@ class UserControllerTest { } } - @Test - fun registerUserNotExistsRegisterTokenTest(){ - // given - val request = RegisterUserRequest("nonExistsToken", false, true, true, LocalDate.now()) - // when - val response = mockMvc.post("/api/v1/users") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(request) - } - // then - response.andExpect { - status { isBadRequest() } - } - } } \ No newline at end of file diff --git a/Bootstrap-Module/src/test/java/com/asap/bootstrap/auth/controller/AuthControllerTest.kt b/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/auth/AuthApiIntegrationTest.kt similarity index 75% rename from Bootstrap-Module/src/test/java/com/asap/bootstrap/auth/controller/AuthControllerTest.kt rename to Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/auth/AuthApiIntegrationTest.kt index 04d4195a..082a92a0 100644 --- a/Bootstrap-Module/src/test/java/com/asap/bootstrap/auth/controller/AuthControllerTest.kt +++ b/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/auth/AuthApiIntegrationTest.kt @@ -1,35 +1,37 @@ -package com.asap.bootstrap.auth.controller +package com.asap.bootstrap.integration.auth import com.asap.bootstrap.auth.dto.SocialLoginRequest import com.fasterxml.jackson.databind.ObjectMapper -import org.junit.jupiter.api.Test +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.post +import kotlin.test.Test -@WebMvcTest(AuthController::class) + +@SpringBootTest @AutoConfigureMockMvc -class AuthControllerTest { +class AuthApiIntegrationTest { @Autowired - private lateinit var mockMvc: MockMvc + lateinit var mockMvc: MockMvc - private val objectMapper: ObjectMapper = ObjectMapper() + val objectMapper: ObjectMapper = ObjectMapper().registerModules(JavaTimeModule()) @Test - fun socialLoginSuccessTest(){ + fun socialLoginSuccessTest() { // given val request = SocialLoginRequest("registered") + val provider = "KAKAO" // when - val response = mockMvc.post("/api/v1/auth/login/{provider}", "kakao") { + val response = mockMvc.post("/api/v1/auth/login/{provider}", provider) { contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(request) } - // then response.andExpect { status { isOk() } @@ -47,15 +49,15 @@ class AuthControllerTest { } @Test - fun socialLoginNonRegisteredTest(){ + fun socialLoginNonRegisteredTest() { // given val request = SocialLoginRequest("nonRegistered") + val provider = "KAKAO" // when - val response = mockMvc.post("/api/v1/auth/login/{provider}", "kakao") { + val response = mockMvc.post("/api/v1/auth/login/{provider}", provider) { contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(request) } - // then response.andExpect { status { isUnauthorized() } @@ -67,22 +69,20 @@ class AuthControllerTest { } } - @Test - fun socialLoginBadRequestTest(){ + fun socialLoginBadRequestTest_with_invalid_access_token() { // given val request = SocialLoginRequest("invalid") + val provider = "KAKAO" // when - val response = mockMvc.post("/api/v1/auth/login/{provider}", "kakao") { + val response = mockMvc.post("/api/v1/auth/login/{provider}", provider) { contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(request) } - // then response.andExpect { status { isBadRequest() } } } - } \ No newline at end of file diff --git a/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/user/UserApiIntegrationTest.kt b/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/user/UserApiIntegrationTest.kt new file mode 100644 index 00000000..d72e7e17 --- /dev/null +++ b/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/user/UserApiIntegrationTest.kt @@ -0,0 +1,111 @@ +package com.asap.bootstrap.integration.user + +import com.asap.bootstrap.user.dto.RegisterUserRequest +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +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.web.servlet.MockMvc +import org.springframework.test.web.servlet.post +import java.time.LocalDate + +/** + * TODO: 요청 생성 util 클래스 추가하기 + */ +@SpringBootTest +@AutoConfigureMockMvc +class UserApiIntegrationTest { + + @Autowired + lateinit var mockMvc: MockMvc + + val objectMapper: ObjectMapper = ObjectMapper().registerModules(JavaTimeModule()) + + @Test + fun registerUserSuccessTest() { + // given + val request = RegisterUserRequest("valid", true, true, true, LocalDate.now()) + // when + val response = mockMvc.post("/api/v1/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + // then + response.andExpect { + status { isOk() } + jsonPath("$.accessToken") { + exists() + isString() + isNotEmpty() + } + jsonPath("$.refreshToken") { + exists() + isString() + isNotEmpty() + } + } + } + + @Test + fun registerUserInvalidTest_with_DuplicateUser(){ + // given + val request = RegisterUserRequest("duplicate", true, true, true, LocalDate.now()) + // when + val response = mockMvc.post("/api/v1/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + // then + response.andExpect { + status { isBadRequest() } + } + } + + @Test + fun registerUserInvalidTest_with_InvalidRegisterToken(){ + // given + val request = RegisterUserRequest("invalid", true, true, true, LocalDate.now()) + // when + val response = mockMvc.post("/api/v1/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + // then + response.andExpect { + status { isBadRequest() } + } + } + + @Test + fun registerUserInvalidTest_with_InvalidServicePermission(){ + //given + val request = RegisterUserRequest("valid", false, true, true, LocalDate.now()) + //when + val response = mockMvc.post("/api/v1/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + //then + response.andExpect { + status { isBadRequest() } + } + } + + @Test + fun registerUserInvalidTest_with_InvalidPrivatePermission(){ + //given + val request = RegisterUserRequest("valid", true, false, true, LocalDate.now()) + //when + val response = mockMvc.post("/api/v1/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + //then + response.andExpect { + status { isBadRequest() } + } + } +} \ No newline at end of file diff --git a/Common-Module/src/main/kotlin/com/asap/common/exception/BusinessException.kt b/Common-Module/src/main/kotlin/com/asap/common/exception/BusinessException.kt new file mode 100644 index 00000000..8871023c --- /dev/null +++ b/Common-Module/src/main/kotlin/com/asap/common/exception/BusinessException.kt @@ -0,0 +1,20 @@ +package com.asap.common.exception + +abstract class BusinessException( + codePrefix: String = CODE_PREFIX, + errorCode: Int, + httpStatus: Int = DEFAULT_HTTP_STATUS, + override val message: String = DEFAULT_ERROR_MESSAGE +) : RuntimeException(message){ + + val code: String = "$codePrefix-${errorCode.toString().padStart(DEFAULT_CODE_LENGTH, DEFAULT_CODE_PAD)}" + val httpStatus: Int = httpStatus + + companion object{ + const val CODE_PREFIX = "UNEXPECTED" + const val DEFAULT_ERROR_MESSAGE = "예상하지 못한 예외가 발생했습니다." + const val DEFAULT_HTTP_STATUS = 500 + const val DEFAULT_CODE_LENGTH = 3 + const val DEFAULT_CODE_PAD = '0' + } +} \ No newline at end of file diff --git a/Common-Module/src/main/kotlin/com/asap/common/exception/DefaultException.kt b/Common-Module/src/main/kotlin/com/asap/common/exception/DefaultException.kt new file mode 100644 index 00000000..6ea397e6 --- /dev/null +++ b/Common-Module/src/main/kotlin/com/asap/common/exception/DefaultException.kt @@ -0,0 +1,30 @@ +package com.asap.common.exception + +sealed class DefaultException( + codePrefix: String = CODE_PREFIX, + errorCode: Int, + httpStatus: Int = 400, + message: String = DEFAULT_ERROR_MESSAGE +): BusinessException(codePrefix, errorCode, httpStatus, message) { + + class InvalidDefaultException(message: String = "유효하지 않은 프로퍼티입니다." ): DefaultException( + errorCode = 1, + message = message + ) + + class InvalidArgumentException(message: String = "유효하지 않은 값입니다." ): DefaultException( + errorCode = 2, + message = message + ) + + class InvalidStateException(message: String = "유효하지 않은 상태입니다." ): DefaultException( + errorCode = 3, + httpStatus = 500, + message = message + ) + + companion object{ + const val CODE_PREFIX = "DEFAULT" + const val DEFAULT_ERROR_MESSAGE = "프로퍼티와 관련된 예외가 발생했습니다." + } +} \ No newline at end of file diff --git a/Domain-Module/build.gradle.kts b/Domain-Module/build.gradle.kts new file mode 100644 index 00000000..3c884913 --- /dev/null +++ b/Domain-Module/build.gradle.kts @@ -0,0 +1,5 @@ +dependencies{ + implementation("com.fasterxml.uuid:java-uuid-generator:5.1.0") + + implementation(project(":Common-Module")) +} \ No newline at end of file diff --git a/Domain-Module/src/main/kotlin/com/asap/domain/common/DomainId.kt b/Domain-Module/src/main/kotlin/com/asap/domain/common/DomainId.kt new file mode 100644 index 00000000..f298782e --- /dev/null +++ b/Domain-Module/src/main/kotlin/com/asap/domain/common/DomainId.kt @@ -0,0 +1,13 @@ +package com.asap.domain.common + +import com.fasterxml.uuid.Generators + +data class DomainId( + val id: String +) { + companion object{ + fun generate(): DomainId { + return DomainId(Generators.timeBasedEpochGenerator().generate().toString()) + } + } +} \ No newline at end of file diff --git a/Domain-Module/src/main/kotlin/com/asap/domain/user/entity/User.kt b/Domain-Module/src/main/kotlin/com/asap/domain/user/entity/User.kt new file mode 100644 index 00000000..4bbc9ccc --- /dev/null +++ b/Domain-Module/src/main/kotlin/com/asap/domain/user/entity/User.kt @@ -0,0 +1,11 @@ +package com.asap.domain.user.entity + +import com.asap.domain.common.DomainId +import com.asap.domain.user.vo.UserPermission + +data class User( + val id: DomainId = DomainId.generate(), + val nickname: String, + val permission: UserPermission +) { +} \ No newline at end of file diff --git a/Domain-Module/src/main/kotlin/com/asap/domain/user/entity/UserAuth.kt b/Domain-Module/src/main/kotlin/com/asap/domain/user/entity/UserAuth.kt new file mode 100644 index 00000000..aa5d0cdc --- /dev/null +++ b/Domain-Module/src/main/kotlin/com/asap/domain/user/entity/UserAuth.kt @@ -0,0 +1,12 @@ +package com.asap.domain.user.entity + +import com.asap.domain.common.DomainId +import com.asap.domain.user.enums.SocialLoginProvider + +data class UserAuth( + val id: DomainId = DomainId.generate(), + val userId: DomainId, + val socialId: String, + val socialLoginProvider: SocialLoginProvider +) { +} \ No newline at end of file diff --git a/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/SocialLoginProvider.kt b/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/SocialLoginProvider.kt new file mode 100644 index 00000000..8de56f26 --- /dev/null +++ b/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/SocialLoginProvider.kt @@ -0,0 +1,18 @@ +package com.asap.domain.user.enums + +import com.asap.common.exception.DefaultException + +enum class SocialLoginProvider { + KAKAO; + + companion object{ + fun parse(value: String): SocialLoginProvider { + return when (value) { + entries.firstOrNull { it.name == value }?.name -> valueOf(value) + else -> throw DefaultException.InvalidArgumentException("유효하지 않은 소셜 로그인 제공자입니다.") + } + } + } + + +} \ No newline at end of file diff --git a/Domain-Module/src/main/kotlin/com/asap/domain/user/vo/UserPermission.kt b/Domain-Module/src/main/kotlin/com/asap/domain/user/vo/UserPermission.kt new file mode 100644 index 00000000..31741772 --- /dev/null +++ b/Domain-Module/src/main/kotlin/com/asap/domain/user/vo/UserPermission.kt @@ -0,0 +1,23 @@ +package com.asap.domain.user.vo + +import com.asap.common.exception.DefaultException + +data class UserPermission( + val servicePermission: Boolean, + val privatePermission: Boolean, + val marketingPermission: Boolean +) { + + init{ + validate() + } + + private fun validate(){ + if(!servicePermission){ + throw DefaultException.InvalidDefaultException("서비스 이용약관에 동의해야 합니다.") + } + if(!privatePermission){ + throw DefaultException.InvalidDefaultException("개인정보 수집 및 이용에 동의해야 합니다.") + } + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 67946d06..9bf505d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,7 +17,8 @@ allprojects { version = "" val javaVersion = "17" - val kotestVersion = "5.0.0" + val kotestVersion = "5.9.1" + val mockkVersion = "1.13.12" tasks.withType { sourceCompatibility = javaVersion @@ -60,6 +61,11 @@ allprojects { // kotest testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + testImplementation("io.kotest:kotest-property:$kotestVersion") + + // mockk + testImplementation("io.mockk:mockk:${mockkVersion}") // jackson implementation("com.fasterxml.jackson.module:jackson-module-kotlin") diff --git a/settings.gradle.kts b/settings.gradle.kts index d8a26bea..23a66191 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,6 @@ rootProject.name = "Lettering-Backend" -include(":Bootstrap-Module") \ No newline at end of file +include(":Bootstrap-Module") +include(":Application-Module") +include(":Domain-Module") +include(":Common-Module") \ No newline at end of file