diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt new file mode 100644 index 00000000..ddec5727 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort + +interface CreatorChannelCommunityQueryRepository : CreatorChannelCommunityQueryPort diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt new file mode 100644 index 00000000..bb8fe23e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt @@ -0,0 +1,340 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence + +import com.querydsl.core.Tuple +import com.querydsl.core.types.OrderSpecifier +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.core.types.dsl.CaseBuilder +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.block.QBlockMember +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord +import org.springframework.stereotype.Repository + +@Repository +class DefaultCreatorChannelCommunityQueryRepository( + private val queryFactory: JPAQueryFactory +) : CreatorChannelCommunityQueryRepository { + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? { + val creator = queryFactory + .select(member.id, member.role, member.nickname) + .from(member) + .where( + member.id.eq(creatorId), + member.isActive.isTrue + ) + .fetchFirst() ?: return null + + return CreatorChannelCommunityCreatorRecord( + creatorId = creator.get(member.id)!!, + role = creator.get(member.role)!!, + nickname = creator.get(member.nickname)!! + ) + } + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean { + val blockMember = QBlockMember("creatorChannelCommunityBlockMember") + return queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.isActive.isTrue, + blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId)) + .or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId))) + ) + .fetchFirst() != null + } + + override fun countCommunityPosts( + creatorId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): Int { + return queryFactory + .select(creatorCommunity.id.count()) + .from(creatorCommunity) + .where(communityPostCondition(creatorId, canViewAdultContent)) + .fetchOne() + ?.toInt() + ?: 0 + } + + override fun findCommunityPosts( + creatorId: Long, + viewerId: Long, + canViewAdultContent: Boolean, + offset: Long, + limit: Int + ): List { + val rows = queryFactory + .selectCommunityPostRow() + .from(creatorCommunity) + .where(communityPostCondition(creatorId, canViewAdultContent)) + .orderBy( + CaseBuilder() + .`when`(creatorCommunity.isFixed.isTrue) + .then(1) + .otherwise(0) + .desc(), + creatorCommunity.fixedAt.desc().nullsLast(), + CaseBuilder() + .`when`(creatorCommunity.isFixed.isTrue) + .then(creatorCommunity.id) + .otherwise(0L) + .desc(), + creatorCommunity.createdAt.desc(), + creatorCommunity.id.desc() + ) + .offset(offset) + .limit(limit.toLong()) + .fetch() + + return rows.toCommunityPostRecords(creatorId, viewerId) + } + + override fun findHomeCommunityPosts( + creatorId: Long, + viewerId: Long, + isPinned: Boolean, + canViewAdultContent: Boolean, + limit: Int + ): List { + val rows = queryFactory + .selectCommunityPostRow() + .from(creatorCommunity) + .where( + homeCommunityPostCondition(creatorId, viewerId, canViewAdultContent), + creatorCommunity.isFixed.eq(isPinned), + pinnedPostCondition(isPinned) + ) + .orderBy(*homeCommunityPostOrder(isPinned)) + .limit(limit.toLong()) + .fetch() + + return rows.toCommunityPostRecords(creatorId, viewerId) + } + + private fun JPAQueryFactory.selectCommunityPostRow() = select( + creatorCommunity.id, + creatorCommunity.member.id, + creatorCommunity.member.nickname, + creatorCommunity.member.profileImage, + creatorCommunity.imagePath, + creatorCommunity.audioPath, + creatorCommunity.content, + creatorCommunity.price, + creatorCommunity.createdAt, + creatorCommunity.fixedAt, + creatorCommunity.isFixed, + creatorCommunity.isCommentAvailable + ) + + private fun communityPostCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression { + val condition = creatorCommunity.isActive.isTrue + .and(creatorCommunity.member.id.eq(creatorId)) + .and(creatorCommunity.member.isActive.isTrue) + + return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse) + } + + private fun homeCommunityPostCondition( + creatorId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): BooleanExpression { + val condition = creatorCommunity.member.id.eq(creatorId) + .and(creatorCommunity.member.isActive.isTrue) + .and( + creatorCommunity.isActive.isTrue.or( + queryFactory + .select(useCan.id) + .from(useCan) + .where( + useCan.member.id.eq(viewerId), + useCan.communityPost.id.eq(creatorCommunity.id), + useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST), + useCan.isRefund.isFalse + ) + .exists() + ) + ) + + return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse) + } + + private fun pinnedPostCondition(isPinned: Boolean): BooleanExpression? { + return if (isPinned) creatorCommunity.fixedAt.isNotNull else null + } + + private fun homeCommunityPostOrder(isPinned: Boolean): Array> { + return if (isPinned) { + arrayOf(creatorCommunity.fixedAt.desc(), creatorCommunity.id.desc()) + } else { + arrayOf(creatorCommunity.createdAt.desc(), creatorCommunity.id.desc()) + } + } + + private fun List.toCommunityPostRecords( + creatorId: Long, + viewerId: Long + ): List { + val postIds = map { it.postId } + val orderedPostIds = orderedCommunityPostIds(creatorId, viewerId, postIds) + val likeCounts = communityLikeCounts(postIds) + val commentAvailablePostIds = filter { it.isCommentAvailable }.map { it.postId } + val commentCounts = communityCommentCounts(commentAvailablePostIds, creatorId, viewerId) + + return map { row -> + val postId = row.postId + val isPinned = row.isPinned + CreatorChannelCommunityPostRecord( + postId = postId, + creatorId = row.creatorId, + creatorNickname = row.creatorNickname, + creatorProfilePath = row.creatorProfilePath, + imagePath = row.imagePath, + audioPath = row.audioPath, + content = row.content, + price = row.price, + createdAt = row.createdAt, + existOrdered = postId in orderedPostIds, + isCommentAvailable = row.isCommentAvailable, + likeCount = likeCounts[postId] ?: 0, + commentCount = commentCounts[postId] ?: 0, + isPinned = isPinned + ) + } + } + + private fun orderedCommunityPostIds( + creatorId: Long, + viewerId: Long, + postIds: List + ): Set { + if (postIds.isEmpty()) return emptySet() + if (creatorId == viewerId) return postIds.toSet() + + return queryFactory + .select(useCan.communityPost.id) + .distinct() + .from(useCan) + .where( + useCan.member.id.eq(viewerId), + useCan.communityPost.id.`in`(postIds), + useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST), + useCan.isRefund.isFalse + ) + .fetch() + .toSet() + } + + private fun communityLikeCounts(postIds: List): Map { + if (postIds.isEmpty()) return emptyMap() + + return queryFactory + .select(creatorCommunityLike.creatorCommunity.id, creatorCommunityLike.id.count()) + .from(creatorCommunityLike) + .where( + creatorCommunityLike.creatorCommunity.id.`in`(postIds), + creatorCommunityLike.isActive.isTrue + ) + .groupBy(creatorCommunityLike.creatorCommunity.id) + .fetch() + .associate { + it.get(creatorCommunityLike.creatorCommunity.id)!! to (it.get(creatorCommunityLike.id.count())?.toInt() ?: 0) + } + } + + private fun communityCommentCounts( + postIds: List, + creatorId: Long, + viewerId: Long + ): Map { + if (postIds.isEmpty()) return emptyMap() + + return queryFactory + .select(creatorCommunityComment.creatorCommunity.id, creatorCommunityComment.id.count()) + .from(creatorCommunityComment) + .where( + creatorCommunityComment.creatorCommunity.id.`in`(postIds), + creatorCommunityComment.isActive.isTrue, + creatorCommunityComment.parent.isNull, + visibleSecretCommentCondition(creatorId, viewerId), + notBlockedCommentWriterCondition(viewerId) + ) + .groupBy(creatorCommunityComment.creatorCommunity.id) + .fetch() + .associate { + it.get(creatorCommunityComment.creatorCommunity.id)!! to + (it.get(creatorCommunityComment.id.count())?.toInt() ?: 0) + } + } + + private fun visibleSecretCommentCondition(creatorId: Long, viewerId: Long): BooleanExpression { + return creatorCommunityComment.isSecret.isFalse + .or( + creatorCommunityComment.creatorCommunity.member.id.eq(creatorId) + .and(creatorCommunityComment.creatorCommunity.member.id.eq(viewerId)) + ) + .or(creatorCommunityComment.member.id.eq(viewerId)) + } + + private fun notBlockedCommentWriterCondition(viewerId: Long): BooleanExpression { + val viewerBlock = QBlockMember("communityCommentViewerBlockWriter") + val writerBlock = QBlockMember("communityCommentWriterBlockViewer") + return creatorCommunityComment.member.id.notIn( + queryFactory + .select(viewerBlock.blockedMember.id) + .from(viewerBlock) + .where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue) + ).and( + creatorCommunityComment.member.id.notIn( + queryFactory + .select(writerBlock.member.id) + .from(writerBlock) + .where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue) + ) + ) + } + + private val Tuple.postId: Long + get() = get(creatorCommunity.id)!! + + private val Tuple.creatorId: Long + get() = get(creatorCommunity.member.id)!! + + private val Tuple.creatorNickname: String + get() = get(creatorCommunity.member.nickname)!! + + private val Tuple.creatorProfilePath: String? + get() = get(creatorCommunity.member.profileImage) + + private val Tuple.imagePath: String? + get() = get(creatorCommunity.imagePath) + + private val Tuple.audioPath: String? + get() = get(creatorCommunity.audioPath) + + private val Tuple.content: String + get() = get(creatorCommunity.content)!! + + private val Tuple.price: Int + get() = get(creatorCommunity.price)!! + + private val Tuple.createdAt + get() = get(creatorCommunity.createdAt)!! + + private val Tuple.fixedAt + get() = get(creatorCommunity.fixedAt) + + private val Tuple.isPinned: Boolean + get() = get(creatorCommunity.isFixed)!! + + private val Tuple.isCommentAvailable: Boolean + get() = get(creatorCommunity.isCommentAvailable)!! +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt new file mode 100644 index 00000000..8c53345d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt @@ -0,0 +1,445 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( + private val entityManager: EntityManager, + queryFactory: JPAQueryFactory +) { + private val repository = DefaultCreatorChannelCommunityQueryRepository(queryFactory) + + @Test + @DisplayName("활성 크리에이터는 조회되고 비활성 크리에이터는 null이다") + fun shouldFindOnlyActiveCreator() { + val viewer = saveMember("creator-lookup-viewer", MemberRole.USER) + val activeCreator = saveMember("active-creator", MemberRole.CREATOR) + val inactiveCreator = saveMember("inactive-creator", MemberRole.CREATOR, isActive = false) + flushAndClear() + + val activeRecord = repository.findCreator(activeCreator.id!!, viewer.id!!) + val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!) + + assertNotNull(activeRecord) + assertEquals(activeCreator.id, activeRecord!!.creatorId) + assertEquals(MemberRole.CREATOR, activeRecord.role) + assertEquals(activeCreator.nickname, activeRecord.nickname) + assertNull(inactiveRecord) + } + + @Test + @DisplayName("조회자와 크리에이터 사이 양방향 활성 차단은 차단 상태로 조회된다") + fun shouldFindActiveBlockInBothDirections() { + val viewer = saveMember("block-viewer", MemberRole.USER) + val creator = saveMember("block-creator", MemberRole.CREATOR) + val otherCreator = saveMember("not-blocked-creator", MemberRole.CREATOR) + saveBlock(viewer, creator, isActive = true) + saveBlock(otherCreator, viewer, isActive = false) + flushAndClear() + + assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!)) + assertTrue(repository.existsBlockedBetween(creator.id!!, viewer.id!!)) + assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!)) + } + + @Test + @DisplayName("게시글 수는 대상 크리에이터의 활성 게시글만 세고 성인 콘텐츠 정책을 우선 적용한다") + fun shouldCountOnlyVisibleActiveCreatorPostsWithAdultFilter() { + val viewer = saveMember("count-viewer", MemberRole.USER) + val creator = saveMember("count-creator", MemberRole.CREATOR) + val otherCreator = saveMember("other-count-creator", MemberRole.CREATOR) + saveCommunity(creator, isFixed = false, price = 0, isAdult = false) + val adultPost = saveCommunity(creator, isFixed = false, price = 100, isAdult = true) + saveCommunity(creator, isFixed = false, price = 0, isActive = false) + saveCommunity(otherCreator, isFixed = false, price = 0, isAdult = false) + saveCommunityOrder(viewer, adultPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false) + flushAndClear() + + assertEquals(1, repository.countCommunityPosts(creator.id!!, viewer.id!!, canViewAdultContent = false)) + assertEquals(2, repository.countCommunityPosts(creator.id!!, viewer.id!!, canViewAdultContent = true)) + + val visiblePosts: List = repository.findCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + canViewAdultContent = false, + offset = 0, + limit = 10 + ) + val visiblePostIds = visiblePosts.map { it.postId } + + assertFalse(adultPost.id in visiblePostIds) + } + + @Test + @DisplayName("통합 목록은 고정글 우선 정렬 후 일반글 정렬을 적용하고 offset과 limit으로 페이징한다") + fun shouldFindUnifiedPagedPostsWithPinnedFirstOrdering() { + val viewer = saveMember("ordering-viewer", MemberRole.USER) + val creator = saveMember("ordering-creator", MemberRole.CREATOR) + val now = LocalDateTime.of(2026, 6, 21, 12, 0) + val oldPinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(2), price = 0) + val olderCreatedSameFixedPinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0) + val newerCreatedSameFixedPinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0) + val oldNormal = saveCommunity(creator, isFixed = false, price = 0) + val middleNormal = saveCommunity(creator, isFixed = false, price = 0) + val newNormal = saveCommunity(creator, isFixed = false, price = 0) + flushAndClear() + updateCreatedAt("CreatorCommunity", oldPinned.id!!, now.minusDays(10)) + updateCreatedAt("CreatorCommunity", olderCreatedSameFixedPinned.id!!, now.minusDays(1)) + updateCreatedAt("CreatorCommunity", newerCreatedSameFixedPinned.id!!, now.minusDays(5)) + updateCreatedAt("CreatorCommunity", oldNormal.id!!, now.minusDays(3)) + updateCreatedAt("CreatorCommunity", middleNormal.id!!, now.minusDays(2)) + updateCreatedAt("CreatorCommunity", newNormal.id!!, now.minusDays(1)) + flushAndClear() + + val firstPage: List = repository.findCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + canViewAdultContent = true, + offset = 0, + limit = 3 + ) + val secondPage: List = repository.findCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + canViewAdultContent = true, + offset = 3, + limit = 2 + ) + + assertEquals( + listOf(newerCreatedSameFixedPinned.id, olderCreatedSameFixedPinned.id, oldPinned.id), + firstPage.map { it.postId } + ) + assertEquals(listOf(newNormal.id, middleNormal.id), secondPage.map { it.postId }) + assertTrue(firstPage[0].isPinned) + assertEquals(now.minusDays(5), firstPage[0].createdAt) + } + + @Test + @DisplayName("좋아요는 활성 좋아요만 세고 댓글 불가 게시글의 댓글 수는 0이다") + fun shouldCountActiveLikesAndZeroCommentsWhenUnavailable() { + val viewer = saveMember("likes-viewer", MemberRole.USER) + val creator = saveMember("likes-creator", MemberRole.CREATOR) + val activeLiker = saveMember("active-liker", MemberRole.USER) + val inactiveLiker = saveMember("inactive-liker", MemberRole.USER) + val commenter = saveMember("unavailable-commenter", MemberRole.USER) + val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = false) + saveCommunityLike(activeLiker, post, isActive = true) + saveCommunityLike(inactiveLiker, post, isActive = false) + saveCommunityComment(commenter, post, isActive = true) + flushAndClear() + + val record = repository.findCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + canViewAdultContent = true, + offset = 0, + limit = 10 + ).single() + + assertEquals(1, record.likeCount) + assertEquals(0, record.commentCount) + assertFalse(record.isCommentAvailable) + } + + @Test + @DisplayName("댓글 수는 활성 최상위 댓글만 세고 비밀 댓글과 차단 작성자 정책을 적용한다") + fun shouldCountVisibleActiveRootCommentsOnly() { + val creator = saveMember("comment-creator", MemberRole.CREATOR) + val viewer = saveMember("comment-viewer", MemberRole.USER) + val secretWriter = saveMember("secret-writer", MemberRole.USER) + val blockedWriter = saveMember("blocked-writer", MemberRole.USER) + val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = true) + val publicRoot = saveCommunityComment(viewer, post, isActive = true) + saveCommunityComment(viewer, post, isActive = true, parent = publicRoot) + saveCommunityComment(viewer, post, isActive = false) + saveCommunityComment(secretWriter, post, isActive = true, isSecret = true) + saveCommunityComment(blockedWriter, post, isActive = true) + saveBlock(viewer, blockedWriter, isActive = true) + flushAndClear() + + val viewerRecord = repository.findCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + canViewAdultContent = true, + offset = 0, + limit = 10 + ).single() + val writerRecord = repository.findCommunityPosts( + creatorId = creator.id!!, + viewerId = secretWriter.id!!, + canViewAdultContent = true, + offset = 0, + limit = 10 + ).single() + val creatorRecord = repository.findCommunityPosts( + creatorId = creator.id!!, + viewerId = creator.id!!, + canViewAdultContent = true, + offset = 0, + limit = 10 + ).single() + + assertEquals(1, viewerRecord.commentCount) + assertEquals(3, writerRecord.commentCount) + assertEquals(3, creatorRecord.commentCount) + } + + @Test + @DisplayName("유효 구매 내역만 구매 상태로 인정하고 중복 구매는 목록 행을 중복시키지 않는다") + fun shouldUseValidPurchasesWithoutDuplicatingListItems() { + val viewer = saveMember("purchase-viewer", MemberRole.USER) + val creator = saveMember("purchase-creator", MemberRole.CREATOR) + val otherViewer = saveMember("purchase-other-viewer", MemberRole.USER) + val validPost = saveCommunity(creator, isFixed = false, price = 100, imagePath = "valid.png", audioPath = "valid.mp3") + val wrongUsagePost = saveCommunity(creator, isFixed = false, price = 100, imagePath = "wrong-usage.png") + val refundedPost = saveCommunity(creator, isFixed = false, price = 100, imagePath = "refunded.png") + val otherViewerPost = saveCommunity(creator, isFixed = false, price = 100, imagePath = "other-viewer.png") + saveCommunityOrder(viewer, validPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false) + saveCommunityOrder(viewer, validPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false) + saveCommunityOrder(viewer, wrongUsagePost, CanUsage.DONATION, isRefund = false) + saveCommunityOrder(viewer, refundedPost, CanUsage.PAID_COMMUNITY_POST, isRefund = true) + saveCommunityOrder(otherViewer, otherViewerPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false) + flushAndClear() + + val records: List = repository.findCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + canViewAdultContent = true, + offset = 0, + limit = 10 + ) + val recordsById = records.associateBy { it.postId } + + assertEquals(records.map { it.postId }.distinct(), records.map { it.postId }) + assertTrue(recordsById.getValue(validPost.id!!).existOrdered) + assertEquals("valid.mp3", recordsById.getValue(validPost.id!!).audioPath) + assertFalse(recordsById.getValue(wrongUsagePost.id!!).existOrdered) + assertFalse(recordsById.getValue(refundedPost.id!!).existOrdered) + assertFalse(recordsById.getValue(otherViewerPost.id!!).existOrdered) + } + + @Test + @DisplayName("크리에이터 본인은 구매 내역이 없어도 구매 상태로 조회된다") + fun shouldMarkCreatorOwnPostAsOrdered() { + val creator = saveMember("self-order-creator", MemberRole.CREATOR) + saveCommunity(creator, isFixed = false, price = 100, imagePath = "self.png", audioPath = "self.mp3") + flushAndClear() + + val record = repository.findCommunityPosts( + creatorId = creator.id!!, + viewerId = creator.id!!, + canViewAdultContent = true, + offset = 0, + limit = 10 + ).single() + + assertTrue(record.existOrdered) + assertEquals("self.mp3", record.audioPath) + } + + @Test + @DisplayName("홈 커뮤니티 요약은 고정글과 일반글을 분리해 조회한다") + fun shouldFindHomeCommunityPostsByPinnedFlag() { + val viewer = saveMember("home-summary-viewer", MemberRole.USER) + val creator = saveMember("home-summary-creator", MemberRole.CREATOR) + val now = LocalDateTime.of(2026, 6, 21, 12, 0) + val pinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0) + val normal = saveCommunity(creator, isFixed = false, price = 0) + val adultPinned = saveCommunity(creator, isFixed = true, fixedAt = now, price = 0, isAdult = true) + flushAndClear() + updateCreatedAt("CreatorCommunity", normal.id!!, now.minusDays(1)) + flushAndClear() + + val pinnedPosts: List = repository.findHomeCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + isPinned = true, + canViewAdultContent = false, + limit = 3 + ) + val normalPosts: List = repository.findHomeCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + isPinned = false, + canViewAdultContent = false, + limit = 3 + ) + + assertEquals(listOf(pinned.id), pinnedPosts.map { it.postId }) + assertEquals(listOf(normal.id), normalPosts.map { it.postId }) + assertFalse(adultPinned.id in pinnedPosts.map { it.postId }) + assertTrue(pinnedPosts.single().isPinned) + assertFalse(normalPosts.single().isPinned) + } + + @Test + @DisplayName("홈 커뮤니티 요약은 구매한 비활성 유료글을 포함하되 성인 콘텐츠 정책을 우선 적용한다") + fun shouldFindPurchasedInactiveHomeCommunityPostsWithAdultFilter() { + val viewer = saveMember("home-purchased-viewer", MemberRole.USER) + val creator = saveMember("home-purchased-creator", MemberRole.CREATOR) + val inactivePurchasedPost = saveCommunity(creator, isFixed = false, price = 100, isActive = false) + val adultInactivePurchasedPost = saveCommunity( + creator = creator, + isFixed = false, + price = 100, + isAdult = true, + isActive = false + ) + saveCommunityOrder(viewer, inactivePurchasedPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false) + saveCommunityOrder(viewer, adultInactivePurchasedPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false) + flushAndClear() + + val adultAllowedPosts: List = repository.findHomeCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + isPinned = false, + canViewAdultContent = true, + limit = 10 + ) + val adultBlockedPosts: List = repository.findHomeCommunityPosts( + creatorId = creator.id!!, + viewerId = viewer.id!!, + isPinned = false, + canViewAdultContent = false, + limit = 10 + ) + + assertTrue(inactivePurchasedPost.id in adultAllowedPosts.map { it.postId }) + assertTrue(adultInactivePurchasedPost.id in adultAllowedPosts.map { it.postId }) + assertTrue(inactivePurchasedPost.id in adultBlockedPosts.map { it.postId }) + assertFalse(adultInactivePurchasedPost.id in adultBlockedPosts.map { it.postId }) + } + + private fun saveMember( + nickname: String, + role: MemberRole, + isActive: Boolean = true, + memberKind: MemberKind = MemberKind.HUMAN + ): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "$nickname.png", + role = role, + memberKind = memberKind, + isActive = isActive + ) + entityManager.persist(member) + return member + } + + private fun saveBlock(member: Member, blockedMember: Member, isActive: Boolean): BlockMember { + val block = BlockMember(isActive = isActive) + block.member = member + block.blockedMember = blockedMember + entityManager.persist(block) + return block + } + + private fun saveCommunity( + creator: Member, + isFixed: Boolean, + fixedAt: LocalDateTime? = null, + price: Int, + imagePath: String? = null, + audioPath: String? = null, + isAdult: Boolean = false, + isCommentAvailable: Boolean = true, + content: String = "community", + isActive: Boolean = true + ): CreatorCommunity { + val community = CreatorCommunity( + content = content, + price = price, + isCommentAvailable = isCommentAvailable, + isAdult = isAdult, + audioPath = audioPath, + imagePath = imagePath, + isActive = isActive, + isFixed = isFixed, + fixedAt = fixedAt + ) + community.member = creator + entityManager.persist(community) + return community + } + + private fun saveCommunityLike(member: Member, community: CreatorCommunity, isActive: Boolean): CreatorCommunityLike { + val like = CreatorCommunityLike(isActive = isActive) + like.member = member + like.creatorCommunity = community + entityManager.persist(like) + return like + } + + private fun saveCommunityComment( + member: Member, + community: CreatorCommunity, + isActive: Boolean, + isSecret: Boolean = false, + parent: CreatorCommunityComment? = null + ): CreatorCommunityComment { + val comment = CreatorCommunityComment(comment = "comment", isSecret = isSecret, isActive = isActive) + comment.member = member + comment.creatorCommunity = community + comment.parent = parent + entityManager.persist(comment) + return comment + } + + private fun saveCommunityOrder( + member: Member, + community: CreatorCommunity, + canUsage: CanUsage, + isRefund: Boolean + ): UseCan { + val useCan = UseCan(canUsage, community.price, rewardCan = 0, isRefund = isRefund) + useCan.member = member + useCan.communityPost = community + entityManager.persist(useCan) + return useCan + } + + private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } +}