diff --git a/build.gradle b/build.gradle index 6082092..7c7121a 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ apply from: "gradle/spring.gradle" apply from: "gradle/logging.gradle" apply from: "gradle/monitor.gradle" apply from: "gradle/jetbrains.gradle" +apply from: "gradle/analytics.gradle" sentry { includeSourceContext = true diff --git a/gradle/analytics.gradle b/gradle/analytics.gradle new file mode 100644 index 0000000..a254196 --- /dev/null +++ b/gradle/analytics.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation "com.mixpanel:mixpanel-java:1.5.4" +} diff --git a/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt b/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt new file mode 100644 index 0000000..7309a30 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt @@ -0,0 +1,9 @@ +package org.gitanimals.core.event + +interface EventLogger { + + /** + * 분석 이벤트를 트래킹한다. 절대 예외를 throw 하지 않으며, 실패는 구현체 내부에서 로깅으로 처리된다. + */ + fun track(eventName: String, distinctId: String, properties: Map = emptyMap()) +} diff --git a/src/main/kotlin/org/gitanimals/core/event/MixpanelConfiguration.kt b/src/main/kotlin/org/gitanimals/core/event/MixpanelConfiguration.kt new file mode 100644 index 0000000..9ec1c0f --- /dev/null +++ b/src/main/kotlin/org/gitanimals/core/event/MixpanelConfiguration.kt @@ -0,0 +1,31 @@ +package org.gitanimals.core.event + +import com.mixpanel.mixpanelapi.MessageBuilder +import com.mixpanel.mixpanelapi.MixpanelAPI +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +@Configuration +class MixpanelConfiguration( + @Value("\${mixpanel.project.token:}") private val token: String, +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Bean + @Profile("!test") + fun mixpanelEventLogger(): EventLogger { + if (token.isBlank()) { + logger.warn("MIXPANEL_PROJECT_TOKEN is blank — falling back to NoOpEventLogger") + return NoOpEventLogger() + } + return MixpanelEventLogger(MixpanelAPI(), MessageBuilder(token)) + } + + @Bean + @Profile("test") + fun noOpEventLogger(): EventLogger = NoOpEventLogger() +} diff --git a/src/main/kotlin/org/gitanimals/core/event/MixpanelEventLogger.kt b/src/main/kotlin/org/gitanimals/core/event/MixpanelEventLogger.kt new file mode 100644 index 0000000..601af15 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/core/event/MixpanelEventLogger.kt @@ -0,0 +1,31 @@ +package org.gitanimals.core.event + +import com.mixpanel.mixpanelapi.ClientDelivery +import com.mixpanel.mixpanelapi.MessageBuilder +import com.mixpanel.mixpanelapi.MixpanelAPI +import org.gitanimals.core.GracefulShutdownDispatcher.gracefulLaunch +import org.json.JSONObject +import org.slf4j.LoggerFactory + +class MixpanelEventLogger( + private val mixpanelAPI: MixpanelAPI, + private val messageBuilder: MessageBuilder, +) : EventLogger { + + private val logger = LoggerFactory.getLogger(this::class.java) + + override fun track(eventName: String, distinctId: String, properties: Map) { + gracefulLaunch { + runCatching { + val props = JSONObject() + properties.forEach { (k, v) -> if (v != null) props.put(k, v) } + val message = messageBuilder.event(distinctId, eventName, props) + val delivery = ClientDelivery() + delivery.addMessage(message) + mixpanelAPI.deliver(delivery) + }.onFailure { + logger.warn("Failed to track Mixpanel event '{}': {}", eventName, it.message) + } + } + } +} diff --git a/src/main/kotlin/org/gitanimals/core/event/NoOpEventLogger.kt b/src/main/kotlin/org/gitanimals/core/event/NoOpEventLogger.kt new file mode 100644 index 0000000..4f7fec1 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/core/event/NoOpEventLogger.kt @@ -0,0 +1,12 @@ +package org.gitanimals.core.event + +import org.slf4j.LoggerFactory + +class NoOpEventLogger : EventLogger { + + private val logger = LoggerFactory.getLogger(this::class.java) + + override fun track(eventName: String, distinctId: String, properties: Map) { + logger.debug("NoOpEventLogger.track: event={}, distinctId={}", eventName, distinctId) + } +} diff --git a/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt b/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt index 5710b87..ee2f4d2 100644 --- a/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt +++ b/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt @@ -1,7 +1,9 @@ package org.gitanimals.gotcha.controller +import org.gitanimals.core.auth.InternalAuth import org.gitanimals.core.auth.RequiredUserEntryPoints import org.gitanimals.core.auth.UserEntryPoint +import org.gitanimals.core.event.EventLogger import org.gitanimals.gotcha.app.GotchaFacadeV3 import org.gitanimals.gotcha.app.response.GotchaResponseV3 import org.gitanimals.gotcha.controller.response.ErrorResponse @@ -13,6 +15,8 @@ import org.springframework.web.bind.annotation.* @RestController class GotchaController( private val gotchaFacadeV3: GotchaFacadeV3, + private val eventLogger: EventLogger, + private val internalAuth: InternalAuth, ) { @RequiredUserEntryPoints([UserEntryPoint.GITHUB]) @@ -26,6 +30,20 @@ class GotchaController( val gotchaResponses = gotchaFacadeV3.gotcha(token, gotchaType, count) + internalAuth.findUserId()?.let { userId -> + gotchaResponses.forEach { response -> + eventLogger.track( + eventName = "complete_gotcha", + distinctId = userId.toString(), + properties = mapOf( + "pet_persona" to response.name, + "cost_point" to gotchaType.point, + "user_id" to userId, + ), + ) + } + } + return mapOf("gotchaResults" to gotchaResponses) } diff --git a/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt b/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt index ebe331f..b7bc7c0 100644 --- a/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt +++ b/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper import io.jsonwebtoken.Jwts import org.gitanimals.core.AUTHORIZATION_EXCEPTION +import org.gitanimals.core.event.EventLogger import org.gitanimals.identity.app.AppleOauth2Api.AppleAuthKeyResponse import org.gitanimals.identity.domain.EntryPoint import org.gitanimals.identity.domain.UserService @@ -23,6 +24,7 @@ class AppleLoginFacade( private val appleOauth2Api: AppleOauth2Api, private val objectMapper: ObjectMapper, @Value("\${login.secret}") private val loginSecret: String, + private val eventLogger: EventLogger, ) { private val logger = LoggerFactory.getLogger(this::class.simpleName) @@ -49,6 +51,16 @@ class AppleLoginFacade( } } + eventLogger.track( + eventName = "complete_login", + distinctId = user.id.toString(), + properties = mapOf( + "provider" to "apple", + "user_id" to user.id, + "is_new_user" to !isExistsUser, + ), + ) + return tokenManager.createToken(user).withType() } diff --git a/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt b/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt index 78466c6..dd60721 100644 --- a/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt +++ b/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt @@ -1,5 +1,6 @@ package org.gitanimals.identity.app +import org.gitanimals.core.event.EventLogger import org.gitanimals.identity.domain.EntryPoint import org.gitanimals.identity.domain.UserService import org.springframework.stereotype.Service @@ -10,11 +11,13 @@ class GithubLoginFacade( private val userService: UserService, private val contributionApi: ContributionApi, private val tokenManager: TokenManager, + private val eventLogger: EventLogger, ) { fun login(code: String): String { val oauthUserResponse = githubOauth2Api.getOauthUsername(githubOauth2Api.getToken(code)) + var isNewUser = false val user = when (userService.existsByEntryPointAndAuthenticationId( entryPoint = EntryPoint.GITHUB, authenticationId = oauthUserResponse.id, @@ -58,6 +61,7 @@ class GithubLoginFacade( entryPoint = EntryPoint.GITHUB, ) } else { + isNewUser = true val contributedYears = contributionApi.getAllContributionYearsWithToken(oauthUserResponse.username) val contributionCountPerYears = @@ -77,6 +81,16 @@ class GithubLoginFacade( } } + eventLogger.track( + eventName = "complete_login", + distinctId = user.id.toString(), + properties = mapOf( + "provider" to "github", + "user_id" to user.id, + "is_new_user" to isNewUser, + ), + ) + return tokenManager.createToken(user).withType() } } diff --git a/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt b/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt index 369653d..03af387 100644 --- a/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt +++ b/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt @@ -1,12 +1,14 @@ package org.gitanimals.quiz.app import org.gitanimals.core.auth.InternalAuth +import org.gitanimals.core.event.EventLogger import org.gitanimals.quiz.app.request.CreateSolveQuizRequest import org.gitanimals.quiz.app.response.QuizContextResponse import org.gitanimals.quiz.app.response.TodaySolvedContextResponse import org.gitanimals.quiz.domain.approved.QuizService import org.gitanimals.quiz.domain.context.QuizSolveContext import org.gitanimals.quiz.domain.context.QuizSolveContextService +import org.gitanimals.quiz.domain.context.QuizSolveContextStatus import org.gitanimals.quiz.domain.core.Language import org.gitanimals.quiz.domain.core.Level import org.springframework.stereotype.Service @@ -16,6 +18,7 @@ class SolveQuizFacade( private val internalAuth: InternalAuth, private val quizService: QuizService, private val quizSolveContextService: QuizSolveContextService, + private val eventLogger: EventLogger, ) { fun createContext(locale: String, request: CreateSolveQuizRequest): Long { @@ -54,6 +57,17 @@ class SolveQuizFacade( val userId = internalAuth.getUserId() quizSolveContextService.solveQuiz(id, userId, answer) + + val context = quizSolveContextService.getQuizSolveContextByIdAndUserId(id, userId) + eventLogger.track( + eventName = "submit_quiz_answer", + distinctId = userId.toString(), + properties = mapOf( + "quiz_id" to id, + "is_correct" to (context.getStatus() in setOf(QuizSolveContextStatus.SUCCESS, QuizSolveContextStatus.DONE)), + "user_id" to userId, + ) + ) } fun getQuizById(id: Long): QuizSolveContext { diff --git a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt index 4620428..057bcea 100644 --- a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt +++ b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt @@ -1,5 +1,6 @@ package org.gitanimals.quiz.infra.hibernate +import org.gitanimals.core.event.EventLogger import org.gitanimals.quiz.domain.approved.Quiz import org.gitanimals.quiz.infra.event.NewQuizCreated import org.hibernate.event.spi.PostInsertEvent @@ -12,6 +13,7 @@ import org.springframework.stereotype.Component @Component class NewQuizCreatedInsertHibernateEventListener( private val applicationEventPublisher: ApplicationEventPublisher, + private val eventLogger: EventLogger, ) : PostInsertEventListener { private val logger = LoggerFactory.getLogger(this::class.simpleName) @@ -21,13 +23,23 @@ class NewQuizCreatedInsertHibernateEventListener( override fun onPostInsert(event: PostInsertEvent) { if (event.entity is Quiz) { + val quiz = event.entity as Quiz runCatching { applicationEventPublisher.publishEvent( - NewQuizCreated.from(event.entity as Quiz) + NewQuizCreated.from(quiz) ) }.onFailure { logger.error("Cannot publish NewQuizCreate event. cause ${it.message}", it) } + eventLogger.track( + eventName = "complete_make_quiz", + distinctId = quiz.userId.toString(), + properties = mapOf( + "quiz_id" to quiz.id, + "language" to quiz.language.name, + "user_id" to quiz.userId, + ) + ) } } } diff --git a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt index 26dee79..e1087aa 100644 --- a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt +++ b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt @@ -3,6 +3,7 @@ package org.gitanimals.quiz.infra.hibernate import org.gitanimals.core.GracefulShutdownDispatcher.gracefulLaunch import org.gitanimals.core.IdGenerator import org.gitanimals.core.clock +import org.gitanimals.core.event.EventLogger import org.gitanimals.inbox.domain.InboxType import org.gitanimals.quiz.app.IdentityApi import org.gitanimals.quiz.app.InboxApi @@ -34,9 +35,11 @@ class QuizSolveContextDoneHibernateEventListener( if (quizSolveContext.getStatus() == QuizSolveContextStatus.DONE) { applicationEventPublisher.publishEvent( QuizSolveContextDoneLogicDelegator.QuizSolveContextDone( + contextId = quizSolveContext.id, userId = quizSolveContext.userId, prize = quizSolveContext.getPrize(), status = quizSolveContext.getStatus(), + language = quizSolveContext.category.name, ) ) } @@ -47,14 +50,17 @@ class QuizSolveContextDoneHibernateEventListener( class QuizSolveContextDoneLogicDelegator( private val inboxApi: InboxApi, private val identityApi: IdentityApi, + private val eventLogger: EventLogger, ) { private val logger = LoggerFactory.getLogger(this::class.simpleName) data class QuizSolveContextDone( + val contextId: Long, val userId: Long, val prize: Int, val status: QuizSolveContextStatus, + val language: String, ) @EventListener(QuizSolveContextDone::class) @@ -91,6 +97,17 @@ class QuizSolveContextDoneLogicDelegator( it ) } + + eventLogger.track( + eventName = "complete_solve_quiz", + distinctId = event.userId.toString(), + properties = mapOf( + "context_id" to event.contextId, + "score" to event.prize, + "language" to event.language, + "user_id" to event.userId, + ) + ) } } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index dfaa0c6..a549aa5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -61,3 +61,6 @@ tokenizer.api.key=foo quiz.approve.token= relay.approve.token= gitanimals.admin.token= + +### Mixpanel ### +mixpanel.project.token=${MIXPANEL_PROJECT_TOKEN:} diff --git a/src/test/kotlin/org/gitanimals/core/event/MixpanelEventLoggerTest.kt b/src/test/kotlin/org/gitanimals/core/event/MixpanelEventLoggerTest.kt new file mode 100644 index 0000000..ccec707 --- /dev/null +++ b/src/test/kotlin/org/gitanimals/core/event/MixpanelEventLoggerTest.kt @@ -0,0 +1,65 @@ +package org.gitanimals.core.event + +import com.mixpanel.mixpanelapi.ClientDelivery +import com.mixpanel.mixpanelapi.MessageBuilder +import com.mixpanel.mixpanelapi.MixpanelAPI +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.json.JSONObject + +internal class MixpanelEventLoggerTest : DescribeSpec({ + + val mixpanelAPI = mockk(relaxed = true) + val token = "test-token" + val messageBuilder = MessageBuilder(token) + val logger = MixpanelEventLogger(mixpanelAPI, messageBuilder) + + describe("track") { + + context("when called with eventName, distinctId, and properties") { + it("delivers a ClientDelivery to MixpanelAPI") { + val deliverySlot = slot() + every { mixpanelAPI.deliver(capture(deliverySlot)) } returns Unit + + logger.track( + eventName = "complete_login", + distinctId = "user-123", + properties = mapOf("platform" to "github", "count" to 1) + ) + + // give coroutine time to execute + Thread.sleep(200) + + verify(exactly = 1) { mixpanelAPI.deliver(any()) } + } + } + + context("when properties contain null values") { + it("skips null values and still delivers") { + logger.track( + eventName = "complete_login", + distinctId = "user-456", + properties = mapOf("key" to null, "valid" to "value") + ) + + Thread.sleep(200) + + verify(atLeast = 1) { mixpanelAPI.deliver(any()) } + } + } + + context("when MixpanelAPI throws") { + it("swallows the exception and does not propagate") { + every { mixpanelAPI.deliver(any()) } throws RuntimeException("network error") + + // should not throw + logger.track("some_event", "user-789") + + Thread.sleep(200) + } + } + } +}) diff --git a/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt b/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt new file mode 100644 index 0000000..104c9d7 --- /dev/null +++ b/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt @@ -0,0 +1,54 @@ +package org.gitanimals.gotcha.controller + +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.gitanimals.core.auth.InternalAuth +import org.gitanimals.core.event.EventLogger +import org.gitanimals.gotcha.app.GotchaFacadeV3 +import org.gitanimals.gotcha.app.response.GotchaResponseV3 + +internal class GotchaControllerTest : DescribeSpec({ + + val gotchaFacadeV3 = mockk() + val eventLogger = mockk(relaxed = true) + val internalAuth = mockk() + val controller = GotchaController(gotchaFacadeV3, eventLogger, internalAuth) + + describe("gotchaV3 메소드는") { + context("gotcha가 성공하면") { + every { gotchaFacadeV3.gotcha(any(), any(), any()) } returns listOf( + GotchaResponseV3(name = "GOOSE", dropRate = "1.0"), + ) + every { internalAuth.findUserId() } returns 42L + + it("complete_gotcha 이벤트를 user_id와 함께 트래킹한다") { + controller.gotchaV3("token", "DEFAULT", 1) + + verify { + eventLogger.track( + eventName = "complete_gotcha", + distinctId = "42", + properties = match { it["user_id"] != null }, + ) + } + } + } + + context("InternalAuth.findUserId()가 null을 반환하면") { + every { gotchaFacadeV3.gotcha(any(), any(), any()) } returns listOf( + GotchaResponseV3(name = "GOOSE", dropRate = "1.0"), + ) + every { internalAuth.findUserId() } returns null + + it("이벤트를 트래킹하지 않고 silent하게 동작한다") { + controller.gotchaV3("token", "DEFAULT", 1) + + verify(exactly = 0) { + eventLogger.track(any(), any(), any()) + } + } + } + } +}) diff --git a/src/test/kotlin/org/gitanimals/identity/app/GithubLoginFacadeTest.kt b/src/test/kotlin/org/gitanimals/identity/app/GithubLoginFacadeTest.kt index 00ecc65..503eba6 100644 --- a/src/test/kotlin/org/gitanimals/identity/app/GithubLoginFacadeTest.kt +++ b/src/test/kotlin/org/gitanimals/identity/app/GithubLoginFacadeTest.kt @@ -6,6 +6,8 @@ import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.mockk.every +import io.mockk.verify +import org.gitanimals.core.event.EventLogger import org.gitanimals.identity.domain.* import org.gitanimals.identity.infra.JwtTokenManager import org.springframework.boot.autoconfigure.domain.EntityScan @@ -31,6 +33,7 @@ internal class GithubLoginFacadeTest( private val userRepository: UserRepository, @MockkBean(relaxed = true) private val githubOauth2Api: GithubOauth2Api, @MockkBean(relaxed = true) private val contributionApi: ContributionApi, + @MockkBean(relaxed = true) private val eventLogger: EventLogger, ) : DescribeSpec({ afterEach { @@ -110,5 +113,28 @@ internal class GithubLoginFacadeTest( userRepository.findAll().count() shouldBe 1 } } + + context("로그인에 성공했을때") { + val username = "track-user" + val authenticationId = "track-id" + + every { githubOauth2Api.getOauthUsername(any()) } returns GithubOauth2Api.OAuthUserResponse( + username = username, + id = authenticationId, + profileImage = "https://...", + ) + + it("complete_login 이벤트를 user_id와 함께 트래킹한다") { + githubLoginFacade.login("code") + + verify { + eventLogger.track( + eventName = "complete_login", + distinctId = any(), + properties = match { it["user_id"] != null }, + ) + } + } + } } }) diff --git a/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt b/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt new file mode 100644 index 0000000..6d84ffa --- /dev/null +++ b/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt @@ -0,0 +1,111 @@ +package org.gitanimals.quiz.app + +import com.ninjasquad.springmockk.MockkBean +import io.kotest.core.annotation.DisplayName +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.just +import io.mockk.runs +import io.mockk.verify +import org.gitanimals.core.auth.InternalAuth +import org.gitanimals.core.event.EventLogger +import org.gitanimals.quiz.app.request.CreateSolveQuizRequest +import org.gitanimals.quiz.domain.approved.QuizRepository +import org.gitanimals.quiz.domain.approved.QuizService +import org.gitanimals.quiz.domain.context.QuizSolveContextRepository +import org.gitanimals.quiz.domain.context.QuizSolveContextService +import org.gitanimals.quiz.domain.context.quizSolveContext +import org.gitanimals.quiz.domain.core.Category +import org.gitanimals.quiz.domain.core.Level +import org.gitanimals.quiz.domain.quiz.quiz +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource + +@DataJpaTest +@ContextConfiguration( + classes = [ + SolveQuizFacade::class, + QuizSolveContextService::class, + QuizService::class, + ] +) +@EntityScan(basePackages = ["org.gitanimals.quiz.domain"]) +@EnableJpaRepositories(basePackages = ["org.gitanimals.quiz.domain"]) +@DisplayName("QuizEventTrackingTest 클래스의") +@TestPropertySource("classpath:test.properties") +internal class QuizEventTrackingTest( + private val solveQuizFacade: SolveQuizFacade, + private val quizRepository: QuizRepository, + private val quizSolveContextRepository: QuizSolveContextRepository, + private val quizSolveContextService: QuizSolveContextService, + @MockkBean private val internalAuth: InternalAuth, + @MockkBean private val identityApi: IdentityApi, + @MockkBean private val eventLogger: EventLogger, +) : DescribeSpec({ + + beforeAny { + every { internalAuth.getUserId() } returns userId + every { eventLogger.track(any(), any(), any()) } just runs + } + + afterAny { + quizRepository.deleteAll() + quizSolveContextRepository.deleteAll() + } + + describe("answerQuizById 메소드는") { + context("퀴즈 답변 제출 후") { + val quizContext = quizSolveContextRepository.save(quizSolveContext(userId = userId)) + quizSolveContextService.getAndStartSolveQuizContext(quizContext.id, quizContext.userId) + + it("submit_quiz_answer 이벤트를 트래킹한다") { + solveQuizFacade.answerQuizById(quizContext.id, "YES") + + verify(exactly = 1) { + eventLogger.track( + eventName = "submit_quiz_answer", + distinctId = userId.toString(), + properties = match { props -> + props["quiz_id"] == quizContext.id && + props["user_id"] == userId + } + ) + } + } + } + + } + + describe("createContext 메소드는") { + context("퀴즈 컨텍스트 생성 시") { + quizRepository.saveAll( + listOf( + quiz(level = Level.EASY), + quiz(level = Level.EASY), + quiz(level = Level.MEDIUM), + quiz(level = Level.DIFFICULT), + quiz(level = Level.DIFFICULT), + ) + ) + every { identityApi.getUserByToken(any()) } returns defaultUser + + it("createContext가 정상 동작한다") { + solveQuizFacade.createContext("KR", CreateSolveQuizRequest(Category.BACKEND)) + } + } + } +}) { + + companion object { + private const val userId = 42L + + private val defaultUser = IdentityApi.UserResponse( + id = userId.toString(), + username = "testuser", + points = "1000", + ) + } +}