Test #467

Merged
klaus merged 18 commits from test into main 2026-11-06 12:15:59 +00:00
15 changed files with 812 additions and 10 deletions
Showing only changes of commit 4fbe4ced28 - Show all commits
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade
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.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
@@ -18,6 +19,16 @@ class CreatorChannelDonationController(
private val creatorChannelDonationFacade: CreatorChannelDonationFacade,
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")
fun getDonationTab(
@PathVariable creatorId: Long,
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.i18n.Lang
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.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 org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -13,8 +14,14 @@ import java.time.LocalDateTime
@Transactional(readOnly = true)
class CreatorChannelDonationFacade(
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(
creatorId: Long,
viewer: Member,
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
@@ -48,6 +49,8 @@ data class MemberDonationRankingResponse(
}
data class CreatorChannelDonationResponse(
@JsonInclude(JsonInclude.Include.NON_NULL)
val donationId: Long?,
val nickname: String,
val profileImageUrl: String,
val can: Int,
@@ -59,6 +62,7 @@ data class CreatorChannelDonationResponse(
companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
return CreatorChannelDonationResponse(
donationId = donation.donationId,
nickname = donation.nickname,
profileImageUrl = donation.profileImageUrl,
can = donation.can,
@@ -87,6 +87,7 @@ class DefaultCreatorChannelDonationQueryRepository(
.select(
Projections.constructor(
CreatorChannelDonationRecord::class.java,
channelDonationMessage.id,
channelDonationMessage.member.id,
donorName.displayName,
channelDonationMessage.member.profileImage,
@@ -113,6 +114,7 @@ class DefaultCreatorChannelDonationQueryRepository(
): BooleanExpression {
val monthRange = queryPolicy.currentKstMonthRange(now)
return channelDonationMessage.creator.id.eq(creatorId)
.and(channelDonationMessage.isHidden.isFalse)
.and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc))
.and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc))
}
@@ -68,7 +68,7 @@ class CreatorChannelDonationQueryService(
return CreatorChannelDonationTab(
donationCount = queryPort.countChannelDonations(creatorId, viewerId, now),
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,
hasNext = queryPolicy.hasNext(fetchedDonations, donationPage)
)
@@ -95,9 +95,11 @@ class CreatorChannelDonationQueryService(
).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
return CreatorChannelDonation(
donationId = donationId.takeIf { viewer.role == MemberRole.CREATOR && viewerId == creatorId },
memberId = memberId,
nickname = when {
isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty()
@@ -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")
}
}
}
@@ -19,6 +19,7 @@ data class CreatorChannelDonationRanking(
)
data class CreatorChannelDonation(
val donationId: Long?,
val memberId: Long,
val nickname: String,
val profileImageUrl: String,
@@ -35,6 +35,7 @@ data class CreatorChannelDonationCreatorRecord(
)
data class CreatorChannelDonationRecord(
val donationId: Long,
val memberId: Long,
val nickname: String,
val profileImagePath: String?,
@@ -28,6 +28,7 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ
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.delete
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
@@ -83,6 +84,31 @@ class CreatorChannelDonationControllerTest @Autowired constructor(
.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
@DisplayName("크리에이터 채널 후원 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
fun shouldReturnCreatorChannelDonationTabForAuthenticatedMember() {
@@ -166,6 +192,7 @@ class CreatorChannelDonationControllerTest @Autowired constructor(
),
donations = listOf(
CreatorChannelDonationResponse(
donationId = null,
nickname = "donor",
profileImageUrl = "https://cdn.test/donor.png",
can = 50,
@@ -1,6 +1,8 @@
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 kr.co.vividnext.sodalive.admin.calculate.channelDonation.AdminChannelDonationCalculateQueryRepository
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
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.explorer.profile.channelDonation.ChannelDonationMessage
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.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
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.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
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.assertFalse
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.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
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.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -37,6 +52,7 @@ import java.time.ZoneOffset
import javax.persistence.EntityManager
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
@@ -45,12 +61,279 @@ import javax.persistence.EntityManager
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class CreatorChannelDonationEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
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
@DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다")
fun shouldReturnDonationTabThroughControllerServiceRepositoryAndLegacyRanking() {
@@ -223,10 +506,14 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
"ja" to "クリエイターにのみ表示されます"
).getValue(language)
fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) ->
val isOwner = fixture.viewer.role == MemberRole.CREATOR && fixture.viewer.id == fixture.creatorId
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()
)
if (isOwner) assertEquals(stored.id, item["donationId"]?.asLong())
assertFalse(item.has("memberId"))
assertTrue(item["isSecret"].isBoolean)
assertEquals(stored.isSecret, item["isSecret"].asBoolean())
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
@DisplayName("후원 탭은 KST 월 공개·비밀 전체를 최신순으로 페이지화하고 범위 밖에서도 count를 유지한다")
fun shouldPaginateAllCurrentMonthDonationsThroughHttp() {
@@ -363,9 +845,11 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
can: Int,
createdAt: LocalDateTime,
isSecret: Boolean = false,
additionalMessage: String?
additionalMessage: String?,
isHidden: Boolean = false
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.isHidden = isHidden
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
@@ -374,8 +858,14 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
return donation
}
private fun saveRankingDonation(donor: Member, creator: Member, can: Int, rewardCan: Int) {
val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false)
private fun saveRankingDonation(
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
entityManager.persist(useCan)
@@ -387,6 +877,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
calculate.useCan = useCan
calculate.recipientCreatorId = creator.id
entityManager.persist(calculate)
return useCan
}
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
@@ -402,6 +893,16 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
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(
val creatorId: Long,
val donorId: Long,
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.Member
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.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.CreatorChannelDonationRanking
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 facade = CreatorChannelDonationFacade(
service,
Mockito.mock(CreatorLocalizedNameService::class.java)
Mockito.mock(CreatorLocalizedNameService::class.java),
Mockito.mock(HideCreatorChannelDonationService::class.java)
)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
@@ -121,6 +123,7 @@ class CreatorChannelDonationFacadeTest {
),
donations = listOf(
CreatorChannelDonation(
donationId = null,
memberId = 10L,
nickname = "donor",
profileImageUrl = "https://cdn.test/donor.png",
@@ -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
@DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다")
fun shouldUseProjectionForDonationList() {
@@ -276,9 +328,11 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
can: Int,
createdAt: LocalDateTime,
isSecret: Boolean = false,
additionalMessage: String? = "thanks"
additionalMessage: String? = "thanks",
isHidden: Boolean = false
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.isHidden = isHidden
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
@@ -162,6 +162,9 @@ class CreatorChannelDonationQueryServiceTest {
"20, USER, true",
"10, USER, true",
"30, CREATOR, true",
"1, USER, true",
"1, ADMIN, true",
"40, ADMIN, true",
"1, CREATOR, false",
"20, USER, false",
"10, USER, false",
@@ -199,6 +202,7 @@ class CreatorChannelDonationQueryServiceTest {
assertEquals(isSecret, donation.isSecret)
assertEquals(100, donation.can)
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
): CreatorChannelDonationRecord {
return CreatorChannelDonationRecord(
donationId = 101L,
memberId = memberId,
nickname = nickname,
profileImagePath = profileImagePath,
@@ -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)
}
@@ -78,6 +78,7 @@ class CreatorChannelDonationQueryPolicyTest {
donationCan = 100
)
val donation = CreatorChannelDonation(
donationId = 101L,
memberId = 10L,
nickname = "donor",
profileImageUrl = "https://cdn.test/donor.png",
@@ -101,6 +102,7 @@ class CreatorChannelDonationQueryPolicyTest {
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
)
val donationRecord = CreatorChannelDonationRecord(
donationId = 101L,
memberId = 10L,
nickname = "donor",
profileImagePath = null,