diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt new file mode 100644 index 00000000..b727b36f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt @@ -0,0 +1,73 @@ +package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType +import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort +import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@Repository +class HomeFollowingNewsInboxPersistenceAdapter( + private val repository: HomeFollowingNewsInboxJpaRepository, + private val entityManager: EntityManager +) : HomeFollowingNewsInboxPort { + @Transactional + override fun insertIgnoreAll(records: List): Int { + if (records.isEmpty()) { + return 0 + } + + return records + .distinctBy { Triple(it.memberId, it.newsType, it.sourceKey) } + .sumOf { record -> insertIgnore(record) } + } + + @Transactional + override fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long { + return repository.deactivateByMemberIdAndCreatorId(memberId, creatorId).toLong() + } + + override fun findActiveFollowerIds(creatorId: Long): List { + return repository.findActiveFollowerIds(creatorId) + } + + private fun insertIgnore(record: HomeFollowingNewsInboxRecord): Int { + val newsType = FollowingNewsType.valueOf(record.newsType) + if (repository.existsByMemberIdAndNewsTypeAndSourceKey(record.memberId, newsType, record.sourceKey)) { + return 0 + } + + return try { + repository.saveAndFlush(record.toEntity(newsType)) + 1 + } catch (e: DataIntegrityViolationException) { + entityManager.clear() + if (repository.existsByMemberIdAndNewsTypeAndSourceKey(record.memberId, newsType, record.sourceKey)) { + 0 + } else { + throw e + } + } + } + + private fun HomeFollowingNewsInboxRecord.toEntity(newsType: FollowingNewsType): HomeFollowingNewsInbox { + return HomeFollowingNewsInbox( + memberId = memberId, + creatorId = creatorId, + newsType = newsType, + sourceKey = sourceKey, + targetId = targetId, + occurredAtUtc = occurredAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + creatorNickname = creatorNickname, + creatorProfileImagePath = creatorProfileImagePath, + title = title, + body = body, + thumbnailImagePath = thumbnailImagePath, + rank = rank, + isAdult = isAdult + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt new file mode 100644 index 00000000..50aebc10 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt @@ -0,0 +1,165 @@ +package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType +import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.dao.DataIntegrityViolationException +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 HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor( + private val repository: HomeFollowingNewsInboxJpaRepository, + private val entityManager: EntityManager +) { + private val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager) + + @Test + @DisplayName("insertIgnoreAll은 memberId newsType sourceKey 중복을 예외 없이 무시하고 신규 row만 저장한다") + fun shouldInsertOnlyNewRowsWhenUniqueSourceIsDuplicated() { + val firstInsertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = "CREATOR_RANKING:1:2026-06-25"))) + val secondInsertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = "CREATOR_RANKING:1:2026-06-25"))) + + entityManager.flush() + entityManager.clear() + + assertEquals(1, firstInsertCount) + assertEquals(0, secondInsertCount) + assertEquals(1, repository.findAll().size) + assertEquals(FollowingNewsType.CREATOR_RANKING, repository.findAll().first().newsType) + } + + @Test + @DisplayName("insertIgnoreAll은 exists 확인 이후 발생한 중복 insert 충돌도 예외 없이 무시한다") + fun shouldIgnoreDuplicateInsertRaceAfterExistsCheck() { + val mockRepository = Mockito.mock(HomeFollowingNewsInboxJpaRepository::class.java) + val mockEntityManager = Mockito.mock(EntityManager::class.java) + val raceAdapter = HomeFollowingNewsInboxPersistenceAdapter(mockRepository, mockEntityManager) + val record = record(sourceKey = "race-source-key") + Mockito.`when`( + mockRepository.existsByMemberIdAndNewsTypeAndSourceKey( + record.memberId, + FollowingNewsType.CREATOR_RANKING, + record.sourceKey + ) + ).thenReturn(false, true) + Mockito.`when`(mockRepository.saveAndFlush(Mockito.any(HomeFollowingNewsInbox::class.java))) + .thenThrow(DataIntegrityViolationException("duplicate")) + + val insertCount = raceAdapter.insertIgnoreAll(listOf(record)) + + assertEquals(0, insertCount) + Mockito.verify(mockEntityManager).clear() + } + + @Test + @DisplayName("memberId creatorId 기준 활성 inbox row를 비활성화한다") + fun shouldDeactivateActiveRowsByMemberAndCreator() { + adapter.insertIgnoreAll( + listOf( + record(memberId = 10L, creatorId = 1L, sourceKey = "A"), + record(memberId = 10L, creatorId = 1L, sourceKey = "B"), + record(memberId = 11L, creatorId = 1L, sourceKey = "C") + ) + ) + entityManager.flush() + entityManager.clear() + + val deactivatedCount = adapter.deactivateByMemberIdAndCreatorId(memberId = 10L, creatorId = 1L) + entityManager.flush() + entityManager.clear() + + val rows = repository.findAll().sortedBy { it.sourceKey } + assertEquals(2L, deactivatedCount) + assertFalse(rows.first { it.sourceKey == "A" }.isActive) + assertFalse(rows.first { it.sourceKey == "B" }.isActive) + assertTrue(rows.first { it.sourceKey == "C" }.isActive) + } + + @Test + @DisplayName("findActiveFollowerIds는 활성 팔로우 관계가 있는 회원 id만 반환한다") + fun shouldFindOnlyActiveFollowerIds() { + val creator = saveMember("creator", MemberRole.CREATOR) + val activeFollowerWithoutInbox = saveMember("active-follower", MemberRole.USER) + val inactiveFollowerWithInbox = saveMember("inactive-follower", MemberRole.USER) + val otherCreatorFollower = saveMember("other-follower", MemberRole.USER) + val otherCreator = saveMember("other-creator", MemberRole.CREATOR) + saveFollowing(activeFollowerWithoutInbox, creator, isActive = true) + saveFollowing(inactiveFollowerWithInbox, creator, isActive = false) + saveFollowing(otherCreatorFollower, otherCreator, isActive = true) + adapter.insertIgnoreAll( + listOf( + record(memberId = inactiveFollowerWithInbox.id!!, creatorId = creator.id!!, sourceKey = "inactive-inbox"), + record(memberId = otherCreatorFollower.id!!, creatorId = otherCreator.id!!, sourceKey = "other-inbox") + ) + ) + entityManager.flush() + entityManager.clear() + + val followerIds = adapter.findActiveFollowerIds(creatorId = creator.id!!) + + assertEquals(listOf(activeFollowerWithoutInbox.id!!), followerIds) + } + + private fun saveMember(seed: String, role: MemberRole): Member { + val member = Member( + email = "$seed@test.com", + password = "password", + nickname = seed, + role = role + ) + entityManager.persist(member) + return member + } + + private fun saveFollowing(member: Member, creator: Member, isActive: Boolean): CreatorFollowing { + val following = CreatorFollowing(isActive = isActive).apply { + this.member = member + this.creator = creator + } + entityManager.persist(following) + return following + } + + private fun record( + memberId: Long = 10L, + creatorId: Long = 1L, + sourceKey: String, + newsType: FollowingNewsType = FollowingNewsType.CREATOR_RANKING + ): HomeFollowingNewsInboxRecord { + return HomeFollowingNewsInboxRecord( + memberId = memberId, + creatorId = creatorId, + newsType = newsType.name, + sourceKey = sourceKey, + targetId = creatorId, + occurredAtUtc = LocalDateTime.of(2026, 6, 25, 0, 0, 0), + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0, 0), + creatorNickname = "creator-$creatorId", + creatorProfileImagePath = "profile-$creatorId.png", + title = "title", + body = "body", + thumbnailImagePath = null, + rank = 1, + isAdult = false + ) + } +}