generated from pricelees/issue-pr-template
Compare commits
9 Commits
main
...
refactor/#
| Author | SHA1 | Date | |
|---|---|---|---|
| da81474ff4 | |||
| 0ff0f4e9fc | |||
| 0f1ce2a497 | |||
| 49e00d91e5 | |||
| 076470b7ab | |||
| 4ec1e0c813 | |||
| cfaf83b70c | |||
| 56023ac543 | |||
| ebe6357adc |
@ -1,6 +1,7 @@
|
|||||||
package com.sangdol.roomescape.auth.business
|
package com.sangdol.roomescape.auth.business
|
||||||
|
|
||||||
import com.sangdol.roomescape.admin.business.AdminService
|
import com.sangdol.roomescape.admin.business.AdminService
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
import com.sangdol.roomescape.auth.dto.LoginContext
|
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||||
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
||||||
@ -12,8 +13,8 @@ import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
|||||||
import com.sangdol.roomescape.user.business.UserService
|
import com.sangdol.roomescape.user.business.UserService
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -25,10 +26,9 @@ const val CLAIM_STORE_ID_KEY = "store_id"
|
|||||||
class AuthService(
|
class AuthService(
|
||||||
private val adminService: AdminService,
|
private val adminService: AdminService,
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
private val loginHistoryService: LoginHistoryService,
|
|
||||||
private val jwtUtils: JwtUtils,
|
private val jwtUtils: JwtUtils,
|
||||||
|
private val eventPublisher: ApplicationEventPublisher
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun login(
|
fun login(
|
||||||
request: LoginRequest,
|
request: LoginRequest,
|
||||||
context: LoginContext
|
context: LoginContext
|
||||||
@ -36,20 +36,25 @@ class AuthService(
|
|||||||
log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
||||||
val (credentials, extraClaims) = getCredentials(request)
|
val (credentials, extraClaims) = getCredentials(request)
|
||||||
|
|
||||||
|
val event = LoginHistoryEvent(
|
||||||
|
id = credentials.id,
|
||||||
|
type = request.principalType,
|
||||||
|
ipAddress = context.ipAddress,
|
||||||
|
userAgent = context.userAgent
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
verifyPasswordOrThrow(request, credentials)
|
verifyPasswordOrThrow(request, credentials)
|
||||||
|
|
||||||
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
||||||
|
|
||||||
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
|
eventPublisher.publishEvent(event.onSuccess())
|
||||||
|
|
||||||
return credentials.toResponse(accessToken).also {
|
return credentials.toResponse(accessToken).also {
|
||||||
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
|
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context)
|
eventPublisher.publishEvent(event.onFailure())
|
||||||
|
|
||||||
when (e) {
|
when (e) {
|
||||||
is AuthException -> {
|
is AuthException -> {
|
||||||
log.info { "[login] 로그인 실패: account = ${request.account}" }
|
log.info { "[login] 로그인 실패: account = ${request.account}" }
|
||||||
|
|||||||
@ -0,0 +1,96 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||||
|
import com.sangdol.roomescape.auth.mapper.toEntity
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
|
class LoginHistoryEventListener(
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
private val loginHistoryRepository: LoginHistoryRepository,
|
||||||
|
private val queue: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue()
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Value(value = "\${spring.jpa.properties.hibernate.jdbc.batch_size:100}")
|
||||||
|
private var batchSize: Int = 0
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener(classes = [LoginHistoryEvent::class])
|
||||||
|
fun onLoginCompleted(event: LoginHistoryEvent) {
|
||||||
|
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
|
||||||
|
|
||||||
|
queue.add(event.toEntity(idGenerator.create())).also {
|
||||||
|
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.size >= batchSize) {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
|
||||||
|
fun flushScheduled() {
|
||||||
|
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
||||||
|
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
log.info { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 완료: size=${queue.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun flushAll() {
|
||||||
|
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 완료: size=${queue.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flush() {
|
||||||
|
log.info { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
||||||
|
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
log.info { "[flush] 큐에 있는 로그인 이력이 없음." }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
val batch = mutableListOf<LoginHistoryEntity>()
|
||||||
|
repeat(batchSize) {
|
||||||
|
val entity: LoginHistoryEntity? = queue.poll()
|
||||||
|
|
||||||
|
if (entity != null) {
|
||||||
|
batch.add(entity)
|
||||||
|
} else {
|
||||||
|
return@repeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loginHistoryRepository.saveAll(batch).also {
|
||||||
|
log.info { "[flush] 큐에 저장된 로그인 이력 저장 완료: size=${batch.size}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,63 +0,0 @@
|
|||||||
package com.sangdol.roomescape.auth.business
|
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
|
||||||
import com.sangdol.roomescape.auth.dto.LoginContext
|
|
||||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Propagation
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class LoginHistoryService(
|
|
||||||
private val loginHistoryRepository: LoginHistoryRepository,
|
|
||||||
private val idGenerator: IDGenerator,
|
|
||||||
) {
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
fun createSuccessHistory(
|
|
||||||
principalId: Long,
|
|
||||||
principalType: PrincipalType,
|
|
||||||
context: LoginContext
|
|
||||||
) {
|
|
||||||
createHistory(principalId, principalType, success = true, context = context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
fun createFailureHistory(
|
|
||||||
principalId: Long,
|
|
||||||
principalType: PrincipalType,
|
|
||||||
context: LoginContext
|
|
||||||
) {
|
|
||||||
createHistory(principalId, principalType, success = false, context = context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createHistory(
|
|
||||||
principalId: Long,
|
|
||||||
principalType: PrincipalType,
|
|
||||||
success: Boolean,
|
|
||||||
context: LoginContext
|
|
||||||
) {
|
|
||||||
log.info { "[createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
LoginHistoryEntity(
|
|
||||||
id = idGenerator.create(),
|
|
||||||
principalId = principalId,
|
|
||||||
principalType = principalType,
|
|
||||||
success = success,
|
|
||||||
ipAddress = context.ipAddress,
|
|
||||||
userAgent = context.userAgent,
|
|
||||||
).also {
|
|
||||||
loginHistoryRepository.save(it)
|
|
||||||
log.info { "[createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
log.warn { "[createHistory] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business.domain
|
||||||
|
|
||||||
|
class LoginHistoryEvent(
|
||||||
|
val id: Long,
|
||||||
|
val type: PrincipalType,
|
||||||
|
var success: Boolean = true,
|
||||||
|
val ipAddress: String,
|
||||||
|
val userAgent: String
|
||||||
|
) {
|
||||||
|
fun onSuccess(): LoginHistoryEvent {
|
||||||
|
this.success = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFailure(): LoginHistoryEvent {
|
||||||
|
this.success = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.sangdol.roomescape.auth.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||||
|
|
||||||
|
fun LoginHistoryEvent.toEntity(id: Long) = LoginHistoryEntity(
|
||||||
|
id = id,
|
||||||
|
principalId = this.id,
|
||||||
|
principalType = this.type,
|
||||||
|
success = this.success,
|
||||||
|
ipAddress = this.ipAddress,
|
||||||
|
userAgent = this.userAgent
|
||||||
|
)
|
||||||
@ -11,6 +11,11 @@ spring:
|
|||||||
active: ${ACTIVE_PROFILE:local}
|
active: ${ACTIVE_PROFILE:local}
|
||||||
jpa:
|
jpa:
|
||||||
open-in-view: false
|
open-in-view: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
jdbc:
|
||||||
|
batch_size: ${JDBC_BATCH_SIZE:100}
|
||||||
|
order_inserts: true
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
package com.sangdol.roomescape.auth
|
package com.sangdol.roomescape.auth
|
||||||
|
|
||||||
|
import com.ninjasquad.springmockk.MockkBean
|
||||||
import com.ninjasquad.springmockk.SpykBean
|
import com.ninjasquad.springmockk.SpykBean
|
||||||
import com.sangdol.common.types.web.HttpStatus
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
import com.sangdol.roomescape.admin.exception.AdminErrorCode
|
import com.sangdol.roomescape.admin.exception.AdminErrorCode
|
||||||
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
||||||
import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
|
import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
|
||||||
import com.sangdol.roomescape.auth.business.CLAIM_STORE_ID_KEY
|
import com.sangdol.roomescape.auth.business.CLAIM_STORE_ID_KEY
|
||||||
|
import com.sangdol.roomescape.auth.business.LoginHistoryEventListener
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
|
||||||
import com.sangdol.roomescape.auth.dto.LoginRequest
|
|
||||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
|
||||||
import com.sangdol.roomescape.supports.AdminFixture
|
import com.sangdol.roomescape.supports.AdminFixture
|
||||||
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
||||||
import com.sangdol.roomescape.supports.UserFixture
|
import com.sangdol.roomescape.supports.UserFixture
|
||||||
@ -18,19 +20,31 @@ import com.sangdol.roomescape.supports.runTest
|
|||||||
import com.sangdol.roomescape.user.exception.UserErrorCode
|
import com.sangdol.roomescape.user.exception.UserErrorCode
|
||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||||
import io.kotest.assertions.assertSoftly
|
import io.kotest.assertions.assertSoftly
|
||||||
import io.kotest.matchers.collections.shouldHaveSize
|
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.shouldNotBe
|
import io.kotest.matchers.shouldNotBe
|
||||||
import io.mockk.every
|
import io.mockk.*
|
||||||
import io.restassured.response.ValidatableResponse
|
import io.restassured.response.ValidatableResponse
|
||||||
import org.hamcrest.CoreMatchers.equalTo
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
|
|
||||||
class AuthApiTest(
|
class AuthApiTest(
|
||||||
@SpykBean private val jwtUtils: JwtUtils,
|
@SpykBean private val jwtUtils: JwtUtils,
|
||||||
private val loginHistoryRepository: LoginHistoryRepository
|
@MockkBean(relaxed = true) private val loginHistoryEventListener: LoginHistoryEventListener,
|
||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
|
lateinit var slot: CapturingSlot<LoginHistoryEvent>
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
beforeTest {
|
||||||
|
slot = slot<LoginHistoryEvent>()
|
||||||
|
every {
|
||||||
|
loginHistoryEventListener.onLoginCompleted(capture(slot))
|
||||||
|
} just Runs
|
||||||
|
}
|
||||||
|
|
||||||
|
afterTest {
|
||||||
|
clearMocks(jwtUtils, loginHistoryEventListener)
|
||||||
|
}
|
||||||
|
|
||||||
context("로그인을 시도한다.") {
|
context("로그인을 시도한다.") {
|
||||||
context("성공 응답") {
|
context("성공 응답") {
|
||||||
listOf(
|
listOf(
|
||||||
@ -64,6 +78,7 @@ class AuthApiTest(
|
|||||||
password = user.password,
|
password = user.password,
|
||||||
type = PrincipalType.USER,
|
type = PrincipalType.USER,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val token: String = it.extract().path("data.accessToken")
|
val token: String = it.extract().path("data.accessToken")
|
||||||
jwtUtils.extractSubject(token) shouldBe user.id.toString()
|
jwtUtils.extractSubject(token) shouldBe user.id.toString()
|
||||||
}
|
}
|
||||||
@ -71,6 +86,16 @@ class AuthApiTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
context("실패 응답") {
|
context("실패 응답") {
|
||||||
|
|
||||||
|
lateinit var slot: CapturingSlot<LoginHistoryEvent>
|
||||||
|
|
||||||
|
beforeTest {
|
||||||
|
slot = slot<LoginHistoryEvent>()
|
||||||
|
every {
|
||||||
|
loginHistoryEventListener.onLoginCompleted(capture(slot))
|
||||||
|
} just Runs
|
||||||
|
}
|
||||||
|
|
||||||
context("계정이 맞으면 로그인 실패 이력을 남긴다.") {
|
context("계정이 맞으면 로그인 실패 이력을 남긴다.") {
|
||||||
test("비밀번호가 틀린 경우") {
|
test("비밀번호가 틀린 경우") {
|
||||||
val admin = testAuthUtil.createAdmin(AdminFixture.default)
|
val admin = testAuthUtil.createAdmin(AdminFixture.default)
|
||||||
@ -88,9 +113,14 @@ class AuthApiTest(
|
|||||||
body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode))
|
body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode))
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) {
|
verify(exactly = 1) {
|
||||||
|
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSoftly(slot.captured) {
|
||||||
|
this.id shouldBe admin.id
|
||||||
|
this.type shouldBe PrincipalType.ADMIN
|
||||||
this.success shouldBe false
|
this.success shouldBe false
|
||||||
this.principalType shouldBe PrincipalType.ADMIN
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,9 +145,14 @@ class AuthApiTest(
|
|||||||
body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode))
|
body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode))
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) {
|
verify(exactly = 1) {
|
||||||
|
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSoftly(slot.captured) {
|
||||||
|
this.id shouldBe admin.id
|
||||||
|
this.type shouldBe PrincipalType.ADMIN
|
||||||
this.success shouldBe false
|
this.success shouldBe false
|
||||||
this.principalType shouldBe PrincipalType.ADMIN
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,7 +179,9 @@ class AuthApiTest(
|
|||||||
body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode))
|
body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode))
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
loginHistoryRepository.findAll() shouldHaveSize 0
|
verify(exactly = 0) {
|
||||||
|
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,7 +205,9 @@ class AuthApiTest(
|
|||||||
body("code", equalTo(AdminErrorCode.ADMIN_NOT_FOUND.errorCode))
|
body("code", equalTo(AdminErrorCode.ADMIN_NOT_FOUND.errorCode))
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
loginHistoryRepository.findAll() shouldHaveSize 0
|
verify(exactly = 0) {
|
||||||
|
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -198,10 +237,13 @@ class AuthApiTest(
|
|||||||
).also {
|
).also {
|
||||||
extraAssertions?.invoke(it)
|
extraAssertions?.invoke(it)
|
||||||
|
|
||||||
assertSoftly(loginHistoryRepository.findByPrincipalId(id)) { history ->
|
verify(exactly = 1) {
|
||||||
history shouldHaveSize (1)
|
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
||||||
history[0].success shouldBe true
|
}
|
||||||
history[0].principalType shouldBe type
|
assertSoftly(slot.captured) {
|
||||||
|
this.id shouldBe id
|
||||||
|
this.type shouldBe type
|
||||||
|
this.success shouldBe true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
package com.sangdol.roomescape.auth
|
|
||||||
|
|
||||||
import com.ninjasquad.springmockk.MockkBean
|
|
||||||
import com.sangdol.common.types.web.HttpStatus
|
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
|
||||||
import com.sangdol.roomescape.auth.dto.LoginRequest
|
|
||||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
|
||||||
import com.sangdol.roomescape.supports.AdminFixture
|
|
||||||
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
|
||||||
import com.sangdol.roomescape.supports.UserFixture
|
|
||||||
import com.sangdol.roomescape.supports.runTest
|
|
||||||
import io.mockk.clearMocks
|
|
||||||
import io.mockk.every
|
|
||||||
|
|
||||||
class FailOnSaveLoginHistoryTest(
|
|
||||||
@MockkBean private val loginHistoryRepository: LoginHistoryRepository
|
|
||||||
) : FunSpecSpringbootTest() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
context("로그인 이력 저장 과정에서 예외가 발생해도 로그인 작업 자체는 정상 처리된다.") {
|
|
||||||
beforeTest {
|
|
||||||
clearMocks(loginHistoryRepository)
|
|
||||||
|
|
||||||
every {
|
|
||||||
loginHistoryRepository.save(any())
|
|
||||||
} throws RuntimeException("intended exception")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("회원") {
|
|
||||||
val user = testAuthUtil.signup(UserFixture.createRequest)
|
|
||||||
val request = LoginRequest(user.email, user.password, PrincipalType.USER)
|
|
||||||
|
|
||||||
runTest(
|
|
||||||
using = {
|
|
||||||
body(request)
|
|
||||||
},
|
|
||||||
on = {
|
|
||||||
post("/auth/login")
|
|
||||||
},
|
|
||||||
expect = {
|
|
||||||
statusCode(HttpStatus.OK.value())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("관리자") {
|
|
||||||
val admin = testAuthUtil.createAdmin(AdminFixture.default)
|
|
||||||
val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN)
|
|
||||||
|
|
||||||
runTest(
|
|
||||||
using = {
|
|
||||||
body(request)
|
|
||||||
},
|
|
||||||
on = {
|
|
||||||
post("/auth/login")
|
|
||||||
},
|
|
||||||
expect = {
|
|
||||||
statusCode(HttpStatus.OK.value())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||||
|
import com.sangdol.roomescape.supports.IDGenerator
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.collections.shouldBeEmpty
|
||||||
|
import io.kotest.matchers.collections.shouldNotContainAnyOf
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
|
||||||
|
class LoginHistoryEventListenerTest : FunSpec() {
|
||||||
|
|
||||||
|
val histories: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue((1..1000).map {
|
||||||
|
LoginHistoryEntity(
|
||||||
|
id = IDGenerator.create(),
|
||||||
|
principalId = IDGenerator.create(),
|
||||||
|
principalType = PrincipalType.USER,
|
||||||
|
success = true,
|
||||||
|
ipAddress = "127.0.0.1",
|
||||||
|
userAgent = "UserAgent"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
init {
|
||||||
|
test("ConcurrentLinkedQueue에서 데이터를 꺼내는 작업을 여러 스레드에서 동시 호출해도 중복 처리되지 않는다.") {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val latch = CountDownLatch(2)
|
||||||
|
|
||||||
|
val flushJob1 = async {
|
||||||
|
latch.countDown()
|
||||||
|
latch.await()
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
val flushJob2 = async {
|
||||||
|
latch.countDown()
|
||||||
|
latch.await()
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
val flushJob1Result = flushJob1.await()
|
||||||
|
val flushJob2Result = flushJob2.await()
|
||||||
|
|
||||||
|
flushJob2Result shouldNotContainAnyOf flushJob1Result
|
||||||
|
flushJob1Result shouldNotContainAnyOf flushJob2Result
|
||||||
|
(flushJob1Result.size + flushJob2Result.size) shouldBe 1000
|
||||||
|
histories.shouldBeEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flush(batchSize: Int = 500): MutableList<LoginHistoryEntity> {
|
||||||
|
val batch = mutableListOf<LoginHistoryEntity>()
|
||||||
|
repeat(batchSize) {
|
||||||
|
val entity: LoginHistoryEntity? = histories.poll()
|
||||||
|
|
||||||
|
if (entity != null) {
|
||||||
|
batch.add(entity)
|
||||||
|
} else {
|
||||||
|
return@repeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,7 +31,7 @@ export function maxIterations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fetchUsers() {
|
export function fetchUsers() {
|
||||||
const userCount = Math.round(maxIterations() * 1.2)
|
const userCount = Math.round(maxIterations() * 0.5)
|
||||||
const userAccountRes = http.get(`${BASE_URL}/tests/users?count=${userCount}`)
|
const userAccountRes = http.get(`${BASE_URL}/tests/users?count=${userCount}`)
|
||||||
|
|
||||||
if (userAccountRes.status !== 200) {
|
if (userAccountRes.status !== 200) {
|
||||||
|
|||||||
@ -17,15 +17,17 @@ export const options = {
|
|||||||
executor: 'ramping-vus',
|
executor: 'ramping-vus',
|
||||||
startVUs: 0,
|
startVUs: 0,
|
||||||
stages: [
|
stages: [
|
||||||
{ duration: '1m', target: 5 },
|
{ duration: '1m', target: 100 },
|
||||||
{ duration: '1m', target: 10 },
|
{ duration: '1m', target: 200 },
|
||||||
{ duration: '1m', target: 15 },
|
{ duration: '1m', target: 300 },
|
||||||
{ duration: '1m', target: 20 },
|
{ duration: '1m', target: 300 },
|
||||||
{ duration: '1m', target: 25 },
|
{ duration: '1m', target: 400 },
|
||||||
{ duration: '1m', target: 30 },
|
{ duration: '1m', target: 500 },
|
||||||
{ duration: '1m', target: 35 },
|
{ duration: '2m', target: 500 },
|
||||||
{ duration: '1m', target: 40 },
|
{ duration: '1m', target: 600 },
|
||||||
{ duration: '1m', target: 0 },
|
{ duration: '1m', target: 800 },
|
||||||
|
{ duration: '1m', target: 1000 },
|
||||||
|
{ duration: '3m', target: 0 },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -88,25 +90,33 @@ export default function (data) {
|
|||||||
console.log(`로그인 실패: token=${accessToken}`)
|
console.log(`로그인 실패: token=${accessToken}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const targetDate = randomDayBetween(1, 6)
|
const targetDate = randomDayBetween(1, 4)
|
||||||
|
|
||||||
let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount
|
let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount
|
||||||
|
|
||||||
group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () {
|
group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () {
|
||||||
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
|
let searchTrial = 0
|
||||||
if (check(res, { '일정 조회 성공': (r) => r.status === 200 })) {
|
let schedules
|
||||||
sleep(20)
|
|
||||||
let schedules = parseIdToString(res).data.schedules
|
|
||||||
|
|
||||||
if (!schedules || schedules.length === 0) {
|
while (searchTrial < 5) {
|
||||||
console.log("일정 없음. 1회 재시도")
|
|
||||||
const storeId = randomItem(stores).storeId
|
|
||||||
const targetDate = randomDayBetween(0, 6)
|
|
||||||
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
|
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
|
||||||
|
const result = check(res, {'일정 조회 성공': (r) => r.status === 200})
|
||||||
|
if (result !== true) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
schedules = parseIdToString(res).data.schedules
|
schedules = parseIdToString(res).data.schedules
|
||||||
|
if (schedules && schedules.length > 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
searchTrial++
|
||||||
|
sleep(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schedules.length <= 0) {
|
||||||
|
console.log(`5회 시도에도 일정 조회 실패`)
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schedules && schedules.length > 0) {
|
|
||||||
group(`일부 테마는 상세 조회`, function () {
|
group(`일부 테마는 상세 조회`, function () {
|
||||||
const themesByStoreAndDate = schedules.map(s => s.theme)
|
const themesByStoreAndDate = schedules.map(s => s.theme)
|
||||||
if (!themesByStoreAndDate && themesByStoreAndDate.length <= 0) {
|
if (!themesByStoreAndDate && themesByStoreAndDate.length <= 0) {
|
||||||
@ -126,34 +136,40 @@ export default function (data) {
|
|||||||
availableScheduleId = availableSchedule.schedule.id
|
availableScheduleId = availableSchedule.schedule.id
|
||||||
selectedThemeId = availableSchedule.theme.id
|
selectedThemeId = availableSchedule.theme.id
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!availableScheduleId) {
|
if (!availableScheduleId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isScheduleHeld = false
|
||||||
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
|
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
|
||||||
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken))
|
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken))
|
||||||
|
const body = JSON.parse(holdRes)
|
||||||
|
|
||||||
if (check(holdRes, { '일정 점유 성공': (r) => r.status === 200 })) {
|
if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) {
|
||||||
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`)
|
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`)
|
||||||
if (themeInfoRes.status !== 200) {
|
|
||||||
throw new Error("테마 상세 조회 실패")
|
|
||||||
}
|
|
||||||
selectedThemeInfo = parseIdToString(themeInfoRes).data
|
selectedThemeInfo = parseIdToString(themeInfoRes).data
|
||||||
|
isScheduleHeld = true
|
||||||
|
} else {
|
||||||
|
const errorCode = body.code
|
||||||
|
const errorMessage = body.message
|
||||||
|
|
||||||
|
console.log(`일정 점유 실패: code=${errorCode}, message=${errorMessage}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let isPendingReservationCreated = false
|
if (!isScheduleHeld || !selectedThemeInfo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isPendingReservationCreated = false
|
||||||
group(`예약 정보 입력 페이지`, function () {
|
group(`예약 정보 입력 페이지`, function () {
|
||||||
let userName, userContact
|
let userName, userContact
|
||||||
group(`회원 연락처 조회`, function () {
|
group(`회원 연락처 조회`, function () {
|
||||||
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken))
|
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken))
|
||||||
|
|
||||||
if (!check(userContactRes, { '회원 연락처 조회 성공': (r) => r.status === 200 })) {
|
if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) {
|
||||||
throw new Error("회원 연락처 조회 과정에서 예외 발생")
|
throw new Error("회원 연락처 조회 과정에서 예외 발생")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,14 +214,12 @@ export default function (data) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!isPendingReservationCreated) {
|
if (!isPendingReservationCreated) {
|
||||||
console.log("회원의 예약 정보 입력 중 페이지 이탈")
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
group(`결제 및 예약 확정`, function () {
|
group(`결제 및 예약 확정`, function () {
|
||||||
// 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트
|
// 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트
|
||||||
if (Math.random() <= 0.2) {
|
if (Math.random() <= 0.2) {
|
||||||
console.log("결제 페이지에서의 이탈")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +238,7 @@ export default function (data) {
|
|||||||
sleep(30)
|
sleep(30)
|
||||||
const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken))
|
const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken))
|
||||||
|
|
||||||
if (check(confirmOrderRes, { '예약 확정 성공': (r) => r.status === 200 })) {
|
if (check(confirmOrderRes, {'예약 확정 성공': (r) => r.status === 200})) {
|
||||||
isConfirmed = true
|
isConfirmed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -241,7 +255,7 @@ export default function (data) {
|
|||||||
|
|
||||||
sleep(10)
|
sleep(10)
|
||||||
const temporalConfirmRes = http.post(`${BASE_URL}/reservations/${reservationId}/confirm`)
|
const temporalConfirmRes = http.post(`${BASE_URL}/reservations/${reservationId}/confirm`)
|
||||||
if (check(temporalConfirmRes, { '임시 예약 확정 성공': (r) => r.status === 200})) {
|
if (check(temporalConfirmRes, {'임시 예약 확정 성공': (r) => r.status === 200})) {
|
||||||
console.log("예약 확정 성공")
|
console.log("예약 확정 성공")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
33
test-scripts/login-performance.js
Normal file
33
test-scripts/login-performance.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import {fetchUsers, login} from "./common.js";
|
||||||
|
import {randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
login: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: '1m', target: 100 },
|
||||||
|
{ duration: '1m', target: 200 },
|
||||||
|
{ duration: '1m', target: 300 },
|
||||||
|
{ duration: '1m', target: 300 },
|
||||||
|
{ duration: '1m', target: 400 },
|
||||||
|
{ duration: '1m', target: 500 },
|
||||||
|
{ duration: '2m', target: 0 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
const users = fetchUsers()
|
||||||
|
|
||||||
|
console.log(`${users.length}명의 회원 준비`)
|
||||||
|
|
||||||
|
return { users }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
const user = randomItem(data.users)
|
||||||
|
const token = login(user.account, user.password, 'USER').accessToken
|
||||||
|
}
|
||||||
@ -28,7 +28,7 @@ spring:
|
|||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
mode: always
|
mode: always
|
||||||
schema-locations: classpath:schema/schema-mysql.sql
|
schema-locations: classpath:schema/schema-h2.sql
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user