From 59a4b06d86a805d80abe84de813db9c3059be2ab Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Apr 2026 19:53:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-read):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/read/AdminAgentReadController.kt | 118 ++++++ .../read/AdminAgentReadQueryRepository.kt | 175 ++++++++ .../agent/read/AdminAgentReadService.kt | 126 ++++++ .../GetAdminAgentAssignedCreatorResponse.kt | 15 + .../agent/read/GetAdminAgentListResponse.kt | 19 + ...archAdminAgentAssignableCreatorResponse.kt | 15 + .../AdminAgentReadControllerSecurityTest.kt | 133 ++++++ .../read/AdminAgentReadControllerTest.kt | 173 ++++++++ ...minAgentReadCurrentMonthListSummaryTest.kt | 382 ++++++++++++++++++ .../agent/read/AdminAgentReadParityTest.kt | 323 +++++++++++++++ .../read/AdminAgentReadQueryRepositoryTest.kt | 123 ++++++ .../agent/read/AdminAgentReadServiceTest.kt | 159 ++++++++ 12 files changed, 1761 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentAssignedCreatorResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentListResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/SearchAdminAgentAssignableCreatorResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerSecurityTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadCurrentMonthListSummaryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadParityTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepositoryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadController.kt new file mode 100644 index 00000000..5b1360eb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadController.kt @@ -0,0 +1,118 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@PreAuthorize("hasRole('ADMIN')") +@RequestMapping("/admin/partner/agent") +class AdminAgentReadController( + private val service: AdminAgentReadService +) { + @GetMapping("/list") + fun getAgentList(pageable: Pageable) = ApiResponse.ok( + service.getAgentList(offset = pageable.offset, limit = pageable.pageSize.toLong()) + ) + + @GetMapping("/creator/search") + fun searchAssignableCreators( + @RequestParam(value = "search_word") searchWord: String, + pageable: Pageable + ) = ApiResponse.ok( + service.searchAssignableCreators(searchWord = searchWord, offset = pageable.offset, limit = pageable.pageSize.toLong()) + ) + + @GetMapping("/{agentId}/creator/list") + fun getAssignedCreators( + @PathVariable agentId: Long, + pageable: Pageable + ) = ApiResponse.ok( + service.getAssignedCreators(agentId = agentId, offset = pageable.offset, limit = pageable.pageSize.toLong()) + ) + + @GetMapping("/{agentId}/calculate/live-by-creator") + fun getCalculateLiveByCreator( + @PathVariable agentId: Long, + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + pageable: Pageable + ) = ApiResponse.ok( + service.getCalculateLiveByCreator( + agentId, + startDateStr, + endDateStr, + pageable.offset, + pageable.pageSize.toLong() + ) + ) + + @GetMapping("/{agentId}/calculate/content-by-creator") + fun getCalculateContentByCreator( + @PathVariable agentId: Long, + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + pageable: Pageable + ) = ApiResponse.ok( + service.getCalculateContentByCreator( + agentId, + startDateStr, + endDateStr, + pageable.offset, + pageable.pageSize.toLong() + ) + ) + + @GetMapping("/{agentId}/calculate/community-by-creator") + fun getCalculateCommunityByCreator( + @PathVariable agentId: Long, + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + pageable: Pageable + ) = ApiResponse.ok( + service.getCalculateCommunityByCreator( + agentId, + startDateStr, + endDateStr, + pageable.offset, + pageable.pageSize.toLong() + ) + ) + + @GetMapping("/{agentId}/calculate/channel-donation-by-creator") + fun getChannelDonationByCreator( + @PathVariable agentId: Long, + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + pageable: Pageable + ) = ApiResponse.ok( + service.getChannelDonationByCreator( + agentId, + startDateStr, + endDateStr, + pageable.offset, + pageable.pageSize.toLong() + ) + ) + + @GetMapping("/{agentId}/calculate/content-donation-by-creator") + fun getCalculateContentDonationByCreator( + @PathVariable agentId: Long, + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + pageable: Pageable + ) = ApiResponse.ok( + service.getCalculateContentDonationByCreator( + agentId, + startDateStr, + endDateStr, + pageable.offset, + pageable.pageSize.toLong() + ) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepository.kt new file mode 100644 index 00000000..f520a6f7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepository.kt @@ -0,0 +1,175 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.QMember +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.partner.agent.assignment.QAgentCreatorRelation.agentCreatorRelation +import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository +import org.springframework.stereotype.Repository +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +@Repository +class AdminAgentReadQueryRepository( + private val queryFactory: JPAQueryFactory, + private val calculateQueryRepository: AgentCalculateQueryRepository +) { + private val kstZoneId = ZoneId.of("Asia/Seoul") + private val utcZoneId = ZoneId.of("UTC") + + fun getAgentListTotalCount(): Int { + return queryFactory + .select(member.id.count()) + .from(member) + .where(member.role.eq(MemberRole.AGENT)) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getAgentList(offset: Long, limit: Long, currentTime: ZonedDateTime): List { + val kstCurrentTime = currentTime.withZoneSameInstant(kstZoneId) + val currentMonthStartKst = kstCurrentTime.toLocalDate().withDayOfMonth(1).atStartOfDay(kstZoneId) + val nextMonthStartKst = currentMonthStartKst.plusMonths(1) + val currentMonthStart = currentMonthStartKst.withZoneSameInstant(utcZoneId).toLocalDateTime() + val currentMonthEnd = nextMonthStartKst.withZoneSameInstant(utcZoneId).toLocalDateTime().minusNanos(1) + + return queryFactory + .select( + QGetAdminAgentListItem( + member.id, + member.nickname, + agentCreatorRelation.id.countDistinct().intValue(), + Expressions.numberTemplate(Int::class.java, "0"), + Expressions.numberTemplate(Int::class.java, "0"), + Expressions.numberTemplate(Int::class.java, "0"), + Expressions.numberTemplate(Int::class.java, "0"), + Expressions.numberTemplate(Int::class.java, "0") + ) + ) + .from(member) + .leftJoin(agentCreatorRelation) + .on( + agentCreatorRelation.agent.id.eq(member.id) + .and(isActiveAssignment(kstCurrentTime.toLocalDateTime())) + ) + .where(member.role.eq(MemberRole.AGENT)) + .groupBy(member.id, member.nickname) + .orderBy(member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + .map { item -> + item.copy( + liveAgentSettlementAmount = calculateQueryRepository + .getCalculateLiveByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) + .agentSettlementAmount, + contentAgentSettlementAmount = calculateQueryRepository + .getCalculateContentByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) + .agentSettlementAmount, + communityAgentSettlementAmount = calculateQueryRepository + .getCalculateCommunityByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) + .agentSettlementAmount, + contentDonationAgentSettlementAmount = calculateQueryRepository + .getCalculateContentDonationByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) + .agentSettlementAmount, + channelDonationAgentSettlementAmount = calculateQueryRepository + .getChannelDonationByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) + .agentSettlementAmount + ) + } + } + + fun searchAssignableCreatorsTotalCount(searchWord: String, currentTime: LocalDateTime): Int { + return queryFactory + .select(member.id.countDistinct()) + .from(member) + .leftJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(isActiveAssignment(currentTime)) + ) + .where( + member.role.eq(MemberRole.CREATOR) + .and(member.nickname.contains(searchWord)) + ) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun searchAssignableCreators( + searchWord: String, + offset: Long, + limit: Long, + currentTime: LocalDateTime + ): List { + val currentAgent = QMember("currentAgent") + + return queryFactory + .select( + QSearchAdminAgentAssignableCreatorItem( + member.id, + member.nickname, + currentAgent.id, + currentAgent.nickname + ) + ) + .from(member) + .leftJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(isActiveAssignment(currentTime)) + ) + .leftJoin(agentCreatorRelation.agent, currentAgent) + .where( + member.role.eq(MemberRole.CREATOR) + .and(member.nickname.contains(searchWord)) + ) + .orderBy(member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + fun getAssignedCreatorTotalCount(agentId: Long, currentTime: LocalDateTime): Int { + return queryFactory + .select(agentCreatorRelation.id.count()) + .from(agentCreatorRelation) + .where(agentCreatorRelation.agent.id.eq(agentId).and(isActiveAssignment(currentTime))) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getAssignedCreators( + agentId: Long, + offset: Long, + limit: Long, + currentTime: LocalDateTime + ): List { + return queryFactory + .select( + QGetAdminAgentAssignedCreatorItem( + agentCreatorRelation.creator.id, + agentCreatorRelation.creator.nickname, + agentCreatorRelation.assignedAt + ) + ) + .from(agentCreatorRelation) + .where(agentCreatorRelation.agent.id.eq(agentId).and(isActiveAssignment(currentTime))) + .orderBy(agentCreatorRelation.creator.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + private fun isActiveAssignment(currentTime: LocalDateTime): BooleanExpression { + return agentCreatorRelation.assignedAt.loe(currentTime) + .and(agentCreatorRelation.unassignedAt.isNull.or(agentCreatorRelation.unassignedAt.gt(currentTime))) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadService.kt new file mode 100644 index 00000000..02212238 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadService.kt @@ -0,0 +1,126 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorResponse +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +@Service +class AdminAgentReadService( + private val queryRepository: AdminAgentReadQueryRepository, + private val memberRepository: MemberRepository, + private val calculateService: AgentCalculateService +) { + private val kstZoneId = ZoneId.of("Asia/Seoul") + + @Transactional(readOnly = true) + fun getAgentList(offset: Long, limit: Long): GetAdminAgentListResponse { + val now = ZonedDateTime.now(kstZoneId) + return GetAdminAgentListResponse( + totalCount = queryRepository.getAgentListTotalCount(), + items = queryRepository.getAgentList(offset = offset, limit = limit, currentTime = now) + ) + } + + @Transactional(readOnly = true) + fun searchAssignableCreators(searchWord: String, offset: Long, limit: Long): SearchAdminAgentAssignableCreatorResponse { + if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length") + val now = LocalDateTime.now() + return SearchAdminAgentAssignableCreatorResponse( + totalCount = queryRepository.searchAssignableCreatorsTotalCount(searchWord = searchWord, currentTime = now), + items = queryRepository.searchAssignableCreators( + searchWord = searchWord, + offset = offset, + limit = limit, + currentTime = now + ) + ) + } + + @Transactional(readOnly = true) + fun getAssignedCreators(agentId: Long, offset: Long, limit: Long): GetAdminAgentAssignedCreatorResponse { + validateAgent(agentId) + val now = LocalDateTime.now() + return GetAdminAgentAssignedCreatorResponse( + totalCount = queryRepository.getAssignedCreatorTotalCount(agentId = agentId, currentTime = now), + items = queryRepository.getAssignedCreators(agentId = agentId, offset = offset, limit = limit, currentTime = now) + ) + } + + @Transactional(readOnly = true) + fun getCalculateLiveByCreator( + agentId: Long, + startDateStr: String, + endDateStr: String, + offset: Long, + limit: Long + ): GetAgentSettlementByCreatorResponse { + validateAgent(agentId) + return calculateService.getCalculateLiveByCreator(startDateStr, endDateStr, agentId, offset, limit) + } + + @Transactional(readOnly = true) + fun getCalculateContentByCreator( + agentId: Long, + startDateStr: String, + endDateStr: String, + offset: Long, + limit: Long + ): GetAgentSettlementByCreatorResponse { + validateAgent(agentId) + return calculateService.getCalculateContentByCreator(startDateStr, endDateStr, agentId, offset, limit) + } + + @Transactional(readOnly = true) + fun getCalculateCommunityByCreator( + agentId: Long, + startDateStr: String, + endDateStr: String, + offset: Long, + limit: Long + ): GetAgentSettlementByCreatorResponse { + validateAgent(agentId) + return calculateService.getCalculateCommunityByCreator(startDateStr, endDateStr, agentId, offset, limit) + } + + @Transactional(readOnly = true) + fun getCalculateContentDonationByCreator( + agentId: Long, + startDateStr: String, + endDateStr: String, + offset: Long, + limit: Long + ): GetAgentSettlementByCreatorResponse { + validateAgent(agentId) + return calculateService.getCalculateContentDonationByCreator(startDateStr, endDateStr, agentId, offset, limit) + } + + @Transactional(readOnly = true) + fun getChannelDonationByCreator( + agentId: Long, + startDateStr: String, + endDateStr: String, + offset: Long, + limit: Long + ): GetAgentChannelDonationSettlementByCreatorResponse { + validateAgent(agentId) + return calculateService.getChannelDonationByCreator(startDateStr, endDateStr, agentId, offset, limit) + } + + private fun validateAgent(agentId: Long) { + val member = memberRepository.findById(agentId).orElseThrow { + SodaException(messageKey = "partner.agent.ratio.agent_not_found") + } + + if (member.role != MemberRole.AGENT) { + throw SodaException(messageKey = "partner.agent.ratio.invalid_agent") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentAssignedCreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentAssignedCreatorResponse.kt new file mode 100644 index 00000000..cace91e2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentAssignedCreatorResponse.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import com.querydsl.core.annotations.QueryProjection +import java.time.LocalDateTime + +data class GetAdminAgentAssignedCreatorResponse( + val totalCount: Int, + val items: List +) + +data class GetAdminAgentAssignedCreatorItem @QueryProjection constructor( + val creatorId: Long, + val creatorNickname: String, + val assignedAt: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentListResponse.kt new file mode 100644 index 00000000..a0227e71 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentListResponse.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import com.querydsl.core.annotations.QueryProjection + +data class GetAdminAgentListResponse( + val totalCount: Int, + val items: List +) + +data class GetAdminAgentListItem @QueryProjection constructor( + val agentId: Long, + val agentNickname: String, + val assignedCreatorCount: Int, + val liveAgentSettlementAmount: Int = 0, + val contentAgentSettlementAmount: Int = 0, + val communityAgentSettlementAmount: Int = 0, + val contentDonationAgentSettlementAmount: Int = 0, + val channelDonationAgentSettlementAmount: Int = 0 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/SearchAdminAgentAssignableCreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/SearchAdminAgentAssignableCreatorResponse.kt new file mode 100644 index 00000000..da152316 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/SearchAdminAgentAssignableCreatorResponse.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import com.querydsl.core.annotations.QueryProjection + +data class SearchAdminAgentAssignableCreatorResponse( + val totalCount: Int, + val items: List +) + +data class SearchAdminAgentAssignableCreatorItem @QueryProjection constructor( + val creatorId: Long, + val creatorNickname: String, + val currentAgentId: Long?, + val currentAgentNickname: String? +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerSecurityTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerSecurityTest.kt new file mode 100644 index 00000000..d6b1fa4d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerSecurityTest.kt @@ -0,0 +1,133 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpStatus +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.HttpStatusEntryPoint +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import javax.servlet.http.HttpServletResponse + +@WebMvcTest(AdminAgentReadController::class) +@Import(AdminAgentReadControllerSecurityTest.TestSecurityConfig::class) +class AdminAgentReadControllerSecurityTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var service: AdminAgentReadService + + @MockBean + private lateinit var countryContext: CountryContext + + @MockBean + private lateinit var langContext: LangContext + + @MockBean + private lateinit var sodaMessageSource: SodaMessageSource + + @TestConfiguration + @EnableGlobalMethodSecurity(prePostEnabled = true) + class TestSecurityConfig { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + return http + .csrf().disable() + .authorizeRequests() + .antMatchers("/admin/partner/agent/**").hasRole("ADMIN") + .anyRequest().permitAll() + .and() + .exceptionHandling() + .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) } + .and() + .build() + } + } + + @Test + @DisplayName("관리자 권한이면 에이전트 목록 조회에 성공한다") + fun shouldAllowAdminRole() { + Mockito.`when`(service.getAgentList(offset = 0L, limit = 20L)).thenReturn( + GetAdminAgentListResponse( + totalCount = 1, + items = listOf( + GetAdminAgentListItem( + agentId = 11L, + agentNickname = "agent-a", + assignedCreatorCount = 2, + liveAgentSettlementAmount = 131, + contentAgentSettlementAmount = 80, + communityAgentSettlementAmount = 55, + contentDonationAgentSettlementAmount = 12, + channelDonationAgentSettlementAmount = 397 + ) + ) + ) + ) + + val result = mockMvc.perform( + get("/admin/partner/agent/list") + .param("page", "0") + .param("size", "20") + .with(user("admin").roles("ADMIN")) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andReturn() + + println(result.response.contentAsString) + } + + @Test + @DisplayName("익명 사용자는 관리자 조회 API에 접근할 수 없다") + fun shouldRejectAnonymousUser() { + mockMvc.perform( + get("/admin/partner/agent/list") + .param("page", "0") + .param("size", "20") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("에이전트 권한 사용자는 관리자 조회 API에 접근할 수 없다") + fun shouldRejectAgentRole() { + mockMvc.perform( + get("/admin/partner/agent/list") + .param("page", "0") + .param("size", "20") + .with(user("agent").roles("AGENT")) + ) + .andExpect(status().isForbidden) + } + + @Test + @DisplayName("일반 사용자 권한 사용자는 관리자 조회 API에 접근할 수 없다") + fun shouldRejectUserRole() { + mockMvc.perform( + get("/admin/partner/agent/list") + .param("page", "0") + .param("size", "20") + .with(user("user").roles("USER")) + ) + .andExpect(status().isForbidden) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerTest.kt new file mode 100644 index 00000000..241bd1b3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerTest.kt @@ -0,0 +1,173 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorItem +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorResponse +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementTotal +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorResponse +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorTotal +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest +import java.time.LocalDateTime + +class AdminAgentReadControllerTest { + private lateinit var service: AdminAgentReadService + private lateinit var controller: AdminAgentReadController + + @BeforeEach + fun setup() { + service = Mockito.mock(AdminAgentReadService::class.java) + controller = AdminAgentReadController(service) + } + + @Test + @DisplayName("관리자 컨트롤러는 에이전트 목록 조회 파라미터를 서비스로 전달한다") + fun shouldForwardAgentListRequest() { + val body = GetAdminAgentListResponse( + totalCount = 1, + items = listOf( + GetAdminAgentListItem( + agentId = 11L, + agentNickname = "agent-a", + assignedCreatorCount = 2, + liveAgentSettlementAmount = 131, + contentAgentSettlementAmount = 80, + communityAgentSettlementAmount = 55, + contentDonationAgentSettlementAmount = 12, + channelDonationAgentSettlementAmount = 397 + ) + ) + ) + Mockito.`when`(service.getAgentList(offset = 20L, limit = 20L)).thenReturn(body) + + val response = controller.getAgentList(PageRequest.of(1, 20)) + + assertEquals(true, response.success) + assertEquals(2, response.data!!.items.first().assignedCreatorCount) + assertEquals(131, response.data!!.items.first().liveAgentSettlementAmount) + assertEquals(397, response.data!!.items.first().channelDonationAgentSettlementAmount) + Mockito.verify(service).getAgentList(offset = 20L, limit = 20L) + } + + @Test + @DisplayName("관리자 컨트롤러는 크리에이터 검색 파라미터를 서비스로 전달한다") + fun shouldForwardCreatorSearchRequest() { + val body = SearchAdminAgentAssignableCreatorResponse( + totalCount = 1, + items = listOf( + SearchAdminAgentAssignableCreatorItem( + creatorId = 21L, + creatorNickname = "creator-a", + currentAgentId = 11L, + currentAgentNickname = "agent-a" + ) + ) + ) + Mockito.`when`(service.searchAssignableCreators(searchWord = "creator", offset = 0L, limit = 10L)).thenReturn(body) + + val response = controller.searchAssignableCreators(searchWord = "creator", pageable = PageRequest.of(0, 10)) + + assertEquals(true, response.success) + assertEquals(11L, response.data!!.items.first().currentAgentId) + Mockito.verify(service).searchAssignableCreators(searchWord = "creator", offset = 0L, limit = 10L) + } + + @Test + @DisplayName("관리자 컨트롤러는 소속 크리에이터 목록 조회 파라미터를 서비스로 전달한다") + fun shouldForwardAssignedCreatorListRequest() { + val body = GetAdminAgentAssignedCreatorResponse( + totalCount = 1, + items = listOf( + GetAdminAgentAssignedCreatorItem( + creatorId = 21L, + creatorNickname = "creator-a", + assignedAt = LocalDateTime.of(2026, 4, 1, 10, 0) + ) + ) + ) + Mockito.`when`(service.getAssignedCreators(agentId = 11L, offset = 10L, limit = 5L)).thenReturn(body) + + val response = controller.getAssignedCreators(agentId = 11L, pageable = PageRequest.of(2, 5)) + + assertEquals(true, response.success) + assertEquals(21L, response.data!!.items.first().creatorId) + Mockito.verify(service).getAssignedCreators(agentId = 11L, offset = 10L, limit = 5L) + } + + @Test + @DisplayName("관리자 컨트롤러는 라이브 정산 조회 파라미터를 서비스로 전달한다") + fun shouldForwardLiveSettlementRequest() { + val body = GetAgentSettlementByCreatorResponse( + totalCount = 1, + total = GetAgentSettlementByCreatorTotal(1, 50, 5000, 330, 3736, 123, 3613, 131), + items = listOf(GetAgentSettlementByCreatorItem(21L, "creator-a", 1, 50, 5000, 330, 3736, 123, 3613, 131)) + ) + Mockito.`when`( + service.getCalculateLiveByCreator( + agentId = 11L, + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + offset = 0L, + limit = 20L + ) + ).thenReturn(body) + + val response = controller.getCalculateLiveByCreator( + agentId = 11L, + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + pageable = PageRequest.of(0, 20) + ) + + assertEquals(true, response.success) + assertEquals(131, response.data!!.items.first().agentSettlementAmount) + Mockito.verify(service).getCalculateLiveByCreator(11L, "2026-04-01", "2026-04-30", 0L, 20L) + } + + @Test + @DisplayName("관리자 컨트롤러는 채널후원 정산 조회 파라미터를 서비스로 전달한다") + fun shouldForwardChannelDonationSettlementRequest() { + val body = GetAgentChannelDonationSettlementByCreatorResponse( + totalCount = 1, + total = GetAgentChannelDonationSettlementTotal(1, 50, 5000, 330, 3970, 131, 3839, 397), + items = listOf( + GetAgentChannelDonationSettlementByCreatorItem( + 21L, + "creator-a", + 1, + 50, + 5000, + 330, + 3970, + 131, + 3839, + 397 + ) + ) + ) + Mockito.`when`( + service.getChannelDonationByCreator( + agentId = 11L, + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + offset = 0L, + limit = 20L + ) + ).thenReturn(body) + + val response = controller.getChannelDonationByCreator( + agentId = 11L, + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + pageable = PageRequest.of(0, 20) + ) + + assertEquals(true, response.success) + assertEquals(397, response.data!!.items.first().agentSettlementAmount) + Mockito.verify(service).getChannelDonationByCreator(11L, "2026-04-01", "2026-04-30", 0L, 20L) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadCurrentMonthListSummaryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadCurrentMonthListSummaryTest.kt new file mode 100644 index 00000000..3615c656 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadCurrentMonthListSummaryTest.kt @@ -0,0 +1,382 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatio +import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatioRepository +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository +import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository +import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import java.time.Month +import java.time.ZoneId +import java.time.ZonedDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) +@Import(QueryDslConfig::class) +class AdminAgentReadCurrentMonthListSummaryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val relationRepository: AgentCreatorRelationRepository, + private val creatorSettlementRatioRepository: CreatorSettlementRatioRepository, + private val audioContentRepository: AudioContentRepository, + private val orderRepository: OrderRepository, + private val liveRoomRepository: LiveRoomRepository, + private val creatorCommunityRepository: CreatorCommunityRepository, + private val useCanRepository: UseCanRepository, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val snapshotRepository: AgentSettlementSnapshotRepository, + private val entityManager: EntityManager +) { + private lateinit var repository: AdminAgentReadQueryRepository + private lateinit var calculateService: AgentCalculateService + + @BeforeEach + fun setup() { + val calculateQueryRepository = AgentCalculateQueryRepository(queryFactory, entityManager) + repository = AdminAgentReadQueryRepository(queryFactory, calculateQueryRepository) + calculateService = AgentCalculateService(calculateQueryRepository, snapshotRepository) + registerMysqlDateFunctions() + } + + @Test + @DisplayName("에이전트 목록 조회는 현재 월 5종 정산 합계를 포함하고 내역이 없으면 0을 반환한다") + fun shouldReturnCurrentMonthSettlementSummariesAndZeroForEmptyAgents() { + val now = ZonedDateTime.of(LocalDateTime.of(2026, 4, 10, 12, 0), ZoneId.of("Asia/Seoul")) + val agentWithRows = saveMember("agent-with-rows", MemberRole.AGENT) + val emptyAgent = saveMember("agent-empty", MemberRole.AGENT) + seedCurrentMonthSettlementFixtures(agentWithRows, now.toLocalDateTime()) + + val items = repository.getAgentList(offset = 0, limit = 20, currentTime = now) + val emptyItem = items.first { it.agentId == emptyAgent.id } + val agentItem = items.first { it.agentId == agentWithRows.id } + + assertEquals(0, emptyItem.liveAgentSettlementAmount) + assertEquals(0, emptyItem.contentAgentSettlementAmount) + assertEquals(0, emptyItem.communityAgentSettlementAmount) + assertEquals(0, emptyItem.contentDonationAgentSettlementAmount) + assertEquals(0, emptyItem.channelDonationAgentSettlementAmount) + + assertEquals( + calculateService + .getCalculateLiveByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L) + .total.agentSettlementAmount, + agentItem.liveAgentSettlementAmount + ) + assertEquals( + calculateService + .getCalculateContentByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L) + .total.agentSettlementAmount, + agentItem.contentAgentSettlementAmount + ) + assertEquals( + calculateService + .getCalculateCommunityByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L) + .total.agentSettlementAmount, + agentItem.communityAgentSettlementAmount + ) + assertEquals( + calculateService + .getCalculateContentDonationByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L) + .total.agentSettlementAmount, + agentItem.contentDonationAgentSettlementAmount + ) + assertEquals( + calculateService + .getChannelDonationByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L) + .total.agentSettlementAmount, + agentItem.channelDonationAgentSettlementAmount + ) + } + + @Test + @DisplayName("에이전트 목록 현재 월 경계는 Asia/Seoul 기준으로 계산한다") + fun shouldCalculateCurrentMonthBoundaryInAsiaSeoul() { + val currentTime = ZonedDateTime.of( + LocalDateTime.of(2026, 3, 31, 15, 30), + ZoneId.of("UTC") + ) + val agent = saveMember("agent-kst-boundary", MemberRole.AGENT) + + seedUtcBoundaryFixtures(agent) + + val items = repository.getAgentList(offset = 0, limit = 20, currentTime = currentTime) + val item = items.first { it.agentId == agent.id } + + assertEquals(true, item.liveAgentSettlementAmount > 0) + assertEquals(true, item.contentAgentSettlementAmount > 0) + assertEquals(true, item.communityAgentSettlementAmount > 0) + assertEquals(true, item.contentDonationAgentSettlementAmount > 0) + assertEquals(true, item.channelDonationAgentSettlementAmount > 0) + } + + @Test + @DisplayName("에이전트 목록 현재 월 경계는 UTC 조회 기준으로는 전월 말 15시부터 시작될 수 있다") + fun shouldIncludeUtcRowsThatBelongToKstMonthStart() { + val currentTime = ZonedDateTime.of( + LocalDateTime.of(2026, 3, 31, 15, 30), + ZoneId.of("UTC") + ) + val agent = saveMember("agent-kst-utc-range", MemberRole.AGENT) + + seedUtcBoundaryFixtures(agent) + + val items = repository.getAgentList(offset = 0, limit = 20, currentTime = currentTime) + val item = items.first { it.agentId == agent.id } + + assertEquals(Month.APRIL, currentTime.withZoneSameInstant(ZoneId.of("Asia/Seoul")).month) + assertEquals(true, item.liveAgentSettlementAmount > 0) + } + + private fun seedCurrentMonthSettlementFixtures(agent: Member, now: LocalDateTime) { + val creator = saveMember("creator-${agent.nickname}", MemberRole.CREATOR) + val buyer = saveMember("buyer-${agent.nickname}", MemberRole.USER) + val sender = saveMember("sender-${agent.nickname}", MemberRole.USER) + + saveRelation(agent, creator, assignedAt = now.withDayOfMonth(1).minusDays(1)) + saveCreatorSettlementRatio(creator, live = 70, content = 60, community = 65) + + val liveRoom = saveLiveRoom(creator, now.withDayOfMonth(5).withHour(8)) + saveLiveUseCan(sender, liveRoom, 10, now.withDayOfMonth(5).withHour(9)) + saveLiveUseCan(sender, liveRoom, 20, now.withDayOfMonth(5).withHour(10)) + + val paidContent = saveAudioContent(creator, "content-${agent.nickname}", price = 50, settlementRatio = 80) + val donationContent = saveAudioContent(creator, "donation-${agent.nickname}", price = 0, settlementRatio = null) + saveOrder(buyer, creator, paidContent, now.withDayOfMonth(6).withHour(11)) + + val communityPost = saveCommunityPost(creator, 10) + saveCommunityUseCan(buyer, communityPost, 15, now.withDayOfMonth(7).withHour(12)) + + saveContentDonationUseCan(buyer, donationContent, 12, now.withDayOfMonth(8).withHour(13)) + + val channelDonation = saveChannelDonationUseCan(sender, 40, now.withDayOfMonth(9).withHour(14)) + saveUseCanCalculate(channelDonation, creator.id!!, 15, PaymentGateway.PG) + saveUseCanCalculate(channelDonation, creator.id!!, 25, PaymentGateway.GOOGLE_IAP) + } + + private fun seedUtcBoundaryFixtures(agent: Member) { + val creator = saveMember("creator-${agent.nickname}", MemberRole.CREATOR) + val buyer = saveMember("buyer-${agent.nickname}", MemberRole.USER) + val sender = saveMember("sender-${agent.nickname}", MemberRole.USER) + + saveRelation(agent, creator, assignedAt = LocalDateTime.of(2026, 3, 1, 0, 0)) + saveCreatorSettlementRatio(creator, live = 70, content = 60, community = 65) + + val liveRoom = saveLiveRoom(creator, LocalDateTime.of(2026, 3, 31, 15, 10)) + saveLiveUseCan(sender, liveRoom, 10, LocalDateTime.of(2026, 3, 31, 15, 30)) + saveLiveUseCan(sender, liveRoom, 20, LocalDateTime.of(2026, 3, 31, 15, 40)) + + val paidContent = saveAudioContent(creator, "content-${agent.nickname}", price = 50, settlementRatio = 80) + val donationContent = saveAudioContent(creator, "donation-${agent.nickname}", price = 0, settlementRatio = null) + saveOrder(buyer, creator, paidContent, LocalDateTime.of(2026, 3, 31, 15, 50)) + + val communityPost = saveCommunityPost(creator, 10) + saveCommunityUseCan(buyer, communityPost, 15, LocalDateTime.of(2026, 3, 31, 15, 55)) + + saveContentDonationUseCan(buyer, donationContent, 12, LocalDateTime.of(2026, 3, 31, 15, 58)) + + val channelDonation = saveChannelDonationUseCan(sender, 40, LocalDateTime.of(2026, 3, 31, 15, 59)) + saveUseCanCalculate(channelDonation, creator.id!!, 15, PaymentGateway.PG) + saveUseCanCalculate(channelDonation, creator.id!!, 25, PaymentGateway.GOOGLE_IAP) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveRelation(agent: Member, creator: Member, assignedAt: LocalDateTime) { + val relation = AgentCreatorRelation() + relation.agent = agent + relation.creator = creator + relation.assignedAt = assignedAt + relation.unassignedAt = null + relationRepository.saveAndFlush(relation) + } + + private fun saveCreatorSettlementRatio(creator: Member, live: Int, content: Int, community: Int) { + val ratio = CreatorSettlementRatio( + subsidy = 0, + liveSettlementRatio = live, + contentSettlementRatio = content, + communitySettlementRatio = community + ) + ratio.member = creator + creatorSettlementRatioRepository.saveAndFlush(ratio) + } + + private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime): LiveRoom { + val room = LiveRoom( + title = "live-room", + notice = "notice", + beginDateTime = beginDateTime, + numberOfPeople = 10, + isAdult = false, + price = 10 + ) + room.member = creator + return liveRoomRepository.saveAndFlush(room) + } + + private fun saveLiveUseCan(sender: Member, room: LiveRoom, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.LIVE, + can = can, + rewardCan = 0 + ) + useCan.member = sender + useCan.room = room + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveAudioContent(creator: Member, title: String, price: Int, settlementRatio: Int?): AudioContent { + val theme = AudioContentTheme( + theme = "theme-$title", + image = "image-$title.png" + ) + entityManager.persist(theme) + + val audioContent = AudioContent( + title = title, + detail = "detail-$title", + languageCode = "ko", + price = price, + settlementRatio = settlementRatio + ) + audioContent.theme = theme + audioContent.member = creator + audioContent.isActive = true + return audioContentRepository.saveAndFlush(audioContent) + } + + private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime): Order { + val order = Order(type = OrderType.KEEP) + order.member = buyer + order.creator = creator + order.audioContent = content + val saved = orderRepository.saveAndFlush(order) + updateOrderCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveCommunityPost(creator: Member, price: Int): CreatorCommunity { + val post = CreatorCommunity( + content = "community-content-$price", + price = price, + isCommentAvailable = true, + isAdult = false + ) + post.member = creator + return creatorCommunityRepository.saveAndFlush(post) + } + + private fun saveCommunityUseCan(buyer: Member, post: CreatorCommunity, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.PAID_COMMUNITY_POST, + can = can, + rewardCan = 0 + ) + useCan.member = buyer + useCan.communityPost = post + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveContentDonationUseCan(buyer: Member, content: AudioContent, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.DONATION, + can = can, + rewardCan = 0 + ) + useCan.member = buyer + useCan.audioContent = content + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveChannelDonationUseCan(sender: Member, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.CHANNEL_DONATION, + can = can, + rewardCan = 0 + ) + useCan.member = sender + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveUseCanCalculate(useCan: UseCan, recipientCreatorId: Long, can: Int, paymentGateway: PaymentGateway) { + val useCanCalculate = UseCanCalculate( + can = can, + paymentGateway = paymentGateway, + status = UseCanCalculateStatus.RECEIVED + ) + useCanCalculate.useCan = useCan + useCanCalculate.recipientCreatorId = recipientCreatorId + useCanCalculateRepository.saveAndFlush(useCanCalculate) + } + + private fun updateUseCanCreatedAt(useCanId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update UseCan u set u.createdAt = :createdAt where u.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", useCanId) + .executeUpdate() + } + + private fun updateOrderCreatedAt(orderId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update Order o set o.createdAt = :createdAt where o.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", orderId) + .executeUpdate() + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadParityTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadParityTest.kt new file mode 100644 index 00000000..9c2d5ad7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadParityTest.kt @@ -0,0 +1,323 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatio +import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatioRepository +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository +import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository +import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) +@Import(QueryDslConfig::class) +class AdminAgentReadParityTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val relationRepository: AgentCreatorRelationRepository, + private val creatorSettlementRatioRepository: CreatorSettlementRatioRepository, + private val audioContentRepository: AudioContentRepository, + private val orderRepository: OrderRepository, + private val liveRoomRepository: LiveRoomRepository, + private val creatorCommunityRepository: CreatorCommunityRepository, + private val useCanRepository: UseCanRepository, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val snapshotRepository: AgentSettlementSnapshotRepository, + private val entityManager: EntityManager +) { + private var seededAgentId: Long = 0L + private lateinit var agentService: AgentCalculateService + private lateinit var adminService: AdminAgentReadService + + @BeforeEach + fun setup() { + registerMysqlDateFunctions() + val queryRepository = AgentCalculateQueryRepository(queryFactory, entityManager) + val adminQueryRepository = AdminAgentReadQueryRepository(queryFactory, queryRepository) + agentService = AgentCalculateService(queryRepository, snapshotRepository) + adminService = AdminAgentReadService(adminQueryRepository, memberRepository, agentService) + seededAgentId = seedParityFixtures() + } + + @Test + @DisplayName("관리자 라이브 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") + fun shouldMatchLiveSettlementBetweenAdminAndAgent() { + val agentResponse = agentService.getCalculateLiveByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) + val adminResponse = adminService.getCalculateLiveByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) + + assertEquals(agentResponse, adminResponse) + } + + @Test + @DisplayName("관리자 콘텐츠 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") + fun shouldMatchContentSettlementBetweenAdminAndAgent() { + val agentResponse = agentService.getCalculateContentByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) + val adminResponse = adminService.getCalculateContentByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) + + assertEquals(agentResponse.totalCount, adminResponse.totalCount) + assertEquals(agentResponse.total, adminResponse.total) + assertEquals(agentResponse.items, adminResponse.items) + } + + @Test + @DisplayName("관리자 커뮤니티 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") + fun shouldMatchCommunitySettlementBetweenAdminAndAgent() { + val agentResponse = agentService.getCalculateCommunityByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) + val adminResponse = adminService.getCalculateCommunityByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) + + assertEquals(agentResponse.totalCount, adminResponse.totalCount) + assertEquals(agentResponse.total, adminResponse.total) + assertEquals(agentResponse.items, adminResponse.items) + } + + @Test + @DisplayName("관리자 채널후원 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") + fun shouldMatchChannelDonationSettlementBetweenAdminAndAgent() { + val agentResponse = agentService.getChannelDonationByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) + val adminResponse = adminService.getChannelDonationByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) + + assertEquals(agentResponse.totalCount, adminResponse.totalCount) + assertEquals(agentResponse.total, adminResponse.total) + assertEquals(agentResponse.items, adminResponse.items) + } + + @Test + @DisplayName("관리자 콘텐츠후원 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") + fun shouldMatchContentDonationSettlementBetweenAdminAndAgent() { + val agentResponse = agentService.getCalculateContentDonationByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) + val adminResponse = adminService.getCalculateContentDonationByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) + + assertEquals(agentResponse.totalCount, adminResponse.totalCount) + assertEquals(agentResponse.total, adminResponse.total) + assertEquals(agentResponse.items, adminResponse.items) + } + + private fun seedParityFixtures(): Long { + val agent = saveMember("agent-parity", MemberRole.AGENT) + val creator = saveMember("creator-parity", MemberRole.CREATOR) + val buyer = saveMember("buyer-parity", MemberRole.USER) + val sender = saveMember("sender-parity", MemberRole.USER) + + saveRelation(agent, creator) + saveCreatorSettlementRatio(creator, live = 70, content = 60, community = 65) + + val liveRoom = saveLiveRoom(creator) + saveLiveUseCan(sender, liveRoom, 10, LocalDateTime.of(2026, 2, 20, 9, 0, 0)) + saveLiveUseCan(sender, liveRoom, 20, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + + val paidContent = saveAudioContent(creator, "content-parity", price = 50, settlementRatio = 80) + val donationContent = saveAudioContent(creator, "content-donation-parity", price = 0, settlementRatio = null) + saveOrder(buyer, creator, paidContent, LocalDateTime.of(2026, 2, 20, 11, 0, 0)) + + val communityPost = saveCommunityPost(creator, 10) + saveCommunityUseCan(buyer, communityPost, 15, LocalDateTime.of(2026, 2, 20, 12, 0, 0)) + + saveContentDonationUseCan(buyer, donationContent, 12, LocalDateTime.of(2026, 2, 20, 13, 0, 0)) + + val channelDonation = saveChannelDonationUseCan(sender, 40, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + saveUseCanCalculate(channelDonation, creator.id!!, 15, PaymentGateway.PG) + saveUseCanCalculate(channelDonation, creator.id!!, 25, PaymentGateway.GOOGLE_IAP) + + return agent.id!! + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveRelation(agent: Member, creator: Member) { + val relation = AgentCreatorRelation() + relation.agent = agent + relation.creator = creator + relation.assignedAt = LocalDateTime.of(2026, 2, 1, 0, 0, 0) + relation.unassignedAt = null + relationRepository.saveAndFlush(relation) + } + + private fun saveCreatorSettlementRatio(creator: Member, live: Int, content: Int, community: Int) { + val ratio = CreatorSettlementRatio( + subsidy = 0, + liveSettlementRatio = live, + contentSettlementRatio = content, + communitySettlementRatio = community + ) + ratio.member = creator + creatorSettlementRatioRepository.saveAndFlush(ratio) + } + + private fun saveLiveRoom(creator: Member): LiveRoom { + val room = LiveRoom( + title = "live-room", + notice = "notice", + beginDateTime = LocalDateTime.of(2026, 2, 20, 8, 0, 0), + numberOfPeople = 10, + isAdult = false, + price = 10 + ) + room.member = creator + return liveRoomRepository.saveAndFlush(room) + } + + private fun saveLiveUseCan(sender: Member, room: LiveRoom, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.LIVE, + can = can, + rewardCan = 0 + ) + useCan.member = sender + useCan.room = room + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveAudioContent(creator: Member, title: String, price: Int, settlementRatio: Int?): AudioContent { + val theme = AudioContentTheme( + theme = "theme-$title", + image = "image-$title.png" + ) + entityManager.persist(theme) + + val audioContent = AudioContent( + title = title, + detail = "detail-$title", + languageCode = "ko", + price = price, + settlementRatio = settlementRatio + ) + audioContent.theme = theme + audioContent.member = creator + audioContent.isActive = true + return audioContentRepository.saveAndFlush(audioContent) + } + + private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime): Order { + val order = Order(type = OrderType.KEEP) + order.member = buyer + order.creator = creator + order.audioContent = content + val saved = orderRepository.saveAndFlush(order) + updateOrderCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveCommunityPost(creator: Member, price: Int): CreatorCommunity { + val post = CreatorCommunity( + content = "community-content-$price", + price = price, + isCommentAvailable = true, + isAdult = false + ) + post.member = creator + return creatorCommunityRepository.saveAndFlush(post) + } + + private fun saveCommunityUseCan(buyer: Member, post: CreatorCommunity, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.PAID_COMMUNITY_POST, + can = can, + rewardCan = 0 + ) + useCan.member = buyer + useCan.communityPost = post + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveContentDonationUseCan(buyer: Member, content: AudioContent, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.DONATION, + can = can, + rewardCan = 0 + ) + useCan.member = buyer + useCan.audioContent = content + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveChannelDonationUseCan(sender: Member, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.CHANNEL_DONATION, + can = can, + rewardCan = 0 + ) + useCan.member = sender + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveUseCanCalculate(useCan: UseCan, recipientCreatorId: Long, can: Int, paymentGateway: PaymentGateway) { + val useCanCalculate = UseCanCalculate( + can = can, + paymentGateway = paymentGateway, + status = UseCanCalculateStatus.RECEIVED + ) + useCanCalculate.useCan = useCan + useCanCalculate.recipientCreatorId = recipientCreatorId + useCanCalculateRepository.saveAndFlush(useCanCalculate) + } + + private fun updateUseCanCreatedAt(useCanId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update UseCan u set u.createdAt = :createdAt where u.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", useCanId) + .executeUpdate() + } + + private fun updateOrderCreatedAt(orderId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update Order o set o.createdAt = :createdAt where o.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", orderId) + .executeUpdate() + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepositoryTest.kt new file mode 100644 index 00000000..62540df0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepositoryTest.kt @@ -0,0 +1,123 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository +import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) +@Import(QueryDslConfig::class) +class AdminAgentReadQueryRepositoryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val relationRepository: AgentCreatorRelationRepository, + private val entityManager: EntityManager +) { + private lateinit var repository: AdminAgentReadQueryRepository + + @BeforeEach + fun setup() { + repository = AdminAgentReadQueryRepository( + queryFactory, + AgentCalculateQueryRepository(queryFactory, entityManager) + ) + } + + @Test + @DisplayName("에이전트 목록 조회는 활성 소속 크리에이터 수를 함께 반환한다") + fun shouldGetAgentListWithAssignedCreatorCount() { + val agentA = saveMember("agent-a", MemberRole.AGENT) + val agentB = saveMember("agent-b", MemberRole.AGENT) + val creatorA = saveMember("creator-a", MemberRole.CREATOR) + val creatorB = saveMember("creator-b", MemberRole.CREATOR) + val now = LocalDateTime.of(2026, 4, 10, 12, 0) + + saveRelation(agentA, creatorA, assignedAt = now.minusDays(1), unassignedAt = null) + saveRelation(agentA, creatorB, assignedAt = now.minusDays(2), unassignedAt = now.plusDays(1)) + + val totalCount = repository.getAgentListTotalCount() + val items = repository.getAgentList( + offset = 0, + limit = 20, + currentTime = ZonedDateTime.of(now, ZoneId.of("Asia/Seoul")) + ) + + assertEquals(2, totalCount) + assertEquals(listOf(agentB.id, agentA.id), items.map { it.agentId }) + assertEquals(listOf(0, 2), items.map { it.assignedCreatorCount }) + } + + @Test + @DisplayName("크리에이터 검색은 현재 활성 에이전트 소속 정보를 함께 반환한다") + fun shouldSearchAssignableCreatorsWithCurrentAgentInfo() { + val agent = saveMember("agent-search", MemberRole.AGENT) + val creatorAssigned = saveMember("creator-alpha", MemberRole.CREATOR) + val creatorFree = saveMember("creator-beta", MemberRole.CREATOR) + val now = LocalDateTime.of(2026, 4, 10, 12, 0) + + saveRelation(agent, creatorAssigned, assignedAt = now.minusDays(1), unassignedAt = null) + + val totalCount = repository.searchAssignableCreatorsTotalCount(searchWord = "creator", currentTime = now) + val items = repository.searchAssignableCreators(searchWord = "creator", offset = 0, limit = 20, currentTime = now) + + assertEquals(2, totalCount) + assertEquals(listOf(creatorFree.id, creatorAssigned.id), items.map { it.creatorId }) + assertEquals(listOf(null, agent.id), items.map { it.currentAgentId }) + assertEquals(listOf(null, "agent-search"), items.map { it.currentAgentNickname }) + } + + @Test + @DisplayName("특정 에이전트 소속 크리에이터 목록은 assignedAt을 포함해 현재 활성 구간만 반환한다") + fun shouldGetAssignedCreatorsForAdminDetail() { + val agent = saveMember("agent-detail", MemberRole.AGENT) + val activeCreator = saveMember("creator-active", MemberRole.CREATOR) + val futureCreator = saveMember("creator-future", MemberRole.CREATOR) + val now = LocalDateTime.of(2026, 4, 10, 12, 0) + + saveRelation(agent, activeCreator, assignedAt = now.minusDays(3), unassignedAt = null) + saveRelation(agent, futureCreator, assignedAt = now.plusDays(1), unassignedAt = null) + + val totalCount = repository.getAssignedCreatorTotalCount(agentId = agent.id!!, currentTime = now) + val items = repository.getAssignedCreators(agentId = agent.id!!, offset = 0, limit = 20, currentTime = now) + + assertEquals(1, totalCount) + assertEquals(1, items.size) + assertEquals(activeCreator.id, items.first().creatorId) + assertEquals(now.minusDays(3), items.first().assignedAt) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveRelation(agent: Member, creator: Member, assignedAt: LocalDateTime, unassignedAt: LocalDateTime?) { + val relation = AgentCreatorRelation() + relation.agent = agent + relation.creator = creator + relation.assignedAt = assignedAt + relation.unassignedAt = unassignedAt + relationRepository.saveAndFlush(relation) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadServiceTest.kt new file mode 100644 index 00000000..63cda4c1 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadServiceTest.kt @@ -0,0 +1,159 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.read + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorResponse +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorTotal +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.ZonedDateTime +import java.util.Optional + +class AdminAgentReadServiceTest { + private lateinit var queryRepository: AdminAgentReadQueryRepository + private lateinit var memberRepository: MemberRepository + private lateinit var calculateService: AgentCalculateService + private lateinit var service: AdminAgentReadService + + @BeforeEach + fun setup() { + queryRepository = Mockito.mock(AdminAgentReadQueryRepository::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + calculateService = Mockito.mock(AgentCalculateService::class.java) + service = AdminAgentReadService(queryRepository, memberRepository, calculateService) + } + + @Test + @DisplayName("크리에이터 검색은 두 글자 미만 검색어를 거부한다") + fun shouldRejectTooShortSearchWord() { + val exception = assertThrows(SodaException::class.java) { + service.searchAssignableCreators(searchWord = "a", offset = 0, limit = 20) + } + + assertEquals("admin.member.search_word_min_length", exception.messageKey) + } + + @Test + @DisplayName("에이전트 목록 조회는 현재 월 summary 필드를 그대로 반환한다") + fun shouldReturnAgentListWithCurrentMonthSummaryFields() { + Mockito.`when`(queryRepository.getAgentListTotalCount()).thenReturn(1) + Mockito.doReturn( + listOf( + GetAdminAgentListItem( + agentId = 11L, + agentNickname = "agent-a", + assignedCreatorCount = 2, + liveAgentSettlementAmount = 131, + contentAgentSettlementAmount = 80, + communityAgentSettlementAmount = 55, + contentDonationAgentSettlementAmount = 12, + channelDonationAgentSettlementAmount = 397 + ) + ) + ).`when`(queryRepository).getAgentList( + Mockito.eq(0L), + Mockito.eq(20L), + Mockito.any(ZonedDateTime::class.java) ?: ZonedDateTime.now() + ) + + val actual = service.getAgentList(offset = 0L, limit = 20L) + + assertEquals(397, actual.items.first().channelDonationAgentSettlementAmount) + } + + @Test + @DisplayName("소속 크리에이터 목록은 AGENT 대상만 조회한다") + fun shouldThrowWhenTargetMemberIsNotAgent() { + val creator = Member( + email = "creator@test.com", + password = "password", + nickname = "creator", + role = MemberRole.CREATOR + ) + creator.id = 7L + + Mockito.`when`(memberRepository.findById(7L)).thenReturn(Optional.of(creator)) + + val exception = assertThrows(SodaException::class.java) { + service.getAssignedCreators(agentId = 7L, offset = 0, limit = 20) + } + + assertEquals("partner.agent.ratio.invalid_agent", exception.messageKey) + } + + @Test + @DisplayName("라이브 정산 상세 조회는 기존 AgentCalculateService로 위임한다") + fun shouldDelegateLiveSettlementToAgentCalculateService() { + val agent = Member( + email = "agent@test.com", + password = "password", + nickname = "agent", + role = MemberRole.AGENT + ) + agent.id = 11L + + val response = GetAgentSettlementByCreatorResponse( + totalCount = 1, + total = GetAgentSettlementByCreatorTotal( + count = 1, + totalCan = 50, + krw = 5000, + fee = 330, + settlementAmount = 3736, + tax = 123, + depositAmount = 3613, + agentSettlementAmount = 131 + ), + items = listOf( + GetAgentSettlementByCreatorItem( + creatorId = 21L, + creatorNickname = "creator-a", + count = 1, + totalCan = 50, + krw = 5000, + fee = 330, + settlementAmount = 3736, + tax = 123, + depositAmount = 3613, + agentSettlementAmount = 131 + ) + ) + ) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) + Mockito.`when`( + calculateService.getCalculateLiveByCreator( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + agentId = 11L, + offset = 0L, + limit = 20L + ) + ).thenReturn(response) + + val actual = service.getCalculateLiveByCreator( + agentId = 11L, + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + offset = 0L, + limit = 20L + ) + + assertEquals(131, actual.items.first().agentSettlementAmount) + Mockito.verify(calculateService).getCalculateLiveByCreator( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + agentId = 11L, + offset = 0L, + limit = 20L + ) + } +}