feat(home-following): 팔로잉 소식 inbox 저장 adapter를 추가한다
This commit is contained in:
@@ -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<HomeFollowingNewsInboxRecord>): 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<Long> {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user