feat(channel-donation): 소유자 전용 후원 숨김 API와 조회 필터를 추가한다
This commit is contained in:
+11
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
|
|||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -18,6 +19,16 @@ class CreatorChannelDonationController(
|
|||||||
private val creatorChannelDonationFacade: CreatorChannelDonationFacade,
|
private val creatorChannelDonationFacade: CreatorChannelDonationFacade,
|
||||||
private val langContext: LangContext
|
private val langContext: LangContext
|
||||||
) {
|
) {
|
||||||
|
@DeleteMapping("/{creatorId}/donations/{donationId}")
|
||||||
|
fun hideDonation(
|
||||||
|
@PathVariable creatorId: Long,
|
||||||
|
@PathVariable donationId: Long,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
): ApiResponse<Unit> {
|
||||||
|
creatorChannelDonationFacade.hide(creatorId, donationId, requireMember(member))
|
||||||
|
return ApiResponse.ok<Unit>()
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{creatorId}/donations")
|
@GetMapping("/{creatorId}/donations")
|
||||||
fun getDonationTab(
|
fun getDonationTab(
|
||||||
@PathVariable creatorId: Long,
|
@PathVariable creatorId: Long,
|
||||||
|
|||||||
+8
-1
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.i18n.Lang
|
|||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationService
|
||||||
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
|
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -13,8 +14,14 @@ import java.time.LocalDateTime
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
class CreatorChannelDonationFacade(
|
class CreatorChannelDonationFacade(
|
||||||
private val creatorChannelDonationQueryService: CreatorChannelDonationQueryService,
|
private val creatorChannelDonationQueryService: CreatorChannelDonationQueryService,
|
||||||
private val creatorLocalizedNameService: CreatorLocalizedNameService
|
private val creatorLocalizedNameService: CreatorLocalizedNameService,
|
||||||
|
private val hideCreatorChannelDonationService: HideCreatorChannelDonationService
|
||||||
) {
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun hide(creatorId: Long, donationId: Long, actor: Member) {
|
||||||
|
hideCreatorChannelDonationService.hide(creatorId, donationId, actor)
|
||||||
|
}
|
||||||
|
|
||||||
fun getDonationTab(
|
fun getDonationTab(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
viewer: Member,
|
viewer: Member,
|
||||||
|
|||||||
+4
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
|
||||||
@@ -48,6 +49,8 @@ data class MemberDonationRankingResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class CreatorChannelDonationResponse(
|
data class CreatorChannelDonationResponse(
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
val donationId: Long?,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val profileImageUrl: String,
|
val profileImageUrl: String,
|
||||||
val can: Int,
|
val can: Int,
|
||||||
@@ -59,6 +62,7 @@ data class CreatorChannelDonationResponse(
|
|||||||
companion object {
|
companion object {
|
||||||
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
|
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
|
||||||
return CreatorChannelDonationResponse(
|
return CreatorChannelDonationResponse(
|
||||||
|
donationId = donation.donationId,
|
||||||
nickname = donation.nickname,
|
nickname = donation.nickname,
|
||||||
profileImageUrl = donation.profileImageUrl,
|
profileImageUrl = donation.profileImageUrl,
|
||||||
can = donation.can,
|
can = donation.can,
|
||||||
|
|||||||
+2
@@ -87,6 +87,7 @@ class DefaultCreatorChannelDonationQueryRepository(
|
|||||||
.select(
|
.select(
|
||||||
Projections.constructor(
|
Projections.constructor(
|
||||||
CreatorChannelDonationRecord::class.java,
|
CreatorChannelDonationRecord::class.java,
|
||||||
|
channelDonationMessage.id,
|
||||||
channelDonationMessage.member.id,
|
channelDonationMessage.member.id,
|
||||||
donorName.displayName,
|
donorName.displayName,
|
||||||
channelDonationMessage.member.profileImage,
|
channelDonationMessage.member.profileImage,
|
||||||
@@ -113,6 +114,7 @@ class DefaultCreatorChannelDonationQueryRepository(
|
|||||||
): BooleanExpression {
|
): BooleanExpression {
|
||||||
val monthRange = queryPolicy.currentKstMonthRange(now)
|
val monthRange = queryPolicy.currentKstMonthRange(now)
|
||||||
return channelDonationMessage.creator.id.eq(creatorId)
|
return channelDonationMessage.creator.id.eq(creatorId)
|
||||||
|
.and(channelDonationMessage.isHidden.isFalse)
|
||||||
.and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc))
|
.and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc))
|
||||||
.and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc))
|
.and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc))
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -68,7 +68,7 @@ class CreatorChannelDonationQueryService(
|
|||||||
return CreatorChannelDonationTab(
|
return CreatorChannelDonationTab(
|
||||||
donationCount = queryPort.countChannelDonations(creatorId, viewerId, now),
|
donationCount = queryPort.countChannelDonations(creatorId, viewerId, now),
|
||||||
rankings = findRankings(creator, viewerId),
|
rankings = findRankings(creator, viewerId),
|
||||||
donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain(creatorId, viewerId, lang) },
|
donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain(creatorId, viewer, lang) },
|
||||||
page = donationPage,
|
page = donationPage,
|
||||||
hasNext = queryPolicy.hasNext(fetchedDonations, donationPage)
|
hasNext = queryPolicy.hasNext(fetchedDonations, donationPage)
|
||||||
)
|
)
|
||||||
@@ -95,9 +95,11 @@ class CreatorChannelDonationQueryService(
|
|||||||
).map { it.toDomain() }
|
).map { it.toDomain() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation {
|
private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewer: Member, lang: Lang): CreatorChannelDonation {
|
||||||
|
val viewerId = viewer.id!!
|
||||||
val isMasked = isSecret && viewerId != creatorId && viewerId != memberId
|
val isMasked = isSecret && viewerId != creatorId && viewerId != memberId
|
||||||
return CreatorChannelDonation(
|
return CreatorChannelDonation(
|
||||||
|
donationId = donationId.takeIf { viewer.role == MemberRole.CREATOR && viewerId == creatorId },
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
nickname = when {
|
nickname = when {
|
||||||
isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty()
|
isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty()
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class HideCreatorChannelDonationService(
|
||||||
|
private val hideCreatorChannelDonationPort: HideCreatorChannelDonationPort
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun hide(creatorId: Long, donationId: Long, actor: Member) {
|
||||||
|
if (actor.role != MemberRole.CREATOR || actor.id != creatorId) {
|
||||||
|
throw SodaException(messageKey = "common.error.access_denied")
|
||||||
|
}
|
||||||
|
if (!hideCreatorChannelDonationPort.hideOwnedDonation(creatorId, donationId)) {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -19,6 +19,7 @@ data class CreatorChannelDonationRanking(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class CreatorChannelDonation(
|
data class CreatorChannelDonation(
|
||||||
|
val donationId: Long?,
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val profileImageUrl: String,
|
val profileImageUrl: String,
|
||||||
|
|||||||
+1
@@ -35,6 +35,7 @@ data class CreatorChannelDonationCreatorRecord(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class CreatorChannelDonationRecord(
|
data class CreatorChannelDonationRecord(
|
||||||
|
val donationId: Long,
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val profileImagePath: String?,
|
val profileImagePath: String?,
|
||||||
|
|||||||
+27
@@ -28,6 +28,7 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ
|
|||||||
import org.springframework.security.web.SecurityFilterChain
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
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.jsonPath
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
@@ -83,6 +84,31 @@ class CreatorChannelDonationControllerTest @Autowired constructor(
|
|||||||
.andExpect(status().isUnauthorized)
|
.andExpect(status().isUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 숨김 DELETE는 body 없이 경로 ID와 인증 회원을 전달하고 data 없는 성공을 반환한다")
|
||||||
|
fun shouldHideDonationWithoutRequestBodyOrResponseData() {
|
||||||
|
val creator = createMember(id = 1L).apply { role = MemberRole.CREATOR }
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
delete("/api/v2/creator-channels/1/donations/7")
|
||||||
|
.with(user(MemberAdapter(creator)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data").doesNotExist())
|
||||||
|
|
||||||
|
Mockito.verify(facade).hide(1L, 7L, creator)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 숨김 DELETE는 비회원 요청을 거부한다")
|
||||||
|
fun shouldRejectAnonymousHideDonationRequest() {
|
||||||
|
mockMvc.perform(delete("/api/v2/creator-channels/1/donations/7").with(anonymous()))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
|
||||||
|
Mockito.verifyNoInteractions(facade)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("크리에이터 채널 후원 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
|
@DisplayName("크리에이터 채널 후원 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
|
||||||
fun shouldReturnCreatorChannelDonationTabForAuthenticatedMember() {
|
fun shouldReturnCreatorChannelDonationTabForAuthenticatedMember() {
|
||||||
@@ -166,6 +192,7 @@ class CreatorChannelDonationControllerTest @Autowired constructor(
|
|||||||
),
|
),
|
||||||
donations = listOf(
|
donations = listOf(
|
||||||
CreatorChannelDonationResponse(
|
CreatorChannelDonationResponse(
|
||||||
|
donationId = null,
|
||||||
nickname = "donor",
|
nickname = "donor",
|
||||||
profileImageUrl = "https://cdn.test/donor.png",
|
profileImageUrl = "https://cdn.test/donor.png",
|
||||||
can = 50,
|
can = 50,
|
||||||
|
|||||||
+506
-5
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.calculate.channelDonation.AdminChannelDonationCalculateQueryRepository
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCan
|
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||||
@@ -8,13 +10,16 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
|||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
|
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
|
||||||
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNameEntity
|
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNameEntity
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
@@ -22,10 +27,20 @@ import org.junit.jupiter.api.DisplayName
|
|||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
import org.junit.jupiter.params.provider.CsvSource
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
import org.springframework.test.context.ContextConfiguration
|
import org.springframework.test.context.ContextConfiguration
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
@@ -37,6 +52,7 @@ import java.time.ZoneOffset
|
|||||||
import javax.persistence.EntityManager
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
@SpringBootTest(
|
@SpringBootTest(
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
properties = [
|
properties = [
|
||||||
"cloud.aws.cloud-front.host=https://cdn.test",
|
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||||
"spring.cache.type=none",
|
"spring.cache.type=none",
|
||||||
@@ -45,12 +61,279 @@ import javax.persistence.EntityManager
|
|||||||
)
|
)
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
class CreatorChannelDonationEndToEndTest @Autowired constructor(
|
class CreatorChannelDonationEndToEndTest @Autowired constructor(
|
||||||
private val mockMvc: MockMvc,
|
private val mockMvc: MockMvc,
|
||||||
private val entityManager: EntityManager,
|
private val entityManager: EntityManager,
|
||||||
private val transactionTemplate: TransactionTemplate,
|
private val transactionTemplate: TransactionTemplate,
|
||||||
private val objectMapper: ObjectMapper
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val restTemplate: TestRestTemplate,
|
||||||
|
private val tokenProvider: TokenProvider,
|
||||||
|
private val tokenRepository: MemberTokenRepository,
|
||||||
|
private val jdbcTemplate: JdbcTemplate,
|
||||||
|
private val settlementRepository: AdminChannelDonationCalculateQueryRepository,
|
||||||
|
private val redisTemplate: RedisTemplate<String, Any>
|
||||||
) {
|
) {
|
||||||
|
private val tokenMemberIds = mutableSetOf<Long>()
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun removeIssuedTokens() {
|
||||||
|
tokenMemberIds.forEach { tokenRepository.deleteById(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = [false, true])
|
||||||
|
@DisplayName("소유 CREATOR의 실제 HTTP DELETE는 공개·비밀 한 건만 숨기고 원본과 재무 기록을 보존한다")
|
||||||
|
fun shouldAllowOnlyOwningCreatorToHideDonationThroughHttp(isSecret: Boolean) {
|
||||||
|
val fixture = createHideFixture()
|
||||||
|
val before = readHideRecords(fixture)
|
||||||
|
val donationId = fixture.donationIds[if (isSecret) 1 else 0]
|
||||||
|
|
||||||
|
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
|
||||||
|
|
||||||
|
assertEquals(before, readHideRecords(fixture))
|
||||||
|
assertHiddenDonations(fixture, setOf(donationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = [false, true])
|
||||||
|
@DisplayName("실제 HTTP 숨김은 단일 공개·비밀 후원의 legacy 응답과 양수 랭킹·정산을 그대로 보존한다")
|
||||||
|
fun shouldPreserveLegacyRankingAndSettlementAfterHidingDonationThroughHttp(isSecret: Boolean) {
|
||||||
|
val createdAt = CreatorChannelDonationQueryPolicy()
|
||||||
|
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
|
||||||
|
.plusHours(2).plusSeconds(if (isSecret) 1 else 0)
|
||||||
|
val fixture = transactionTemplate.execute {
|
||||||
|
val prefix = "preservation-$isSecret"
|
||||||
|
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
|
||||||
|
val donor = saveMember("$prefix-donor", MemberRole.USER)
|
||||||
|
val outsider = saveMember("$prefix-outsider", MemberRole.USER)
|
||||||
|
val otherCreator = saveMember("$prefix-other-creator", MemberRole.CREATOR)
|
||||||
|
val admin = saveMember("$prefix-admin", MemberRole.ADMIN)
|
||||||
|
val donation = saveDonation(creator, donor, 500, createdAt, isSecret, "preserved contribution")
|
||||||
|
val otherDonation = saveDonation(otherCreator, donor, 400, createdAt, false, "other channel original")
|
||||||
|
val usage = saveRankingDonation(donor, creator, can = 450, rewardCan = 50, isSecret = isSecret)
|
||||||
|
entityManager.flush()
|
||||||
|
updateCreatedAt("UseCan", usage.id!!, createdAt)
|
||||||
|
updateCreatedAt("UseCanCalculate", usage.useCanCalculates.single().id!!, createdAt)
|
||||||
|
entityManager.clear()
|
||||||
|
val stored = entityManager.find(ChannelDonationMessage::class.java, donation.id!!)
|
||||||
|
val storedUsage = entityManager.find(UseCan::class.java, usage.id!!)
|
||||||
|
val calculation = storedUsage.useCanCalculates.single()
|
||||||
|
assertEquals(stored.can, storedUsage.can + storedUsage.rewardCan)
|
||||||
|
assertEquals(stored.member!!.id, storedUsage.member!!.id)
|
||||||
|
assertEquals(stored.isSecret, storedUsage.isSecret)
|
||||||
|
assertEquals(createdAt, stored.createdAt)
|
||||||
|
assertEquals(stored.createdAt, storedUsage.createdAt)
|
||||||
|
assertEquals(stored.can, calculation.can)
|
||||||
|
assertEquals(stored.creator!!.id, calculation.recipientCreatorId)
|
||||||
|
assertEquals(UseCanCalculateStatus.RECEIVED, calculation.status)
|
||||||
|
assertEquals(CanUsage.CHANNEL_DONATION, storedUsage.canUsage)
|
||||||
|
assertFalse(storedUsage.isRefund)
|
||||||
|
HideFixture(creator, donor, outsider, otherCreator, admin, listOf(donation.id!!), otherDonation.id!!)
|
||||||
|
}!!
|
||||||
|
val before = readHideRecords(fixture)
|
||||||
|
val path = "/api/v2/creator-channels/${fixture.creator.id}"
|
||||||
|
val legacyPath = "/explorer/profile/channel-donation?creatorId=${fixture.creator.id}&page=0&size=20"
|
||||||
|
val tabBefore = requestThroughHttp("$path/donations?page=0&size=20", HttpMethod.GET, fixture.creator)["data"]
|
||||||
|
val selected = tabBefore["donations"].single()
|
||||||
|
val donationId = selected["donationId"].asLong()
|
||||||
|
assertEquals(fixture.donationIds.single(), donationId)
|
||||||
|
assertEquals(500, selected["can"].asInt())
|
||||||
|
assertEquals(isSecret, selected["isSecret"].asBoolean())
|
||||||
|
assertEquals(1, tabBefore["donationCount"].asInt())
|
||||||
|
assertEquals(
|
||||||
|
tabBefore["donations"],
|
||||||
|
requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"]
|
||||||
|
)
|
||||||
|
val legacyBefore = requestThroughHttp(legacyPath, HttpMethod.GET, fixture.creator)
|
||||||
|
assertTrue(legacyBefore["success"].asBoolean())
|
||||||
|
assertEquals(1, legacyBefore["data"]["totalCount"].asInt())
|
||||||
|
assertEquals(donationId, legacyBefore["data"]["items"].single()["id"].asLong())
|
||||||
|
val rankingsBefore = tabBefore["rankings"]
|
||||||
|
assertEquals(fixture.donor.id, rankingsBefore.single()["userId"].asLong())
|
||||||
|
assertEquals(500, rankingsBefore.single()["donationCan"].asInt())
|
||||||
|
val totalBefore = settlementRepository.getChannelDonationByCreatorTotal(createdAt, createdAt)
|
||||||
|
val settlementBefore = settlementRepository.getChannelDonationByCreator(createdAt, createdAt, 0, 20)
|
||||||
|
val settlement = settlementBefore.single().toResponseItem()
|
||||||
|
assertEquals(fixture.creator.nickname, settlement.creator)
|
||||||
|
assertEquals(1L, totalBefore.count)
|
||||||
|
assertEquals(500, totalBefore.totalCan)
|
||||||
|
assertEquals(1, settlement.count)
|
||||||
|
assertEquals(500, settlement.totalCan)
|
||||||
|
assertEquals(39695, settlement.settlementAmount)
|
||||||
|
assertEquals(38385, settlement.depositAmount)
|
||||||
|
println("SETTLEMENT EVIDENCE before creatorId=${fixture.creator.id} at=$createdAt total=$totalBefore item=$settlement")
|
||||||
|
|
||||||
|
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
|
||||||
|
|
||||||
|
assertTrue(redisTemplate.delete("creator_donation_ranking_v2:${fixture.creator.id}:CUMULATIVE:0:8:true"))
|
||||||
|
val tabAfter = requestThroughHttp("$path/donations?page=0&size=20", HttpMethod.GET, fixture.creator)["data"]
|
||||||
|
assertEquals(0, tabAfter["donationCount"].asInt())
|
||||||
|
assertTrue(tabAfter["donations"].isEmpty)
|
||||||
|
assertFalse(tabAfter["hasNext"].asBoolean())
|
||||||
|
assertTrue(requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"].isEmpty)
|
||||||
|
assertEquals(rankingsBefore, tabAfter["rankings"])
|
||||||
|
assertEquals(legacyBefore, requestThroughHttp(legacyPath, HttpMethod.GET, fixture.creator))
|
||||||
|
val totalAfter = settlementRepository.getChannelDonationByCreatorTotal(createdAt, createdAt)
|
||||||
|
val settlementAfter = settlementRepository.getChannelDonationByCreator(createdAt, createdAt, 0, 20)
|
||||||
|
assertEquals(totalBefore, totalAfter)
|
||||||
|
assertEquals(settlementBefore, settlementAfter)
|
||||||
|
assertEquals(settlement, settlementAfter.single().toResponseItem())
|
||||||
|
println(
|
||||||
|
"SETTLEMENT EVIDENCE after creatorId=${fixture.creator.id} at=$createdAt " +
|
||||||
|
"total=$totalAfter item=${settlementAfter.single().toResponseItem()}"
|
||||||
|
)
|
||||||
|
assertEquals(before, readHideRecords(fixture))
|
||||||
|
assertHiddenDonations(fixture, setOf(donationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["donor", "outsider", "otherCreator", "admin"])
|
||||||
|
@DisplayName("후원자·일반 회원·다른 CREATOR·ADMIN의 실제 HTTP DELETE는 기록 변경 없이 거부된다")
|
||||||
|
fun shouldRejectNonOwnersThroughHttpWithoutChangingRecords(actorType: String) {
|
||||||
|
val fixture = createHideFixture()
|
||||||
|
val before = readHideRecords(fixture)
|
||||||
|
val actor = when (actorType) {
|
||||||
|
"donor" -> fixture.donor
|
||||||
|
"outsider" -> fixture.outsider
|
||||||
|
"otherCreator" -> fixture.otherCreator
|
||||||
|
else -> fixture.admin
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteThroughHttp(fixture.creator.id!!, fixture.donationIds.first(), actor, false, "권한이 없습니다.")
|
||||||
|
|
||||||
|
assertEquals(before, readHideRecords(fixture))
|
||||||
|
assertHiddenDonations(fixture, emptySet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 HTTP DELETE는 경로 소유권 위조·다른 채널 후원·미존재 ID를 동일한 원본을 유지하며 거부한다")
|
||||||
|
fun shouldRejectMismatchedOrMissingIdsThroughHttp() {
|
||||||
|
val fixture = createHideFixture()
|
||||||
|
val before = readHideRecords(fixture)
|
||||||
|
val creatorId = fixture.creator.id!!
|
||||||
|
val requests = listOf(
|
||||||
|
Triple(fixture.otherCreator.id!!, fixture.donationIds.first(), "권한이 없습니다."),
|
||||||
|
Triple(creatorId, fixture.otherDonationId, "잘못된 요청입니다."),
|
||||||
|
Triple(creatorId, Long.MAX_VALUE, "잘못된 요청입니다.")
|
||||||
|
)
|
||||||
|
|
||||||
|
requests.forEach { (pathCreatorId, donationId, message) ->
|
||||||
|
deleteThroughHttp(pathCreatorId, donationId, fixture.creator, false, message)
|
||||||
|
assertEquals(before, readHideRecords(fixture))
|
||||||
|
assertHiddenDonations(fixture, emptySet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 HTTP 중복 숨김은 성공하고 이미 숨긴 항목의 비소유자 요청은 거부하며 재무를 보존한다")
|
||||||
|
fun shouldSucceedWhenHidingAgainThroughHttpWithoutChangingRecords() {
|
||||||
|
val fixture = createHideFixture()
|
||||||
|
val before = readHideRecords(fixture)
|
||||||
|
val donationId = fixture.donationIds[1]
|
||||||
|
|
||||||
|
repeat(2) {
|
||||||
|
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
|
||||||
|
assertEquals(before, readHideRecords(fixture))
|
||||||
|
assertHiddenDonations(fixture, setOf(donationId))
|
||||||
|
}
|
||||||
|
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.donor, false, "권한이 없습니다.")
|
||||||
|
assertEquals(before, readHideRecords(fixture))
|
||||||
|
assertHiddenDonations(fixture, setOf(donationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인증 없는 실제 HTTP DELETE는 운영 인증 체인에서 401로 거부되고 기록을 보존한다")
|
||||||
|
fun shouldRejectUnauthenticatedDeleteThroughRealSecurityChain() {
|
||||||
|
val fixture = createHideFixture()
|
||||||
|
val before = readHideRecords(fixture)
|
||||||
|
val path = "/api/v2/creator-channels/${fixture.creator.id}/donations/${fixture.donationIds.first()}"
|
||||||
|
|
||||||
|
val response = restTemplate.exchange(path, HttpMethod.DELETE, HttpEntity<Any>(HttpHeaders()), String::class.java)
|
||||||
|
|
||||||
|
println("HTTP EVIDENCE DELETE $path actor=anonymous ${response.statusCodeValue}: ${response.body}")
|
||||||
|
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode)
|
||||||
|
assertEquals(before, readHideRecords(fixture))
|
||||||
|
assertHiddenDonations(fixture, emptySet())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteThroughHttp(
|
||||||
|
creatorId: Long,
|
||||||
|
donationId: Long,
|
||||||
|
actor: Member,
|
||||||
|
success: Boolean,
|
||||||
|
message: String? = null
|
||||||
|
) {
|
||||||
|
val path = "/api/v2/creator-channels/$creatorId/donations/$donationId"
|
||||||
|
val body = requestThroughHttp(path, HttpMethod.DELETE, actor)
|
||||||
|
assertEquals(success, body["success"].asBoolean(), body.toString())
|
||||||
|
assertTrue(body["data"].isNull, body.toString())
|
||||||
|
if (message != null) assertEquals(message, body["message"].asText())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestThroughHttp(path: String, method: HttpMethod, actor: Member): JsonNode {
|
||||||
|
val principal = MemberAdapter(actor)
|
||||||
|
val token = tokenProvider.createToken(
|
||||||
|
UsernamePasswordAuthenticationToken(principal, "", principal.authorities),
|
||||||
|
actor.id!!
|
||||||
|
)
|
||||||
|
tokenMemberIds.add(actor.id!!)
|
||||||
|
val headers = HttpHeaders().apply {
|
||||||
|
setBearerAuth(token)
|
||||||
|
set(HttpHeaders.ACCEPT_LANGUAGE, "ko")
|
||||||
|
}
|
||||||
|
val response = restTemplate.exchange(path, method, HttpEntity<Any>(headers), String::class.java)
|
||||||
|
println("HTTP EVIDENCE $method $path actor=${actor.role} ${response.statusCodeValue}: ${response.body}")
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode, response.body)
|
||||||
|
return objectMapper.readTree(response.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createHideFixture(): HideFixture = transactionTemplate.execute {
|
||||||
|
val creator = saveMember("hide-http-creator", MemberRole.CREATOR)
|
||||||
|
val donor = saveMember("hide-http-donor", MemberRole.USER)
|
||||||
|
val outsider = saveMember("hide-http-outsider", MemberRole.USER)
|
||||||
|
val otherCreator = saveMember("hide-http-other-creator", MemberRole.CREATOR)
|
||||||
|
val admin = saveMember("hide-http-admin", MemberRole.ADMIN)
|
||||||
|
val createdAt = LocalDateTime.of(2026, 11, 5, 0, 0)
|
||||||
|
val donations = listOf(
|
||||||
|
saveDonation(creator, donor, 100, createdAt, false, "public original"),
|
||||||
|
saveDonation(creator, donor, 200, createdAt, true, "secret original"),
|
||||||
|
saveDonation(creator, donor, 300, createdAt, false, "untouched original")
|
||||||
|
)
|
||||||
|
val otherDonation = saveDonation(otherCreator, donor, 400, createdAt, true, "other channel original")
|
||||||
|
saveRankingDonation(donor, creator, can = 450, rewardCan = 50)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
HideFixture(creator, donor, outsider, otherCreator, admin, donations.map { it.id!! }, otherDonation.id!!)
|
||||||
|
}!!
|
||||||
|
|
||||||
|
private fun readHideRecords(fixture: HideFixture): List<List<Map<String, Any?>>> {
|
||||||
|
val donations = jdbcTemplate.queryForList(
|
||||||
|
"select * from channel_donation_message where creator_id in (?, ?) order by id",
|
||||||
|
fixture.creator.id,
|
||||||
|
fixture.otherCreator.id
|
||||||
|
).map { row -> row.filterKeys { !it.equals("is_hidden", ignoreCase = true) } }
|
||||||
|
val usages = jdbcTemplate.queryForList("select * from use_can where member_id = ? order by id", fixture.donor.id)
|
||||||
|
val calculations = jdbcTemplate.queryForList(
|
||||||
|
"select c.* from use_can_calculate c join use_can u on u.id = c.use_can_id where u.member_id = ? order by c.id",
|
||||||
|
fixture.donor.id
|
||||||
|
)
|
||||||
|
assertEquals(fixture.donationIds.size + 1, donations.size)
|
||||||
|
assertEquals(1, usages.size)
|
||||||
|
assertEquals(1, calculations.size)
|
||||||
|
return listOf(donations, usages, calculations)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertHiddenDonations(fixture: HideFixture, hiddenIds: Set<Long>) {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
entityManager.clear()
|
||||||
|
(fixture.donationIds + fixture.otherDonationId).forEach { donationId ->
|
||||||
|
val stored = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId))
|
||||||
|
assertEquals(donationId in hiddenIds, stored.isHidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다")
|
@DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다")
|
||||||
fun shouldReturnDonationTabThroughControllerServiceRepositoryAndLegacyRanking() {
|
fun shouldReturnDonationTabThroughControllerServiceRepositoryAndLegacyRanking() {
|
||||||
@@ -223,10 +506,14 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
|
|||||||
"ja" to "クリエイターにのみ表示されます"
|
"ja" to "クリエイターにのみ表示されます"
|
||||||
).getValue(language)
|
).getValue(language)
|
||||||
fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) ->
|
fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) ->
|
||||||
|
val isOwner = fixture.viewer.role == MemberRole.CREATOR && fixture.viewer.id == fixture.creatorId
|
||||||
assertEquals(
|
assertEquals(
|
||||||
setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret"),
|
setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret") +
|
||||||
|
if (isOwner) setOf("donationId") else emptySet(),
|
||||||
item.fieldNames().asSequence().toSet()
|
item.fieldNames().asSequence().toSet()
|
||||||
)
|
)
|
||||||
|
if (isOwner) assertEquals(stored.id, item["donationId"]?.asLong())
|
||||||
|
assertFalse(item.has("memberId"))
|
||||||
assertTrue(item["isSecret"].isBoolean)
|
assertTrue(item["isSecret"].isBoolean)
|
||||||
assertEquals(stored.isSecret, item["isSecret"].asBoolean())
|
assertEquals(stored.isSecret, item["isSecret"].asBoolean())
|
||||||
assertEquals(stored.can, item["can"].asInt())
|
assertEquals(stored.can, item["can"].asInt())
|
||||||
@@ -257,6 +544,201 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 HTTP 후원 탭은 숨김 제외 후 20건과 hasNext를 채우며 전부 숨기면 0건을 반환한다")
|
||||||
|
fun shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewerThroughHttp() {
|
||||||
|
val fixture = createHideFixture()
|
||||||
|
val rows = transactionTemplate.execute {
|
||||||
|
val start = CreatorChannelDonationQueryPolicy()
|
||||||
|
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
|
||||||
|
fixture.donationIds.forEach { id ->
|
||||||
|
entityManager.find(ChannelDonationMessage::class.java, id).isHidden = true
|
||||||
|
}
|
||||||
|
val donations = (1..25).map { index ->
|
||||||
|
saveDonation(
|
||||||
|
fixture.creator,
|
||||||
|
fixture.donor,
|
||||||
|
index,
|
||||||
|
start.plusHours(1),
|
||||||
|
index % 2 == 1,
|
||||||
|
"row$index",
|
||||||
|
isHidden = index >= 23
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
donations
|
||||||
|
}!!
|
||||||
|
val viewers = listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin)
|
||||||
|
val path = "/api/v2/creator-channels/${fixture.creator.id}/donations"
|
||||||
|
viewers.forEach { viewer ->
|
||||||
|
listOf((22 downTo 3).toList(), listOf(2, 1), emptyList()).forEachIndexed { page, expected ->
|
||||||
|
val body = requestThroughHttp("$path?page=$page&size=20", HttpMethod.GET, viewer)
|
||||||
|
assertTrue(body["success"].asBoolean())
|
||||||
|
val data = body["data"]
|
||||||
|
assertEquals(22, data["donationCount"].asInt())
|
||||||
|
assertEquals(page, data["page"].asInt())
|
||||||
|
assertEquals(20, data["size"].asInt())
|
||||||
|
assertEquals(page == 0, data["hasNext"].asBoolean())
|
||||||
|
assertEquals(expected, data["donations"].map { it["can"].asInt() })
|
||||||
|
data["donations"].forEach { item ->
|
||||||
|
assertFalse(item.has("memberId"))
|
||||||
|
if (viewer.id == fixture.creator.id) {
|
||||||
|
assertEquals(rows[item["can"].asInt() - 1].id, item["donationId"]?.asLong())
|
||||||
|
} else {
|
||||||
|
assertFalse(item.has("donationId"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.take(2).forEach { deleteThroughHttp(fixture.creator.id!!, it.id!!, fixture.creator, true) }
|
||||||
|
viewers.forEach { viewer ->
|
||||||
|
val data = requestThroughHttp("$path?size=20", HttpMethod.GET, viewer)["data"]
|
||||||
|
assertEquals(20, data["donationCount"].asInt())
|
||||||
|
assertEquals((22 downTo 3).toList(), data["donations"].map { it["can"].asInt() })
|
||||||
|
assertFalse(data["hasNext"].asBoolean())
|
||||||
|
}
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
rows.forEach { entityManager.find(ChannelDonationMessage::class.java, it.id!!).isHidden = true }
|
||||||
|
}
|
||||||
|
viewers.forEach { viewer ->
|
||||||
|
val data = requestThroughHttp(path, HttpMethod.GET, viewer)["data"]
|
||||||
|
assertEquals(0, data["donationCount"].asInt())
|
||||||
|
assertTrue(data["donations"].isEmpty)
|
||||||
|
assertFalse(data["hasNext"].asBoolean())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 HTTP에서 소유 CREATOR만 영속 후원 ID를 얻어 숨길 수 있다")
|
||||||
|
fun shouldExposeDonationIdOnlyToOwningCreator() {
|
||||||
|
val fixture = createHideFixture()
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
val start = CreatorChannelDonationQueryPolicy()
|
||||||
|
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
|
||||||
|
fixture.donationIds.forEachIndexed { index, id ->
|
||||||
|
updateCreatedAt("ChannelDonationMessage", id, start.plusMinutes(index.toLong()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val path = "/api/v2/creator-channels/${fixture.creator.id}/donations"
|
||||||
|
val data = requestThroughHttp(path, HttpMethod.GET, fixture.creator)["data"]
|
||||||
|
assertEquals(fixture.donationIds.reversed(), data["donations"].map { it["donationId"]?.asLong() })
|
||||||
|
val donationId = data["donations"][0]["donationId"].asLong()
|
||||||
|
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
|
||||||
|
val after = requestThroughHttp(path, HttpMethod.GET, fixture.creator)["data"]
|
||||||
|
assertEquals(2, after["donationCount"].asInt())
|
||||||
|
assertEquals(fixture.donationIds.dropLast(1).reversed(), after["donations"].map { it["donationId"].asLong() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 홈 HTTP 응답도 소유 CREATOR에만 탭과 동일한 영속 후원 ID를 제공한다")
|
||||||
|
fun shouldExposeHomeDonationIdOnlyToOwningCreator() {
|
||||||
|
val fixture = createHideFixture()
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
val start = CreatorChannelDonationQueryPolicy()
|
||||||
|
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
|
||||||
|
fixture.donationIds.forEachIndexed { index, id ->
|
||||||
|
updateCreatedAt("ChannelDonationMessage", id, start.plusMinutes(index.toLong()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val path = "/api/v2/creator-channels/${fixture.creator.id}"
|
||||||
|
val home = requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"]
|
||||||
|
val tab = requestThroughHttp("$path/donations", HttpMethod.GET, fixture.creator)["data"]["donations"]
|
||||||
|
assertEquals(fixture.donationIds.reversed(), home.map { it["donationId"]?.asLong() })
|
||||||
|
assertEquals(tab, home)
|
||||||
|
val donationId = home[1]["donationId"].asLong()
|
||||||
|
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
|
||||||
|
listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin).forEach { viewer ->
|
||||||
|
val homeAfter = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"]
|
||||||
|
val tabAfter = requestThroughHttp("$path/donations", HttpMethod.GET, viewer)["data"]
|
||||||
|
assertEquals(listOf(300, 100), homeAfter.map { it["can"].asInt() })
|
||||||
|
assertEquals(tabAfter["donations"], homeAfter)
|
||||||
|
assertEquals(2, tabAfter["donationCount"].asInt())
|
||||||
|
homeAfter.forEach { item ->
|
||||||
|
assertFalse(item.has("memberId"))
|
||||||
|
if (viewer.id == fixture.creator.id) {
|
||||||
|
assertTrue(item.has("donationId"))
|
||||||
|
} else {
|
||||||
|
assertFalse(item.has("donationId"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = [false, true])
|
||||||
|
@DisplayName("실제 HTTP 홈은 숨김 제외 후 8건을 보충하고 공개·비밀 DELETE를 모든 역할에 반영한다")
|
||||||
|
fun shouldRefillHomeWithLatestEightVisibleDonationsAfterHiddenRowsAreExcludedThroughHttp(isSecret: Boolean) {
|
||||||
|
val fixture = createHideFixture()
|
||||||
|
val start = CreatorChannelDonationQueryPolicy()
|
||||||
|
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
|
||||||
|
val rows = transactionTemplate.execute {
|
||||||
|
fixture.donationIds.forEach { entityManager.find(ChannelDonationMessage::class.java, it).isHidden = true }
|
||||||
|
val donations = (1..12).map { index ->
|
||||||
|
saveDonation(
|
||||||
|
fixture.creator,
|
||||||
|
fixture.donor,
|
||||||
|
index,
|
||||||
|
start.plusHours((index / 2).toLong()),
|
||||||
|
index % 2 == 1,
|
||||||
|
"visible$index",
|
||||||
|
isHidden = index >= 10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
donations
|
||||||
|
}!!
|
||||||
|
val path = "/api/v2/creator-channels/${fixture.creator.id}"
|
||||||
|
val viewers = listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin)
|
||||||
|
viewers.forEach { viewer ->
|
||||||
|
val data = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]
|
||||||
|
assertFalse(data.has("donationCount"))
|
||||||
|
val items = data["channelDonations"]
|
||||||
|
assertEquals((9 downTo 2).toList(), items.map { it["can"].asInt() })
|
||||||
|
items.forEach { item ->
|
||||||
|
val can = item["can"].asInt()
|
||||||
|
val secret = can % 2 == 1
|
||||||
|
assertEquals(secret, item["isSecret"].asBoolean())
|
||||||
|
assertEquals(
|
||||||
|
start.plusHours((can / 2).toLong()).atOffset(ZoneOffset.UTC).toInstant().toString(),
|
||||||
|
item["createdAtUtc"].asText()
|
||||||
|
)
|
||||||
|
assertFalse(item.has("memberId"))
|
||||||
|
if (viewer.id == fixture.creator.id) {
|
||||||
|
assertEquals(rows[can - 1].id, item["donationId"]?.asLong())
|
||||||
|
} else {
|
||||||
|
assertFalse(item.has("donationId"))
|
||||||
|
}
|
||||||
|
val masked = secret && viewer.id != fixture.creator.id && viewer.id != fixture.donor.id
|
||||||
|
assertEquals(if (masked) "익명" else fixture.donor.nickname, item["nickname"].asText())
|
||||||
|
assertEquals(if (masked) "크리에이터에게만 보여요" else "visible$can", item["message"].asText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ownerHome = requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"]
|
||||||
|
val selected = ownerHome.first { it["isSecret"].asBoolean() == isSecret }
|
||||||
|
val selectedCan = if (isSecret) 9 else 8
|
||||||
|
assertEquals(selectedCan, selected["can"].asInt())
|
||||||
|
assertEquals(rows[selectedCan - 1].id, selected["donationId"].asLong())
|
||||||
|
deleteThroughHttp(fixture.creator.id!!, selected["donationId"].asLong(), fixture.creator, true)
|
||||||
|
val expected = if (isSecret) (8 downTo 1).toList() else listOf(9, 7, 6, 5, 4, 3, 2, 1)
|
||||||
|
viewers.forEach { viewer ->
|
||||||
|
val home = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"]
|
||||||
|
val tab = requestThroughHttp("$path/donations", HttpMethod.GET, viewer)["data"]
|
||||||
|
assertEquals(expected, home.map { it["can"].asInt() })
|
||||||
|
assertEquals(8, tab["donationCount"].asInt())
|
||||||
|
assertFalse(tab["hasNext"].asBoolean())
|
||||||
|
assertEquals(tab["donations"], home)
|
||||||
|
}
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
rows.forEach { entityManager.find(ChannelDonationMessage::class.java, it.id!!).isHidden = true }
|
||||||
|
}
|
||||||
|
viewers.forEach { viewer ->
|
||||||
|
val home = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"]
|
||||||
|
assertTrue(home.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("후원 탭은 KST 월 공개·비밀 전체를 최신순으로 페이지화하고 범위 밖에서도 count를 유지한다")
|
@DisplayName("후원 탭은 KST 월 공개·비밀 전체를 최신순으로 페이지화하고 범위 밖에서도 count를 유지한다")
|
||||||
fun shouldPaginateAllCurrentMonthDonationsThroughHttp() {
|
fun shouldPaginateAllCurrentMonthDonationsThroughHttp() {
|
||||||
@@ -363,9 +845,11 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
|
|||||||
can: Int,
|
can: Int,
|
||||||
createdAt: LocalDateTime,
|
createdAt: LocalDateTime,
|
||||||
isSecret: Boolean = false,
|
isSecret: Boolean = false,
|
||||||
additionalMessage: String?
|
additionalMessage: String?,
|
||||||
|
isHidden: Boolean = false
|
||||||
): ChannelDonationMessage {
|
): ChannelDonationMessage {
|
||||||
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
|
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
|
||||||
|
donation.isHidden = isHidden
|
||||||
donation.creator = creator
|
donation.creator = creator
|
||||||
donation.member = donor
|
donation.member = donor
|
||||||
entityManager.persist(donation)
|
entityManager.persist(donation)
|
||||||
@@ -374,8 +858,14 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
|
|||||||
return donation
|
return donation
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveRankingDonation(donor: Member, creator: Member, can: Int, rewardCan: Int) {
|
private fun saveRankingDonation(
|
||||||
val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false)
|
donor: Member,
|
||||||
|
creator: Member,
|
||||||
|
can: Int,
|
||||||
|
rewardCan: Int,
|
||||||
|
isSecret: Boolean = false
|
||||||
|
): UseCan {
|
||||||
|
val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false, isSecret = isSecret)
|
||||||
useCan.member = donor
|
useCan.member = donor
|
||||||
entityManager.persist(useCan)
|
entityManager.persist(useCan)
|
||||||
|
|
||||||
@@ -387,6 +877,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
|
|||||||
calculate.useCan = useCan
|
calculate.useCan = useCan
|
||||||
calculate.recipientCreatorId = creator.id
|
calculate.recipientCreatorId = creator.id
|
||||||
entityManager.persist(calculate)
|
entityManager.persist(calculate)
|
||||||
|
return useCan
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
|
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
|
||||||
@@ -402,6 +893,16 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
|
|||||||
val creatorId: Long
|
val creatorId: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class HideFixture(
|
||||||
|
val creator: Member,
|
||||||
|
val donor: Member,
|
||||||
|
val outsider: Member,
|
||||||
|
val otherCreator: Member,
|
||||||
|
val admin: Member,
|
||||||
|
val donationIds: List<Long>,
|
||||||
|
val otherDonationId: Long
|
||||||
|
)
|
||||||
|
|
||||||
private data class DonationFixture(
|
private data class DonationFixture(
|
||||||
val creatorId: Long,
|
val creatorId: Long,
|
||||||
val donorId: Long,
|
val donorId: Long,
|
||||||
|
|||||||
+4
-1
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.Member
|
|||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationService
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
|
||||||
@@ -68,7 +69,8 @@ class CreatorChannelDonationFacadeTest {
|
|||||||
val service = Mockito.mock(CreatorChannelDonationQueryService::class.java)
|
val service = Mockito.mock(CreatorChannelDonationQueryService::class.java)
|
||||||
val facade = CreatorChannelDonationFacade(
|
val facade = CreatorChannelDonationFacade(
|
||||||
service,
|
service,
|
||||||
Mockito.mock(CreatorLocalizedNameService::class.java)
|
Mockito.mock(CreatorLocalizedNameService::class.java),
|
||||||
|
Mockito.mock(HideCreatorChannelDonationService::class.java)
|
||||||
)
|
)
|
||||||
val viewer = createMember(id = 10L)
|
val viewer = createMember(id = 10L)
|
||||||
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
|
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
|
||||||
@@ -121,6 +123,7 @@ class CreatorChannelDonationFacadeTest {
|
|||||||
),
|
),
|
||||||
donations = listOf(
|
donations = listOf(
|
||||||
CreatorChannelDonation(
|
CreatorChannelDonation(
|
||||||
|
donationId = null,
|
||||||
memberId = 10L,
|
memberId = 10L,
|
||||||
nickname = "donor",
|
nickname = "donor",
|
||||||
profileImageUrl = "https://cdn.test/donor.png",
|
profileImageUrl = "https://cdn.test/donor.png",
|
||||||
|
|||||||
+55
-1
@@ -220,6 +220,58 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("숨긴 공개·비밀 후원은 모든 역할의 count와 페이지 경계에서 제외된다")
|
||||||
|
fun shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewer() {
|
||||||
|
val now = LocalDateTime.of(2026, 12, 22, 12, 0)
|
||||||
|
val creator = saveMember("hidden-page-creator", MemberRole.CREATOR)
|
||||||
|
val donor = saveMember("hidden-page-donor", MemberRole.USER)
|
||||||
|
val viewers = listOf(
|
||||||
|
creator,
|
||||||
|
donor,
|
||||||
|
saveMember("hidden-page-outsider", MemberRole.USER),
|
||||||
|
saveMember("hidden-page-other-creator", MemberRole.CREATOR),
|
||||||
|
saveMember("hidden-page-admin", MemberRole.ADMIN)
|
||||||
|
)
|
||||||
|
val rows = (1..25).map { index ->
|
||||||
|
saveDonation(creator, donor, index, now, isSecret = index % 2 == 1, isHidden = index >= 23)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
viewers.forEach { viewer ->
|
||||||
|
assertEquals(22, repository.countChannelDonations(creator.id!!, viewer.id!!, now))
|
||||||
|
val first = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 20, Lang.KO)
|
||||||
|
val second = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 20, 20, Lang.KO)
|
||||||
|
assertEquals((22 downTo 3).toList(), first.map { it.can })
|
||||||
|
assertEquals(listOf(2, 1), second.map { it.can })
|
||||||
|
assertEquals(rows.take(22).reversed().map { it.id }, (first + second).map { it.donationId })
|
||||||
|
}
|
||||||
|
rows.take(2).forEach { row ->
|
||||||
|
entityManager.find(ChannelDonationMessage::class.java, row.id!!).isHidden = true
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
assertEquals(20, repository.countChannelDonations(creator.id!!, donor.id!!, now))
|
||||||
|
assertEquals(20, repository.findChannelDonations(creator.id!!, donor.id!!, now, 0, 21, Lang.KO).size)
|
||||||
|
assertTrue(repository.findChannelDonations(creator.id!!, donor.id!!, now, 20, 21, Lang.KO).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("모든 후원이 숨김이면 모든 역할의 count는 0이고 목록은 비어 있다")
|
||||||
|
fun shouldReturnZeroCountAndEmptyPagesWhenAllDonationsAreHidden() {
|
||||||
|
val now = LocalDateTime.of(2026, 12, 22, 12, 0)
|
||||||
|
val creator = saveMember("all-hidden-creator", MemberRole.CREATOR)
|
||||||
|
val donor = saveMember("all-hidden-donor", MemberRole.USER)
|
||||||
|
listOf(false, true).forEach { secret ->
|
||||||
|
saveDonation(creator, donor, 100, now, isSecret = secret, isHidden = true)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
listOf(creator, donor).forEach { viewer ->
|
||||||
|
assertEquals(0, repository.countChannelDonations(creator.id!!, viewer.id!!, now))
|
||||||
|
assertTrue(repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 21, Lang.KO).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다")
|
@DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다")
|
||||||
fun shouldUseProjectionForDonationList() {
|
fun shouldUseProjectionForDonationList() {
|
||||||
@@ -276,9 +328,11 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
|
|||||||
can: Int,
|
can: Int,
|
||||||
createdAt: LocalDateTime,
|
createdAt: LocalDateTime,
|
||||||
isSecret: Boolean = false,
|
isSecret: Boolean = false,
|
||||||
additionalMessage: String? = "thanks"
|
additionalMessage: String? = "thanks",
|
||||||
|
isHidden: Boolean = false
|
||||||
): ChannelDonationMessage {
|
): ChannelDonationMessage {
|
||||||
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
|
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
|
||||||
|
donation.isHidden = isHidden
|
||||||
donation.creator = creator
|
donation.creator = creator
|
||||||
donation.member = donor
|
donation.member = donor
|
||||||
entityManager.persist(donation)
|
entityManager.persist(donation)
|
||||||
|
|||||||
+5
@@ -162,6 +162,9 @@ class CreatorChannelDonationQueryServiceTest {
|
|||||||
"20, USER, true",
|
"20, USER, true",
|
||||||
"10, USER, true",
|
"10, USER, true",
|
||||||
"30, CREATOR, true",
|
"30, CREATOR, true",
|
||||||
|
"1, USER, true",
|
||||||
|
"1, ADMIN, true",
|
||||||
|
"40, ADMIN, true",
|
||||||
"1, CREATOR, false",
|
"1, CREATOR, false",
|
||||||
"20, USER, false",
|
"20, USER, false",
|
||||||
"10, USER, false",
|
"10, USER, false",
|
||||||
@@ -199,6 +202,7 @@ class CreatorChannelDonationQueryServiceTest {
|
|||||||
assertEquals(isSecret, donation.isSecret)
|
assertEquals(isSecret, donation.isSecret)
|
||||||
assertEquals(100, donation.can)
|
assertEquals(100, donation.can)
|
||||||
assertEquals(NOW, donation.createdAt)
|
assertEquals(NOW, donation.createdAt)
|
||||||
|
assertEquals(if (role == MemberRole.CREATOR && viewerId == CREATOR_ID) 101L else null, donation.donationId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,6 +344,7 @@ class CreatorChannelDonationQueryServiceTest {
|
|||||||
isLocalizedName: Boolean = false
|
isLocalizedName: Boolean = false
|
||||||
): CreatorChannelDonationRecord {
|
): CreatorChannelDonationRecord {
|
||||||
return CreatorChannelDonationRecord(
|
return CreatorChannelDonationRecord(
|
||||||
|
donationId = 101L,
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
profileImagePath = profileImagePath,
|
profileImagePath = profileImagePath,
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:hide-donation-service;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
class HideCreatorChannelDonationServiceTest @Autowired constructor(
|
||||||
|
private val service: HideCreatorChannelDonationService,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionTemplate: TransactionTemplate
|
||||||
|
) {
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = [false, true])
|
||||||
|
@DisplayName("서비스는 소유 CREATOR의 공개·비밀 후원을 숨기고 DB 원본을 보존한다")
|
||||||
|
fun shouldHideOwnedPublicAndSecretDonationAndPreserveOriginalValues(isSecret: Boolean) {
|
||||||
|
val fixture = createFixture(isSecret = isSecret)
|
||||||
|
val before = originalValues(fixture.donationId)
|
||||||
|
|
||||||
|
service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator)
|
||||||
|
|
||||||
|
assertEquals(before, originalValues(fixture.donationId))
|
||||||
|
assertHidden(fixture.donationId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = MemberRole::class, names = ["USER", "ADMIN"])
|
||||||
|
@DisplayName("서비스는 채널 ID가 같아도 CREATOR가 아닌 actor를 거부한다")
|
||||||
|
fun shouldRejectNonCreatorEvenWhenActorIdMatchesChannel(role: MemberRole) {
|
||||||
|
val fixture = createFixture(role = role)
|
||||||
|
val before = originalValues(fixture.donationId)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.access_denied", exception.messageKey)
|
||||||
|
assertEquals(before, originalValues(fixture.donationId))
|
||||||
|
assertHidden(fixture.donationId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = MemberRole::class, names = ["USER", "ADMIN", "CREATOR"])
|
||||||
|
@DisplayName("서비스는 비소유 actor를 대상 존재 여부 확인보다 먼저 거부한다")
|
||||||
|
fun shouldRejectNonOwnerBeforeCheckingDonationExistence(role: MemberRole) {
|
||||||
|
val fixture = createFixture()
|
||||||
|
val actor = transactionTemplate.execute { saveMember("hide-service-outsider", role) }!!
|
||||||
|
val before = originalValues(fixture.donationId)
|
||||||
|
|
||||||
|
listOf(fixture.donationId, Long.MAX_VALUE).forEach { donationId ->
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.hide(fixture.creator.id!!, donationId, actor)
|
||||||
|
}
|
||||||
|
assertEquals("common.error.access_denied", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(before, originalValues(fixture.donationId))
|
||||||
|
assertHidden(fixture.donationId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("서비스는 소속이 다른 후원과 미존재 ID를 같은 오류로 거부한다")
|
||||||
|
fun shouldRejectOtherChannelAndMissingDonationWithSameError() {
|
||||||
|
val fixture = createFixture()
|
||||||
|
val otherCreator = transactionTemplate.execute { saveMember("hide-service-other", MemberRole.CREATOR) }!!
|
||||||
|
val before = originalValues(fixture.donationId)
|
||||||
|
|
||||||
|
listOf(otherCreator to fixture.donationId, fixture.creator to Long.MAX_VALUE).forEach { (actor, donationId) ->
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.hide(actor.id!!, donationId, actor)
|
||||||
|
}
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(before, originalValues(fixture.donationId))
|
||||||
|
assertHidden(fixture.donationId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("서비스의 중복 숨김은 성공하고 원본을 보존한다")
|
||||||
|
fun shouldSucceedWhenHidingAgainWithoutChangingOriginalValues() {
|
||||||
|
val fixture = createFixture(isSecret = true)
|
||||||
|
val before = originalValues(fixture.donationId)
|
||||||
|
|
||||||
|
repeat(2) {
|
||||||
|
service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator)
|
||||||
|
assertEquals(before, originalValues(fixture.donationId))
|
||||||
|
assertHidden(fixture.donationId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFixture(role: MemberRole = MemberRole.CREATOR, isSecret: Boolean = false): Fixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val creator = saveMember("hide-service-creator", role)
|
||||||
|
val donor = saveMember("hide-service-donor", MemberRole.USER)
|
||||||
|
val donation = ChannelDonationMessage(120, isSecret, "service original").apply {
|
||||||
|
this.creator = creator
|
||||||
|
member = donor
|
||||||
|
}
|
||||||
|
entityManager.persist(donation)
|
||||||
|
donation.createdAt = LocalDateTime.of(2026, 11, 5, 0, 0)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
Fixture(creator, donation.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(nickname: String, role: MemberRole): Member {
|
||||||
|
return Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role)
|
||||||
|
.also { entityManager.persist(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun originalValues(donationId: Long): List<Any?> = transactionTemplate.execute {
|
||||||
|
entityManager.clear()
|
||||||
|
val donation = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId))
|
||||||
|
listOf(
|
||||||
|
donation.id,
|
||||||
|
donation.can,
|
||||||
|
donation.additionalMessage,
|
||||||
|
donation.isSecret,
|
||||||
|
donation.creator?.id,
|
||||||
|
donation.member?.id,
|
||||||
|
donation.createdAt
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
|
||||||
|
private fun assertHidden(donationId: Long, hidden: Boolean) {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
entityManager.clear()
|
||||||
|
val donation = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId))
|
||||||
|
assertEquals(hidden, donation.isHidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Fixture(val creator: Member, val donationId: Long)
|
||||||
|
}
|
||||||
+2
@@ -78,6 +78,7 @@ class CreatorChannelDonationQueryPolicyTest {
|
|||||||
donationCan = 100
|
donationCan = 100
|
||||||
)
|
)
|
||||||
val donation = CreatorChannelDonation(
|
val donation = CreatorChannelDonation(
|
||||||
|
donationId = 101L,
|
||||||
memberId = 10L,
|
memberId = 10L,
|
||||||
nickname = "donor",
|
nickname = "donor",
|
||||||
profileImageUrl = "https://cdn.test/donor.png",
|
profileImageUrl = "https://cdn.test/donor.png",
|
||||||
@@ -101,6 +102,7 @@ class CreatorChannelDonationQueryPolicyTest {
|
|||||||
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
|
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
|
||||||
)
|
)
|
||||||
val donationRecord = CreatorChannelDonationRecord(
|
val donationRecord = CreatorChannelDonationRecord(
|
||||||
|
donationId = 101L,
|
||||||
memberId = 10L,
|
memberId = 10L,
|
||||||
nickname = "donor",
|
nickname = "donor",
|
||||||
profileImagePath = null,
|
profileImagePath = null,
|
||||||
|
|||||||
Reference in New Issue
Block a user