generated from pricelees/issue-pr-template
Compare commits
10 Commits
main
...
refactor/#
| Author | SHA1 | Date | |
|---|---|---|---|
| 20d638da0b | |||
| da81474ff4 | |||
| 0ff0f4e9fc | |||
| 0f1ce2a497 | |||
| 49e00d91e5 | |||
| 076470b7ab | |||
| 4ec1e0c813 | |||
| cfaf83b70c | |||
| 56023ac543 | |||
| ebe6357adc |
@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.sangdol.common.web.asepct.ControllerLoggingAspect
|
||||
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
|
||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||
import io.micrometer.tracing.CurrentTraceContext
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
@ -17,9 +18,10 @@ class WebLoggingConfig {
|
||||
@Bean
|
||||
@DependsOn(value = ["webLogMessageConverter"])
|
||||
fun filterRegistrationBean(
|
||||
webLogMessageConverter: WebLogMessageConverter
|
||||
webLogMessageConverter: WebLogMessageConverter,
|
||||
currentTraceContext: CurrentTraceContext
|
||||
): FilterRegistrationBean<OncePerRequestFilter> {
|
||||
val filter = HttpRequestLoggingFilter(webLogMessageConverter)
|
||||
val filter = HttpRequestLoggingFilter(webLogMessageConverter, currentTraceContext)
|
||||
|
||||
return FilterRegistrationBean<OncePerRequestFilter>(filter)
|
||||
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
|
||||
|
||||
@ -5,6 +5,7 @@ import com.sangdol.common.utils.MdcStartTimeUtil
|
||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.micrometer.tracing.CurrentTraceContext
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
@ -15,7 +16,8 @@ import org.springframework.web.util.ContentCachingResponseWrapper
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
class HttpRequestLoggingFilter(
|
||||
private val messageConverter: WebLogMessageConverter
|
||||
private val messageConverter: WebLogMessageConverter,
|
||||
private val currentTraceContext: CurrentTraceContext
|
||||
) : OncePerRequestFilter() {
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
@ -32,9 +34,12 @@ class HttpRequestLoggingFilter(
|
||||
try {
|
||||
filterChain.doFilter(cachedRequest, cachedResponse)
|
||||
cachedResponse.copyBodyToResponse()
|
||||
} catch (e: Exception) {
|
||||
throw e
|
||||
} finally {
|
||||
MdcStartTimeUtil.clear()
|
||||
MdcPrincipalIdUtil.clear()
|
||||
currentTraceContext.maybeScope(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.sangdol.roomescape.auth.business
|
||||
|
||||
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.dto.LoginContext
|
||||
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 io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@ -25,10 +26,9 @@ const val CLAIM_STORE_ID_KEY = "store_id"
|
||||
class AuthService(
|
||||
private val adminService: AdminService,
|
||||
private val userService: UserService,
|
||||
private val loginHistoryService: LoginHistoryService,
|
||||
private val jwtUtils: JwtUtils,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun login(
|
||||
request: LoginRequest,
|
||||
context: LoginContext
|
||||
@ -36,20 +36,25 @@ class AuthService(
|
||||
log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
||||
val (credentials, extraClaims) = getCredentials(request)
|
||||
|
||||
val event = LoginHistoryEvent(
|
||||
id = credentials.id,
|
||||
type = request.principalType,
|
||||
ipAddress = context.ipAddress,
|
||||
userAgent = context.userAgent
|
||||
)
|
||||
|
||||
try {
|
||||
verifyPasswordOrThrow(request, credentials)
|
||||
|
||||
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 {
|
||||
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context)
|
||||
|
||||
eventPublisher.publishEvent(event.onFailure())
|
||||
when (e) {
|
||||
is AuthException -> {
|
||||
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}
|
||||
jpa:
|
||||
open-in-view: false
|
||||
properties:
|
||||
hibernate:
|
||||
jdbc:
|
||||
batch_size: ${JDBC_BATCH_SIZE:100}
|
||||
order_inserts: true
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
package com.sangdol.roomescape.auth
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import com.ninjasquad.springmockk.SpykBean
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
import com.sangdol.roomescape.admin.exception.AdminErrorCode
|
||||
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_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.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.FunSpecSpringbootTest
|
||||
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.infrastructure.persistence.UserEntity
|
||||
import io.kotest.assertions.assertSoftly
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.mockk.every
|
||||
import io.mockk.*
|
||||
import io.restassured.response.ValidatableResponse
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
|
||||
class AuthApiTest(
|
||||
@SpykBean private val jwtUtils: JwtUtils,
|
||||
private val loginHistoryRepository: LoginHistoryRepository
|
||||
@MockkBean(relaxed = true) private val loginHistoryEventListener: LoginHistoryEventListener,
|
||||
) : FunSpecSpringbootTest() {
|
||||
|
||||
lateinit var slot: CapturingSlot<LoginHistoryEvent>
|
||||
|
||||
init {
|
||||
beforeTest {
|
||||
slot = slot<LoginHistoryEvent>()
|
||||
every {
|
||||
loginHistoryEventListener.onLoginCompleted(capture(slot))
|
||||
} just Runs
|
||||
}
|
||||
|
||||
afterTest {
|
||||
clearMocks(jwtUtils, loginHistoryEventListener)
|
||||
}
|
||||
|
||||
context("로그인을 시도한다.") {
|
||||
context("성공 응답") {
|
||||
listOf(
|
||||
@ -64,6 +78,7 @@ class AuthApiTest(
|
||||
password = user.password,
|
||||
type = PrincipalType.USER,
|
||||
) {
|
||||
|
||||
val token: String = it.extract().path("data.accessToken")
|
||||
jwtUtils.extractSubject(token) shouldBe user.id.toString()
|
||||
}
|
||||
@ -71,6 +86,16 @@ class AuthApiTest(
|
||||
}
|
||||
|
||||
context("실패 응답") {
|
||||
|
||||
lateinit var slot: CapturingSlot<LoginHistoryEvent>
|
||||
|
||||
beforeTest {
|
||||
slot = slot<LoginHistoryEvent>()
|
||||
every {
|
||||
loginHistoryEventListener.onLoginCompleted(capture(slot))
|
||||
} just Runs
|
||||
}
|
||||
|
||||
context("계정이 맞으면 로그인 실패 이력을 남긴다.") {
|
||||
test("비밀번호가 틀린 경우") {
|
||||
val admin = testAuthUtil.createAdmin(AdminFixture.default)
|
||||
@ -88,9 +113,14 @@ class AuthApiTest(
|
||||
body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode))
|
||||
}
|
||||
).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.principalType shouldBe PrincipalType.ADMIN
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -115,9 +145,14 @@ class AuthApiTest(
|
||||
body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode))
|
||||
}
|
||||
).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.principalType shouldBe PrincipalType.ADMIN
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -144,7 +179,9 @@ class AuthApiTest(
|
||||
body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode))
|
||||
}
|
||||
).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))
|
||||
}
|
||||
).also {
|
||||
loginHistoryRepository.findAll() shouldHaveSize 0
|
||||
verify(exactly = 0) {
|
||||
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -198,10 +237,13 @@ class AuthApiTest(
|
||||
).also {
|
||||
extraAssertions?.invoke(it)
|
||||
|
||||
assertSoftly(loginHistoryRepository.findByPrincipalId(id)) { history ->
|
||||
history shouldHaveSize (1)
|
||||
history[0].success shouldBe true
|
||||
history[0].principalType shouldBe type
|
||||
verify(exactly = 1) {
|
||||
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
||||
}
|
||||
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() {
|
||||
const userCount = Math.round(maxIterations() * 1.2)
|
||||
const userCount = Math.round(maxIterations() * 0.5)
|
||||
const userAccountRes = http.get(`${BASE_URL}/tests/users?count=${userCount}`)
|
||||
|
||||
if (userAccountRes.status !== 200) {
|
||||
|
||||
@ -17,15 +17,17 @@ export const options = {
|
||||
executor: 'ramping-vus',
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: '1m', target: 5 },
|
||||
{ duration: '1m', target: 10 },
|
||||
{ duration: '1m', target: 15 },
|
||||
{ duration: '1m', target: 20 },
|
||||
{ duration: '1m', target: 25 },
|
||||
{ duration: '1m', target: 30 },
|
||||
{ duration: '1m', target: 35 },
|
||||
{ duration: '1m', target: 40 },
|
||||
{ duration: '1m', target: 0 },
|
||||
{ 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: 500 },
|
||||
{ duration: '1m', target: 600 },
|
||||
{ duration: '1m', target: 800 },
|
||||
{ duration: '1m', target: 1000 },
|
||||
{ duration: '3m', target: 0 },
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -88,25 +90,33 @@ export default function (data) {
|
||||
console.log(`로그인 실패: token=${accessToken}`)
|
||||
return
|
||||
}
|
||||
const targetDate = randomDayBetween(1, 6)
|
||||
const targetDate = randomDayBetween(1, 4)
|
||||
|
||||
let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount
|
||||
|
||||
group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () {
|
||||
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
|
||||
if (check(res, { '일정 조회 성공': (r) => r.status === 200 })) {
|
||||
sleep(20)
|
||||
let schedules = parseIdToString(res).data.schedules
|
||||
let searchTrial = 0
|
||||
let schedules
|
||||
|
||||
if (!schedules || schedules.length === 0) {
|
||||
console.log("일정 없음. 1회 재시도")
|
||||
const storeId = randomItem(stores).storeId
|
||||
const targetDate = randomDayBetween(0, 6)
|
||||
while (searchTrial < 5) {
|
||||
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
|
||||
if (schedules && schedules.length > 0) {
|
||||
break
|
||||
}
|
||||
searchTrial++
|
||||
sleep(10)
|
||||
}
|
||||
|
||||
if (schedules.length <= 0) {
|
||||
console.log(`5회 시도에도 일정 조회 실패`)
|
||||
return;
|
||||
}
|
||||
|
||||
if (schedules && schedules.length > 0) {
|
||||
group(`일부 테마는 상세 조회`, function () {
|
||||
const themesByStoreAndDate = schedules.map(s => s.theme)
|
||||
if (!themesByStoreAndDate && themesByStoreAndDate.length <= 0) {
|
||||
@ -126,28 +136,34 @@ export default function (data) {
|
||||
availableScheduleId = availableSchedule.schedule.id
|
||||
selectedThemeId = availableSchedule.theme.id
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!availableScheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isScheduleHeld = false
|
||||
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
|
||||
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken))
|
||||
const body = JSON.parse(holdRes)
|
||||
|
||||
if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) {
|
||||
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`)
|
||||
if (themeInfoRes.status !== 200) {
|
||||
throw new Error("테마 상세 조회 실패")
|
||||
}
|
||||
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 () {
|
||||
let userName, userContact
|
||||
group(`회원 연락처 조회`, function () {
|
||||
@ -198,14 +214,12 @@ export default function (data) {
|
||||
})
|
||||
|
||||
if (!isPendingReservationCreated) {
|
||||
console.log("회원의 예약 정보 입력 중 페이지 이탈")
|
||||
return;
|
||||
}
|
||||
|
||||
group(`결제 및 예약 확정`, function () {
|
||||
// 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트
|
||||
if (Math.random() <= 0.2) {
|
||||
console.log("결제 페이지에서의 이탈")
|
||||
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:
|
||||
init:
|
||||
mode: always
|
||||
schema-locations: classpath:schema/schema-mysql.sql
|
||||
schema-locations: classpath:schema/schema-h2.sql
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user