From 13f3fc459c31fa9120301121f26607f47cdd1825 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 15:07:18 +0900 Subject: [PATCH 01/49] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EC=BA=90=EC=8B=B1?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CachedFeedLikeState.kt`: 피드의 좋아요 여부(`isLiked`)와 좋아요 수(`likeCount`)를 관리하는 `CachedFeedLikeState` internal 데이터 클래스 추가 --- .../data/feed/repository/model/CachedFeedLikeState.kt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt new file mode 100644 index 000000000..5864dd34e --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt @@ -0,0 +1,6 @@ +package com.into.websoso.data.feed.repository.model + +internal data class CachedFeedLikeState( + val isLiked: Boolean, + val likeCount: Int, +) From 6df2b10129aca420d2bfd0827a113638d01a320b Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 15:08:27 +0900 Subject: [PATCH 02/49] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `feedDetailStates` (ConcurrentHashMap)를 추가하여 피드 상세 정보의 좋아요 상태(`isLiked`, `likeCount`)를 별도로 관리하도록 개선 - `saveLikeState` 시 메인 피드 리스트(Flow)에 해당 데이터가 없는 경우에도 상세 캐시 상태를 업데이트하도록 로직 수정 - `fetchFeed` 수행 시 서버에서 조회한 최신 좋아요 상태를 `feedDetailStates` 캐시에 반영하는 로직 추가 - 캐시된 피드 존재 여부를 확인하기 위한 `findCachedFeed` 메서드 및 상세 상태 업데이트를 위한 `updateFeedDetailState` 메서드 구현 --- .../feed/repository/UpdatedFeedRepository.kt | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index dcdfe4953..765eba96d 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -15,6 +15,7 @@ import com.into.websoso.data.feed.model.CommentsEntity import com.into.websoso.data.feed.model.FeedDetailEntity import com.into.websoso.data.feed.model.FeedEntity import com.into.websoso.data.feed.model.FeedsEntity +import com.into.websoso.data.feed.repository.model.CachedFeedLikeState import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -54,6 +55,7 @@ class UpdatedFeedRepository private val dirtyFeedStates = ConcurrentHashMap() private val originalFeedStates = ConcurrentHashMap() + private val feedDetailStates = ConcurrentHashMap() // ============================================================================================ // Feed Creation & Modification @@ -238,6 +240,10 @@ class UpdatedFeedRepository updateFeedInFlow(_sosoAllFeeds, feedId) updateFeedInFlow(_sosoRecommendedFeeds, feedId) updateFeedInFlow(_myFeeds, feedId) + + if (findCachedFeed(feedId) == null) { + updateFeedDetailState(feedId) + } } private fun updateFeedInFlow( @@ -260,6 +266,23 @@ class UpdatedFeedRepository } } + private fun updateFeedDetailState(feedId: Long) { + val target = feedDetailStates[feedId] ?: return + val newLiked = !target.isLiked + val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 + + trackDirtyState(feedId, target.isLiked, newLiked) + feedDetailStates[feedId] = CachedFeedLikeState( + isLiked = newLiked, + likeCount = newCount.coerceAtLeast(0), + ) + } + + private fun findCachedFeed(feedId: Long): FeedEntity? = + _sosoAllFeeds.value.find { it.id == feedId } + ?: _sosoRecommendedFeeds.value.find { it.id == feedId } + ?: _myFeeds.value.find { it.id == feedId } + /** * 변경된 좋아요 상태를 추적합니다. */ @@ -365,7 +388,12 @@ class UpdatedFeedRepository */ suspend fun fetchFeed(feedId: Long): FeedDetailEntity { val rawDetail = feedApi.getFeed(feedId).toData() - return applyDirtyStateToDetail(rawDetail) + val mergedDetail = applyDirtyStateToDetail(rawDetail) + feedDetailStates[feedId] = CachedFeedLikeState( + isLiked = mergedDetail.isLiked, + likeCount = mergedDetail.likeCount, + ) + return mergedDetail } private fun applyDirtyStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { From 3198b31bd40deb697407eaff450e6a589175ba2e Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 15:51:01 +0900 Subject: [PATCH 03/49] =?UTF-8?q?fix:=20UpdatedFeedRepository=20=EB=82=B4?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=80=EC=88=98=20=EB=B0=8F=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EB=AA=85=EC=B9=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 미동기화된 상태를 의미하는 `dirty` 관련 명칭을 사용자 의도가 반영된 대기 상태를 의미하는 `pendingLike`로 변경 - `dirtyFeedStates`, `originalFeedStates`를 각각 `pendingLikeStates`, `originalLikeStates`로 변경 - `applyDirtyState`, `trackDirtyState` 등의 메서드를 `applyPendingLikeState`, `trackPendingLikeState`로 변경하여 가독성 개선 --- .../feed/repository/UpdatedFeedRepository.kt | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 765eba96d..c5a2241c2 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -53,8 +53,8 @@ class UpdatedFeedRepository private val _myFeeds = MutableStateFlow>(emptyList()) val myFeeds = _myFeeds.asStateFlow() - private val dirtyFeedStates = ConcurrentHashMap() - private val originalFeedStates = ConcurrentHashMap() + private val pendingLikeStates = ConcurrentHashMap() + private val originalLikeStates = ConcurrentHashMap() private val feedDetailStates = ConcurrentHashMap() // ============================================================================================ @@ -168,7 +168,7 @@ class UpdatedFeedRepository // ============================================================================================ /** - * 서버에서 피드 리스트를 조회하고, 로컬의 미동기화된 좋아요 상태(Dirty)를 병합하여 캐시(Flow)를 갱신합니다. + * 서버에서 피드 리스트를 조회하고, 로컬의 미동기화된 좋아요 상태를 병합하여 캐시(Flow)를 갱신합니다. */ suspend fun fetchFeeds( lastFeedId: Long, @@ -182,7 +182,7 @@ class UpdatedFeedRepository size = size, ).toData() - val mergedFeeds = result.feeds.map { feed -> applyDirtyState(feed) } + val mergedFeeds = result.feeds.map { feed -> applyPendingLikeState(feed) } val isRecommended = feedsOption == "RECOMMENDED" val targetFlow = if (isRecommended) _sosoRecommendedFeeds else _sosoAllFeeds @@ -206,7 +206,7 @@ class UpdatedFeedRepository feeds: List, isRefreshed: Boolean, ) { - val mergedFeeds = feeds.map { feed -> applyDirtyState(feed) } + val mergedFeeds = feeds.map { feed -> applyPendingLikeState(feed) } _myFeeds.update { current -> if (isRefreshed) mergedFeeds else (current + mergedFeeds).distinctBy { it.id } @@ -216,8 +216,8 @@ class UpdatedFeedRepository /** * 서버 데이터보다 로컬의 변경사항(좋아요)을 우선 적용하여 반환합니다. */ - private fun applyDirtyState(feed: FeedEntity): FeedEntity { - val localIsLiked = dirtyFeedStates[feed.id] ?: return feed + private fun applyPendingLikeState(feed: FeedEntity): FeedEntity { + val localIsLiked = pendingLikeStates[feed.id] ?: return feed if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 @@ -258,7 +258,7 @@ class UpdatedFeedRepository val newLiked = !target.isLiked val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 - trackDirtyState(feedId, target.isLiked, newLiked) + trackPendingLikeState(feedId, target.isLiked, newLiked) val newList = list.toMutableList() newList[index] = target.copy(isLiked = newLiked, likeCount = newCount) @@ -271,7 +271,7 @@ class UpdatedFeedRepository val newLiked = !target.isLiked val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 - trackDirtyState(feedId, target.isLiked, newLiked) + trackPendingLikeState(feedId, target.isLiked, newLiked) feedDetailStates[feedId] = CachedFeedLikeState( isLiked = newLiked, likeCount = newCount.coerceAtLeast(0), @@ -284,18 +284,18 @@ class UpdatedFeedRepository ?: _myFeeds.value.find { it.id == feedId } /** - * 변경된 좋아요 상태를 추적합니다. + * 서버에 아직 반영되지 않은 마지막 좋아요 상태를 추적합니다. */ - private fun trackDirtyState( + private fun trackPendingLikeState( feedId: Long, original: Boolean, new: Boolean, ) { - originalFeedStates.putIfAbsent(feedId, original) - if (originalFeedStates[feedId] == new) { - dirtyFeedStates.remove(feedId) + originalLikeStates.putIfAbsent(feedId, original) + if (originalLikeStates[feedId] == new) { + pendingLikeStates.remove(feedId) } else { - dirtyFeedStates[feedId] = new + pendingLikeStates[feedId] = new } } @@ -303,11 +303,11 @@ class UpdatedFeedRepository * 기록된 변경 사항들을 서버와 동기화합니다. */ fun syncDirtyFeeds() { - if (dirtyFeedStates.isEmpty()) return + if (pendingLikeStates.isEmpty()) return - val syncMap = dirtyFeedStates.toMap() - dirtyFeedStates.clear() - originalFeedStates.clear() + val syncMap = pendingLikeStates.toMap() + pendingLikeStates.clear() + originalLikeStates.clear() scope.launch { syncMap.forEach { (id, isLiked) -> @@ -388,7 +388,7 @@ class UpdatedFeedRepository */ suspend fun fetchFeed(feedId: Long): FeedDetailEntity { val rawDetail = feedApi.getFeed(feedId).toData() - val mergedDetail = applyDirtyStateToDetail(rawDetail) + val mergedDetail = applyPendingLikeStateToDetail(rawDetail) feedDetailStates[feedId] = CachedFeedLikeState( isLiked = mergedDetail.isLiked, likeCount = mergedDetail.likeCount, @@ -396,8 +396,8 @@ class UpdatedFeedRepository return mergedDetail } - private fun applyDirtyStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { - val localIsLiked = dirtyFeedStates[feed.id] ?: return feed + private fun applyPendingLikeStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { + val localIsLiked = pendingLikeStates[feed.id] ?: return feed if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 From 77a2704441ee89810493c97fbbc3fc77208ce8c1 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 16:16:57 +0900 Subject: [PATCH 04/49] =?UTF-8?q?refactor:=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=AA=85?= =?UTF-8?q?=EC=B9=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: 메서드명을 `syncDirtyFeeds`에서 `syncPendingLikes`로 변경하고, 좋아요 상태를 동기화한다는 점을 명확히 하도록 주석 수정 - `UpdatedFeedDetailViewModel.kt`, `FeedViewModel.kt`: ViewModel이 해제되는 시점(`onCleared`)에 호출되는 메서드명을 변경된 명칭(`syncPendingLikes`)에 맞춰 수정 --- .../into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt | 2 +- .../websoso/data/feed/repository/UpdatedFeedRepository.kt | 4 ++-- .../main/java/com/into/websoso/feature/feed/FeedViewModel.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt index 0b353148a..eeff5bb93 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt @@ -145,7 +145,7 @@ class UpdatedFeedDetailViewModel */ override fun onCleared() { super.onCleared() - feedRepository.syncDirtyFeeds() + feedRepository.syncPendingLikes() } /** diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index c5a2241c2..128c48cee 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -300,9 +300,9 @@ class UpdatedFeedRepository } /** - * 기록된 변경 사항들을 서버와 동기화합니다. + * 서버에 아직 반영되지 않은 좋아요 상태들을 동기화합니다. */ - fun syncDirtyFeeds() { + fun syncPendingLikes() { if (pendingLikeStates.isEmpty()) return val syncMap = pendingLikeStates.toMap() diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt index 00fcaeaa2..56a0add78 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt @@ -202,7 +202,7 @@ class FeedViewModel override fun onCleared() { super.onCleared() - feedRepository.syncDirtyFeeds() + feedRepository.syncPendingLikes() } // --- 기타 탭/필터 로직 --- From efbaa6e7340687dbdd3747ef447fe3573ca88224 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 16:53:47 +0900 Subject: [PATCH 05/49] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20`PendingFeed?= =?UTF-8?q?LikeStore`=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 피드별 좋아요 상태(`isLiked`)를 관리하기 위한 `pendingLikes` Flow 및 CRUD 메서드 정의 - 서버 반영 전 로컬의 임시 상태를 관리하고, 상태 일치 시 삭제하는 기능을 포함하는 `PendingFeedLikeStore` 인터페이스 구현 --- .../data/feed/store/PendingFeedLikeStore.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt new file mode 100644 index 000000000..092f154c9 --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -0,0 +1,19 @@ +package com.into.websoso.data.feed.store + +import kotlinx.coroutines.flow.Flow + +interface PendingFeedLikeStore { + val pendingLikes: Flow> + + suspend fun getPendingLikes(): Map + + suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) + + suspend fun deletePendingLikeIfMatched( + feedId: Long, + isLiked: Boolean, + ): Boolean +} From ecd3b39db89a676a8db3115610ffc4b3d5023d6e Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:33:17 +0900 Subject: [PATCH 06/49] =?UTF-8?q?feat:=20PendingFeedLikeDataStore=EB=A5=BC?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20Dagger=20Qualifier=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DataStoreQualifier.kt`: 좋아요 요청 보류 상태를 관리하는 DataStore를 구분하기 위한 `@PendingFeedLikeDataStore` 한정자(Qualifier) 추가 --- .../com/into/websoso/core/datastore/di/DataStoreQualifier.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt index 318c474ea..4369aa82d 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt @@ -13,3 +13,7 @@ internal annotation class LibraryFilterDataStore @Qualifier @Retention(AnnotationRetention.BINARY) internal annotation class UserDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class PendingFeedLikeDataStore From 92956a8dc9d7145634762c0a40f8e3c4ca75072c Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:33:19 +0900 Subject: [PATCH 07/49] =?UTF-8?q?feat:=20PendingFeedLikeDataStore=EB=A5=BC?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20Dagger=20Qualifier=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DataStoreQualifier.kt`: 좋아요 요청 보류 상태를 관리하는 DataStore를 구분하기 위한 `@PendingFeedLikeDataStore` 한정자(Qualifier) 추가 --- .../websoso/core/datastore/di/DataStoreModule.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt index 6d14fbfe0..7c533b837 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt @@ -27,6 +27,11 @@ internal object DataStoreModule { name = USER_DATASTORE, ) + private const val PENDING_FEED_LIKE_DATASTORE = "PENDING_FEED_LIKE_DATASTORE" + private val Context.pendingFeedLikeDataStore: DataStore by preferencesDataStore( + name = PENDING_FEED_LIKE_DATASTORE, + ) + @Provides @Singleton @AccountDataStore @@ -47,4 +52,11 @@ internal object DataStoreModule { fun provideUserDataStore( @ApplicationContext context: Context, ): DataStore = context.userDataStore + + @Provides + @Singleton + @PendingFeedLikeDataStore + fun providePendingFeedLikeDataStore( + @ApplicationContext context: Context, + ): DataStore = context.pendingFeedLikeDataStore } From 88fcbd57c57df1594265834000ff67ac29e327cf Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:34:42 +0900 Subject: [PATCH 08/49] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PendingFeedLikesPreferences.kt`: 피드 ID(`feedId`)와 좋아요 여부(`isLiked`)를 저장하는 `PendingFeedLikesPreferences` 및 `PendingFeedLikePreferences` 데이터 클래스 정의 --- .../feed/model/PendingFeedLikesPreferences.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt new file mode 100644 index 000000000..ade4a6ac1 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt @@ -0,0 +1,14 @@ +package com.into.websoso.core.datastore.datasource.feed.model + +import kotlinx.serialization.Serializable + +@Serializable +internal data class PendingFeedLikesPreferences( + val likes: List, +) + +@Serializable +internal data class PendingFeedLikePreferences( + val feedId: Long, + val isLiked: Boolean, +) From d324628de6123ebdddc87b6b34f5c720fdb86428 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:34:50 +0900 Subject: [PATCH 09/49] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20DataStore=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultPendingFeedLikeStore`: DataStore를 사용하여 피드 ID별 좋아요 보류 상태(`isLiked`)를 관리하는 로직 구현 - `PendingFeedLikesPreferences` 모델을 JSON 문자열로 인코딩/디코딩하여 저장하는 직렬화 로직 추가 - 보류 중인 좋아요 업데이트(`updatePendingLike`) 및 상태 일치 시 삭제(`deletePendingLikeIfMatched`) 기능 구현 - `PendingFeedLikeStoreModule`: Hilt를 이용한 `PendingFeedLikeStore` 의존성 주입 설정 추가 --- .../feed/DefaultPendingFeedLikeStore.kt | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt new file mode 100644 index 000000000..0f06475a9 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -0,0 +1,114 @@ +package com.into.websoso.core.datastore.datasource.feed + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.into.websoso.core.common.dispatchers.Dispatcher +import com.into.websoso.core.common.dispatchers.WebsosoDispatchers +import com.into.websoso.core.datastore.datasource.feed.model.PendingFeedLikePreferences +import com.into.websoso.core.datastore.datasource.feed.model.PendingFeedLikesPreferences +import com.into.websoso.core.datastore.di.PendingFeedLikeDataStore +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +internal class DefaultPendingFeedLikeStore + @Inject + constructor( + @param:PendingFeedLikeDataStore private val pendingFeedLikeDataStore: DataStore, + @param:Dispatcher(WebsosoDispatchers.DEFAULT) private val dispatcher: CoroutineDispatcher, + ) : PendingFeedLikeStore { + override val pendingLikes: Flow> + get() = pendingFeedLikeDataStore.data + .map { preferences -> + withContext(dispatcher) { + decodePendingLikes(preferences[PENDING_FEED_LIKES_KEY]) + } + }.distinctUntilChanged() + + override suspend fun getPendingLikes(): Map = pendingLikes.first() + + override suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + pendingFeedLikeDataStore.edit { preferences -> + val pendingLikes: MutableMap = decodePendingLikes( + preferences[PENDING_FEED_LIKES_KEY], + ).toMutableMap() + pendingLikes.put(feedId, isLiked) + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + } + + override suspend fun deletePendingLikeIfMatched( + feedId: Long, + isLiked: Boolean, + ): Boolean { + var deleted = false + + pendingFeedLikeDataStore.edit { preferences -> + val pendingLikes: MutableMap = decodePendingLikes( + preferences[PENDING_FEED_LIKES_KEY], + ).toMutableMap() + if (pendingLikes[feedId] != isLiked) return@edit + + pendingLikes.remove(feedId) + if (pendingLikes.isEmpty()) { + preferences.remove(PENDING_FEED_LIKES_KEY) + } else { + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + deleted = true + } + + return deleted + } + + private fun decodePendingLikes(jsonString: String?): Map { + if (jsonString == null) return emptyMap() + + return runCatching { + Json + .decodeFromString(jsonString) + .likes + .associate { pendingLike -> pendingLike.feedId to pendingLike.isLiked } + }.getOrDefault(emptyMap()) + } + + private fun encodePendingLikes(pendingLikes: Map): String = + Json.encodeToString( + PendingFeedLikesPreferences( + likes = pendingLikes.entries.map { pendingLike -> + PendingFeedLikePreferences( + feedId = pendingLike.key, + isLiked = pendingLike.value, + ) + }, + ), + ) + + companion object { + private val PENDING_FEED_LIKES_KEY = stringPreferencesKey("PENDING_FEED_LIKES_KEY") + } + } + +@Module +@InstallIn(SingletonComponent::class) +internal interface PendingFeedLikeStoreModule { + @Binds + @Singleton + fun bindPendingFeedLikeStore(defaultPendingFeedLikeStore: DefaultPendingFeedLikeStore): PendingFeedLikeStore +} From 7f2ef9326dc608cf88305a2e23103e12143b4df7 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 17:34:53 +0900 Subject: [PATCH 10/49] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20DataStore=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultPendingFeedLikeStore`: DataStore를 사용하여 피드 ID별 좋아요 보류 상태(`isLiked`)를 관리하는 로직 구현 - `PendingFeedLikesPreferences` 모델을 JSON 문자열로 인코딩/디코딩하여 저장하는 직렬화 로직 추가 - 보류 중인 좋아요 업데이트(`updatePendingLike`) 및 상태 일치 시 삭제(`deletePendingLikeIfMatched`) 기능 구현 - `PendingFeedLikeStoreModule`: Hilt를 이용한 `PendingFeedLikeStore` 의존성 주입 설정 추가 --- core/datastore/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 3e1e3df38..537747926 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -12,9 +12,9 @@ android { dependencies { implementation(projects.core.common) implementation(projects.data.account) + implementation(projects.data.feed) implementation(projects.data.library) implementation(projects.data.user) - implementation(libs.datastore.preferences) implementation(libs.serialization.json) } From 47ffc9594a8b918cd26494c6bd4c7a407b70778c Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 18:04:05 +0900 Subject: [PATCH 11/49] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=B3=B4=EB=A5=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PendingFeedLikeStore`: 특정 피드 ID의 보류된 좋아요 상태를 삭제하는 `deletePendingLike` 인터페이스 메서드 추가 - `DefaultPendingFeedLikeStore`: `deletePendingLike` 구현을 통해 DataStore에서 특정 피드 ID의 데이터를 삭제하고, 전체 데이터가 비어있을 경우 키 자체를 제거하도록 로직 반영 --- .../feed/DefaultPendingFeedLikeStore.kt | 15 +++++++++++++++ .../data/feed/store/PendingFeedLikeStore.kt | 2 ++ 2 files changed, 17 insertions(+) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt index 0f06475a9..3036f2bf5 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -53,6 +53,21 @@ internal class DefaultPendingFeedLikeStore } } + override suspend fun deletePendingLike(feedId: Long) { + pendingFeedLikeDataStore.edit { preferences -> + val pendingLikes: MutableMap = decodePendingLikes( + preferences[PENDING_FEED_LIKES_KEY], + ).toMutableMap() + + pendingLikes.remove(feedId) + if (pendingLikes.isEmpty()) { + preferences.remove(PENDING_FEED_LIKES_KEY) + } else { + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + } + } + override suspend fun deletePendingLikeIfMatched( feedId: Long, isLiked: Boolean, diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt index 092f154c9..0e0a026e2 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -12,6 +12,8 @@ interface PendingFeedLikeStore { isLiked: Boolean, ) + suspend fun deletePendingLike(feedId: Long) + suspend fun deletePendingLikeIfMatched( feedId: Long, isLiked: Boolean, From 65b4e634e75b02a5fc92945dfe34b12e52662707 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 18:19:35 +0900 Subject: [PATCH 12/49] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository`에 `PendingFeedLikeStore`를 주입하여 앱 종료 후에도 좋아요 상태를 유지할 수 있도록 개선 - 좋아요 상태가 변경될 때 호출되는 `updatePendingLike` 및 `deletePendingLike` 메서드 구현 - 변경된 좋아요 상태가 원본 상태와 다를 경우 로컬에 저장하고, 원본과 일치할 경우 로컬 데이터를 삭제하도록 로직 수정 --- .../feed/repository/UpdatedFeedRepository.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 128c48cee..454e0f445 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -16,6 +16,7 @@ import com.into.websoso.data.feed.model.FeedDetailEntity import com.into.websoso.data.feed.model.FeedEntity import com.into.websoso.data.feed.model.FeedsEntity import com.into.websoso.data.feed.repository.model.CachedFeedLikeState +import com.into.websoso.data.feed.store.PendingFeedLikeStore import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -34,6 +35,7 @@ class UpdatedFeedRepository @Inject constructor( private val feedApi: FeedApi, + private val pendingFeedLikeStore: PendingFeedLikeStore, private val multiPartMapper: MultiPartMapper, private val imageDownloader: ImageDownloader, private val imageCompressor: ImageCompressor, @@ -294,8 +296,33 @@ class UpdatedFeedRepository originalLikeStates.putIfAbsent(feedId, original) if (originalLikeStates[feedId] == new) { pendingLikeStates.remove(feedId) + deletePendingLike(feedId) } else { pendingLikeStates[feedId] = new + updatePendingLike(feedId, new) + } + } + + private fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + scope.launch { + runCatching { + pendingFeedLikeStore.updatePendingLike(feedId, isLiked) + }.onFailure { + Log.e("UpdatedFeedRepository", "Failed to save pending feed like $feedId", it) + } + } + } + + private fun deletePendingLike(feedId: Long) { + scope.launch { + runCatching { + pendingFeedLikeStore.deletePendingLike(feedId) + }.onFailure { + Log.e("UpdatedFeedRepository", "Failed to delete pending feed like $feedId", it) + } } } From e90acebcac21145c13e60fcceb719b4dcf9b1841 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 18:30:35 +0900 Subject: [PATCH 13/49] =?UTF-8?q?fix:=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=EB=90=9C=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EC=9D=98=20DataStore=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository`: 서버와 좋아요 상태 동기화 성공 시, `pendingFeedLikeStore`에 저장된 임시 데이터를 삭제하는 `deleteSyncedPendingLike` 로직 구현 및 적용 --- .../data/feed/repository/UpdatedFeedRepository.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 454e0f445..91ecc8725 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -340,6 +340,8 @@ class UpdatedFeedRepository syncMap.forEach { (id, isLiked) -> runCatching { if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) + }.onSuccess { + deleteSyncedPendingLike(id, isLiked) }.onFailure { Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) } @@ -347,6 +349,17 @@ class UpdatedFeedRepository } } + private suspend fun deleteSyncedPendingLike( + feedId: Long, + isLiked: Boolean, + ) { + runCatching { + pendingFeedLikeStore.deletePendingLikeIfMatched(feedId, isLiked) + }.onFailure { + Log.e("UpdatedFeedRepository", "Failed to delete synced feed like $feedId", it) + } + } + // ============================================================================================ // Feed Actions (Remove, Report) // ============================================================================================ From 808fd0a5ce86333d6855a3dea534b9ffa4223bdd Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 18:43:49 +0900 Subject: [PATCH 14/49] =?UTF-8?q?fix:=20UpdatedFeedRepository=20=EB=82=B4?= =?UTF-8?q?=20=ED=8E=9C=EB=94=A9=EB=90=9C=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `observePendingLikes` 메서드를 통해 `pendingFeedLikeStore`의 변경 사항을 구독하고, `pendingLikeStates`를 실시간으로 업데이트하도록 수정 - 펜딩된 좋아요 상태를 모든 피드 리스트(`_sosoAllFeeds`, `_sosoRecommendedFeeds`, `_myFeeds`)에 즉각 반영하는 `applyPendingLikeStatesToCachedFeeds` 로직 구현 --- .../feed/repository/UpdatedFeedRepository.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 91ecc8725..d555644e4 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -59,6 +59,20 @@ class UpdatedFeedRepository private val originalLikeStates = ConcurrentHashMap() private val feedDetailStates = ConcurrentHashMap() + init { + observePendingLikes() + } + + private fun observePendingLikes() { + scope.launch { + pendingFeedLikeStore.pendingLikes.collect { pendingLikes -> + pendingLikeStates.clear() + pendingLikeStates.putAll(pendingLikes) + applyPendingLikeStatesToCachedFeeds() + } + } + } + // ============================================================================================ // Feed Creation & Modification // ============================================================================================ @@ -231,6 +245,16 @@ class UpdatedFeedRepository return feed } + private fun applyPendingLikeStatesToCachedFeeds() { + val updateAction: (List) -> List = { list -> + list.map { feed -> applyPendingLikeState(feed) } + } + + _sosoAllFeeds.update(updateAction) + _sosoRecommendedFeeds.update(updateAction) + _myFeeds.update(updateAction) + } + // ============================================================================================ // Interaction (Like, Sync) // ============================================================================================ From 9bd112252c60e13b5fd6794c10b95b96a057953d Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 19:09:24 +0900 Subject: [PATCH 15/49] =?UTF-8?q?fix:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=B4=EC=99=84=20=EB=B0=8F=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC/=EB=A1=9C=EC=BB=AC=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `syncPendingLikes`: 동기화 시 메모리(`pendingLikeStates`)뿐만 아니라 로컬 저장소(`pendingFeedLikeStore`)의 데이터도 함께 처리하도록 수정 - `deleteSyncedPendingLike`: 동기화 완료 후 데이터를 삭제하는 로직을 메모리(`FromMemory`)와 로컬 저장소(`FromStore`)용으로 분리하여 관리 최적화 - 동기화 시작 전 빈 맵 확인 로직을 코루틴 내부로 이동하여 데이터 유실 방지 및 안정성 향상 --- .../feed/repository/UpdatedFeedRepository.kt | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index d555644e4..430b2dbe0 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -354,18 +354,16 @@ class UpdatedFeedRepository * 서버에 아직 반영되지 않은 좋아요 상태들을 동기화합니다. */ fun syncPendingLikes() { - if (pendingLikeStates.isEmpty()) return - - val syncMap = pendingLikeStates.toMap() - pendingLikeStates.clear() - originalLikeStates.clear() - scope.launch { + val syncMap = pendingFeedLikeStore.getPendingLikes() + pendingLikeStates.toMap() + if (syncMap.isEmpty()) return@launch + syncMap.forEach { (id, isLiked) -> runCatching { if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) }.onSuccess { - deleteSyncedPendingLike(id, isLiked) + deleteSyncedPendingLikeFromStore(id, isLiked) + deleteSyncedPendingLikeFromMemory(id, isLiked) }.onFailure { Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) } @@ -373,7 +371,17 @@ class UpdatedFeedRepository } } - private suspend fun deleteSyncedPendingLike( + private fun deleteSyncedPendingLikeFromMemory( + feedId: Long, + isLiked: Boolean, + ) { + pendingLikeStates.remove(feedId, isLiked) + if (!pendingLikeStates.containsKey(feedId)) { + originalLikeStates.remove(feedId) + } + } + + private suspend fun deleteSyncedPendingLikeFromStore( feedId: Long, isLiked: Boolean, ) { From 40a43ea056feef7066a6990f558ac34cf600ae7b Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 22 Jun 2026 19:30:40 +0900 Subject: [PATCH 16/49] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=98=20=EC=88=9C=EC=B0=A8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EC=9E=A5=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository`: `pendingFeedLikeStore`에 대한 쓰기 작업의 순서 보장을 위해 `enqueuePendingLikeStoreWrite` 메서드 구현 및 `Job.join()`을 이용한 직렬화 로직 적용 - 초기화 시 `Flow.collect`를 통한 관찰 방식 대신 `getPendingLikes`를 호출하여 일회성으로 데이터를 복구하도록 `restorePendingLikes` 로직 수정 - `savePendingLike` 및 `deletePendingLike` 호출 시 새로운 쓰기 큐 로직을 적용하여 데이터 정합성 강화 --- .../feed/repository/UpdatedFeedRepository.kt | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 430b2dbe0..8484b413c 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -19,6 +19,7 @@ import com.into.websoso.data.feed.repository.model.CachedFeedLikeState import com.into.websoso.data.feed.store.PendingFeedLikeStore import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -58,18 +59,19 @@ class UpdatedFeedRepository private val pendingLikeStates = ConcurrentHashMap() private val originalLikeStates = ConcurrentHashMap() private val feedDetailStates = ConcurrentHashMap() + private val pendingLikeStoreWriteLock = Any() + private var pendingLikeStoreWriteJob: Job? = null init { - observePendingLikes() + restorePendingLikes() } - private fun observePendingLikes() { + private fun restorePendingLikes() { scope.launch { - pendingFeedLikeStore.pendingLikes.collect { pendingLikes -> - pendingLikeStates.clear() - pendingLikeStates.putAll(pendingLikes) - applyPendingLikeStatesToCachedFeeds() - } + val pendingLikes = pendingFeedLikeStore.getPendingLikes() + pendingLikeStates.clear() + pendingLikeStates.putAll(pendingLikes) + applyPendingLikeStatesToCachedFeeds() } } @@ -331,21 +333,35 @@ class UpdatedFeedRepository feedId: Long, isLiked: Boolean, ) { - scope.launch { - runCatching { - pendingFeedLikeStore.updatePendingLike(feedId, isLiked) - }.onFailure { - Log.e("UpdatedFeedRepository", "Failed to save pending feed like $feedId", it) - } + enqueuePendingLikeStoreWrite(feedId, "save") { + pendingFeedLikeStore.updatePendingLike(feedId, isLiked) } } private fun deletePendingLike(feedId: Long) { - scope.launch { - runCatching { - pendingFeedLikeStore.deletePendingLike(feedId) - }.onFailure { - Log.e("UpdatedFeedRepository", "Failed to delete pending feed like $feedId", it) + enqueuePendingLikeStoreWrite(feedId, "delete") { + pendingFeedLikeStore.deletePendingLike(feedId) + } + } + + private fun enqueuePendingLikeStoreWrite( + feedId: Long, + actionName: String, + action: suspend () -> Unit, + ) { + synchronized(pendingLikeStoreWriteLock) { + val previousJob = pendingLikeStoreWriteJob + pendingLikeStoreWriteJob = scope.launch { + previousJob?.join() + runCatching { + action() + }.onFailure { + Log.e( + "UpdatedFeedRepository", + "Failed to $actionName pending feed like $feedId", + it, + ) + } } } } From 6ae731a57bfef31ef6ccc6a088c2095e96aa07b2 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 23 Jun 2026 16:51:40 +0900 Subject: [PATCH 17/49] =?UTF-8?q?fix:=20`UpdatedFeedRepository`=20?= =?UTF-8?q?=EB=82=B4=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=EC=9D=98=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=A0=9C=EC=96=B4=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `pendingLikeStoreWriteLock` (Any) 및 `Job`을 이용한 순차 처리 로직을 `Mutex`와 `withLock`을 사용하는 방식으로 변경하여 코루틴 환경에서의 안정성 및 가독성 향상 - `enqueuePendingLikeStoreWrite` 메서드를 `writePendingLikeStore`로 네이밍 변경 및 로직 최적화 --- .../feed/repository/UpdatedFeedRepository.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 8484b413c..640cf8df9 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -27,6 +27,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -59,8 +61,7 @@ class UpdatedFeedRepository private val pendingLikeStates = ConcurrentHashMap() private val originalLikeStates = ConcurrentHashMap() private val feedDetailStates = ConcurrentHashMap() - private val pendingLikeStoreWriteLock = Any() - private var pendingLikeStoreWriteJob: Job? = null + private val pendingLikeStoreWriteMutex = Mutex() init { restorePendingLikes() @@ -333,26 +334,24 @@ class UpdatedFeedRepository feedId: Long, isLiked: Boolean, ) { - enqueuePendingLikeStoreWrite(feedId, "save") { + writePendingLikeStore(feedId, "save") { pendingFeedLikeStore.updatePendingLike(feedId, isLiked) } } private fun deletePendingLike(feedId: Long) { - enqueuePendingLikeStoreWrite(feedId, "delete") { + writePendingLikeStore(feedId, "delete") { pendingFeedLikeStore.deletePendingLike(feedId) } } - private fun enqueuePendingLikeStoreWrite( + private fun writePendingLikeStore( feedId: Long, actionName: String, action: suspend () -> Unit, ) { - synchronized(pendingLikeStoreWriteLock) { - val previousJob = pendingLikeStoreWriteJob - pendingLikeStoreWriteJob = scope.launch { - previousJob?.join() + scope.launch { + pendingLikeStoreWriteMutex.withLock { runCatching { action() }.onFailure { From d77e967a9662b8581f48eb242a3a39d787c4c78b Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 24 Jun 2026 16:12:41 +0900 Subject: [PATCH 18/49] =?UTF-8?q?refactor:=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미 나간 sync 요청이 성공했을 때 최신 로컬 상태와 다시 맞춰보는 보정 로직 - `UpdatedFeedRepository.kt`: 좋아요 동기화 성공 시 현재 상태를 재확인하여 처리하는 `handleSyncedPendingLike` 메서드 추가 - 동기화 중 사용자가 좋아요 상태를 변경한 경우, 해당 변경 사항이 유실되지 않도록 `pendingLikeStates`를 유지하고 업데이트하는 로직 적용 - 피드 캐시 또는 상세 상태에서 현재 좋아요 상태를 조회하는 `findCurrentLikeState` 메서드 추가로 데이터 정합성 강화 --- .../feed/repository/UpdatedFeedRepository.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 640cf8df9..576e47aa1 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -377,8 +377,7 @@ class UpdatedFeedRepository runCatching { if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) }.onSuccess { - deleteSyncedPendingLikeFromStore(id, isLiked) - deleteSyncedPendingLikeFromMemory(id, isLiked) + handleSyncedPendingLike(id, isLiked) }.onFailure { Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) } @@ -386,6 +385,24 @@ class UpdatedFeedRepository } } + private suspend fun handleSyncedPendingLike( + feedId: Long, + syncedIsLiked: Boolean, + ) { + val currentIsLiked = findCurrentLikeState(feedId) + if (currentIsLiked != null && currentIsLiked != syncedIsLiked) { + originalLikeStates[feedId] = syncedIsLiked + pendingLikeStates[feedId] = currentIsLiked + updatePendingLike(feedId, currentIsLiked) + return + } + + deleteSyncedPendingLikeFromStore(feedId, syncedIsLiked) + deleteSyncedPendingLikeFromMemory(feedId, syncedIsLiked) + } + + private fun findCurrentLikeState(feedId: Long): Boolean? = findCachedFeed(feedId)?.isLiked ?: feedDetailStates[feedId]?.isLiked + private fun deleteSyncedPendingLikeFromMemory( feedId: Long, isLiked: Boolean, From 6d8fa9dcebfa54746c43feeead720c31a8cacbd7 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 24 Jun 2026 16:23:01 +0900 Subject: [PATCH 19/49] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EC=9D=98=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EA=B0=92=20=EC=BA=90=EC=8B=B1=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앱 재시작 후 pending 병합 시, 서버 원본 isLiked를 originalLikeStates에 기록해서 pending 제거 판단 기준을 복구 - `UpdatedFeedRepository` 내 `applyPendingLikeState` 및 `applyPendingLikeStateToDetail` 함수에서 좋아요 상태 변경 전 원본 값을 `originalLikeStates`에 저장하도록 수정하여 데이터 일관성 보장 기반 마련 --- .../into/websoso/data/feed/repository/UpdatedFeedRepository.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 576e47aa1..e9aeeeb35 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -237,6 +237,7 @@ class UpdatedFeedRepository */ private fun applyPendingLikeState(feed: FeedEntity): FeedEntity { val localIsLiked = pendingLikeStates[feed.id] ?: return feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 @@ -502,6 +503,7 @@ class UpdatedFeedRepository private fun applyPendingLikeStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { val localIsLiked = pendingLikeStates[feed.id] ?: return feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 From faaa6e74eaafb5849c0281926cce9f43a005a034 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 24 Jun 2026 16:28:18 +0900 Subject: [PATCH 20/49] =?UTF-8?q?refactor:=20DefaultPendingFeedLikeStore?= =?UTF-8?q?=20=EB=82=B4=20MutableMap=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultPendingFeedLikeStore.kt`: `pendingLikes.put(feedId, isLiked)` 호출 방식을 코틀린 관용구인 인덱스 연산자(`[]`) 사용으로 변경 --- .../datastore/datasource/feed/DefaultPendingFeedLikeStore.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt index 3036f2bf5..07af7e3d9 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -48,7 +48,7 @@ internal class DefaultPendingFeedLikeStore val pendingLikes: MutableMap = decodePendingLikes( preferences[PENDING_FEED_LIKES_KEY], ).toMutableMap() - pendingLikes.put(feedId, isLiked) + pendingLikes[feedId] = isLiked preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) } } From 407e53465b7ebb73e03b9babd6e7c8328895cca0 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 24 Jun 2026 16:38:59 +0900 Subject: [PATCH 21/49] =?UTF-8?q?docs:=20PendingFeedLikeStore=20=EB=82=B4?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=EC=97=AC=EB=B6=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PendingFeedLikeStore.kt`: `deletePendingLikeIfMatched` 메서드에 파라미터 설명 및 반환값 정의를 포함한 KDoc 주석 추가 --- .../com/into/websoso/data/feed/store/PendingFeedLikeStore.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt index 0e0a026e2..7bc64175a 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -14,6 +14,11 @@ interface PendingFeedLikeStore { suspend fun deletePendingLike(feedId: Long) + /** + * 저장된 좋아요 상태가 [isLiked]와 일치하는 경우에만 삭제합니다. + * + * `@return` 매칭되어 삭제된 경우 true, 일치하지 않아 삭제하지 않은 경우 false + * */ suspend fun deletePendingLikeIfMatched( feedId: Long, isLiked: Boolean, From 33441ce1163099ec197bb84783efc47bc28b6221 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 29 Sep 2026 00:33:50 +0900 Subject: [PATCH 22/49] =?UTF-8?q?build:=20data:feed=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=20=EC=9C=A0=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=ED=99=98=EA=B2=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit android.util.Log 등 안드로이드 API를 호출하는 UpdatedFeedRepository를 JVM 유닛 테스트에서 검증할 수 있도록 isReturnDefaultValues와 junit 의존성을 추가한다. --- data/feed/build.gradle.kts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/data/feed/build.gradle.kts b/data/feed/build.gradle.kts index 87a5b0557..04442a813 100644 --- a/data/feed/build.gradle.kts +++ b/data/feed/build.gradle.kts @@ -6,6 +6,10 @@ plugins { android { setNamespace("data.feed") + + testOptions { + unitTests.isReturnDefaultValues = true + } } dependencies { @@ -17,4 +21,6 @@ dependencies { // TODO: 멀티 파트 빌드 오류를 해결하기 위한 임시 조치 -> 추후 네트워크 레이어로 마이그레이션 implementation(libs.okhttp) + + testImplementation(libs.junit) } From 035a452e250e22801a4d7cc14e2b66ba788c1a49 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 29 Sep 2026 00:33:55 +0900 Subject: [PATCH 23/49] =?UTF-8?q?test:=20UpdatedFeedRepository=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toggleLikeLocal/syncPendingLikes/handleSyncedPendingLike/restorePendingLikes를 실제 UpdatedFeedRepository로 검증한다. FeedApi와 PendingFeedLikeStore를 인메모리 페이크로 대체하고 StandardTestDispatcher로 실행 순서를 제어해, 낙관적 업데이트, 전송 전 선택 변경, 전송 실패/부분 실패, 전송 중 재입력, 동기화 호출 겹침, 앱 재시작 시 pending 복원 시나리오를 다룬다. --- .../repository/UpdatedFeedRepositoryTest.kt | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt new file mode 100644 index 000000000..ba351d9a1 --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt @@ -0,0 +1,515 @@ +package com.into.websoso.data.feed.repository + +import android.content.ContextWrapper +import com.into.websoso.core.common.image.ImageCompressor +import com.into.websoso.core.network.common.ImageDownloader +import com.into.websoso.core.network.datasource.feed.FeedApi +import com.into.websoso.core.network.datasource.feed.mapper.MultiPartMapper +import com.into.websoso.core.network.datasource.feed.model.request.CommentRequestDto +import com.into.websoso.core.network.datasource.feed.model.response.CommentsResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto +import com.into.websoso.data.feed.model.FeedEntity +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okhttp3.MultipartBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UpdatedFeedRepositoryTest { + + @Test + fun `좋아요를 누르고 동기화하면 서버 요청이 성공하고 대기 기록이 정리된다`() = runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + + val toggled = repository.sosoAllFeeds.value.feed(1L) + assertTrue(toggled.isLiked) + assertEquals(4, toggled.likeCount) + assertEquals(mapOf(1L to true), pendingStore.currentPendingLikes()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(1L), feedApi.postLikesCalls) + assertTrue(feedApi.deleteLikesCalls.isEmpty()) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + val synced = repository.sosoAllFeeds.value.feed(1L) + assertTrue(synced.isLiked) + assertEquals(4, synced.likeCount) + + // 메모리 pending이 비었는지 간접 확인: 다시 동기화해도 추가 요청이 없어야 한다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), feedApi.postLikesCalls) + } + + @Test + fun `좋아요를 취소하고 동기화하면 서버 요청이 성공하고 대기 기록이 정리된다`() = runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 11L, isLiked = true, likeCount = 4)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(11L) + advanceUntilIdle() + + val toggled = repository.sosoAllFeeds.value.feed(11L) + assertFalse(toggled.isLiked) + assertEquals(3, toggled.likeCount) + assertEquals(mapOf(11L to false), pendingStore.currentPendingLikes()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(11L), feedApi.deleteLikesCalls) + assertTrue(feedApi.postLikesCalls.isEmpty()) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `전송 전에 좋아요를 다시 취소하면 서버 요청 없이 원래 상태로 남는다`() = runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 2L, isLiked = false, likeCount = 5)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + + repository.toggleLikeLocal(2L) + advanceUntilIdle() + assertEquals(mapOf(2L to true), pendingStore.currentPendingLikes()) + + repository.toggleLikeLocal(2L) + advanceUntilIdle() + + val reverted = repository.sosoAllFeeds.value.feed(2L) + assertFalse(reverted.isLiked) + assertEquals(5, reverted.likeCount) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertTrue(feedApi.postLikesCalls.isEmpty()) + assertTrue(feedApi.deleteLikesCalls.isEmpty()) + } + + @Test + fun `동기화 요청이 실패하면 대기 기록이 메모리와 저장소에 그대로 남는다`() = runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 3L, isLiked = false, likeCount = 2)) + failingLikeIds = setOf(3L) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(3L) + advanceUntilIdle() + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(3L), feedApi.postLikesCalls) + assertTrue(pendingStore.deleteIfMatchedCalls.isEmpty()) + assertEquals(mapOf(3L to true), pendingStore.currentPendingLikes()) + + val afterFailure = repository.sosoAllFeeds.value.feed(3L) + assertTrue(afterFailure.isLiked) + assertEquals(3, afterFailure.likeCount) + + // 메모리에도 남아 있는지 간접 확인: 실패 원인을 없애고 다시 동기화하면 같은 요청이 다시 나가고, 이번엔 정리된다. + feedApi.failingLikeIds = emptySet() + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(3L, 3L), feedApi.postLikesCalls) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `두 건을 동기화할 때 하나만 실패하면 성공한 건만 정리되고 실패한 건만 남는다`() = runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf( + feedResponse(feedId = 4L, isLiked = false, likeCount = 1), + feedResponse(feedId = 5L, isLiked = false, likeCount = 1), + ) + failingLikeIds = setOf(5L) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(4L) + repository.toggleLikeLocal(5L) + advanceUntilIdle() + assertEquals(mapOf(4L to true, 5L to true), pendingStore.currentPendingLikes()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(4L, 5L), feedApi.postLikesCalls) + assertEquals(mapOf(5L to true), pendingStore.currentPendingLikes()) + + val feed4 = repository.sosoAllFeeds.value.feed(4L) + val feed5 = repository.sosoAllFeeds.value.feed(5L) + assertTrue(feed4.isLiked) + assertEquals(2, feed4.likeCount) + assertTrue(feed5.isLiked) + assertEquals(2, feed5.likeCount) + + // 메모리 확인: 다시 동기화하면 실패했던 5만 다시 시도된다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(4L, 5L, 5L), feedApi.postLikesCalls) + } + + @Test + fun `전송 중에 취소하면 화면의 취소가 유지되고 취소가 다시 대기열에 쌓인다`() = runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 6L, isLiked = false, likeCount = 0)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(6L) + advanceUntilIdle() + + val postGate = feedApi.holdPostLikes(6L) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(6L), feedApi.postLikesCalls) + + repository.toggleLikeLocal(6L) // 좋아요 응답을 받기 전에 취소 + advanceUntilIdle() + + val cancelled = repository.sosoAllFeeds.value.feed(6L) + assertFalse(cancelled.isLiked) + assertEquals(0, cancelled.likeCount) + + postGate.complete(Unit) // 뒤늦게 좋아요 요청이 성공 응답을 받음 + advanceUntilIdle() + + val afterPostSucceeds = repository.sosoAllFeeds.value.feed(6L) + assertFalse(afterPostSucceeds.isLiked) // 화면의 취소가 유지된다 + assertEquals(0, afterPostSucceeds.likeCount) + assertEquals(mapOf(6L to false), pendingStore.currentPendingLikes()) // 취소가 다시 대기열에 남는다 + + repository.syncPendingLikes() // 명시적으로 다시 동기화 + advanceUntilIdle() + + assertEquals(listOf(6L), feedApi.deleteLikesCalls) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(6L), feedApi.deleteLikesCalls) // 메모리도 비어 추가 요청이 없다 + } + + @Test + fun `동기화 호출이 겹쳐서 요청이 중복돼도 최신 선택은 손실 없이 반영된다`() = runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 7L, isLiked = false, likeCount = 0)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(7L) + advanceUntilIdle() + + val firstPostGate = feedApi.holdPostLikes(7L) + repository.syncPendingLikes() // 첫 번째 동기화: 요청은 서버로 보냈지만 응답은 보류된다 + advanceUntilIdle() + assertEquals(listOf(7L), feedApi.postLikesCalls) // 서버가 받은 순서 1번째 + + repository.syncPendingLikes() // 두 번째 동기화(겹침): pending이 아직 남아 있어 다시 요청한다 + advanceUntilIdle() + // 두 번째 요청은 보류 없이 즉시 응답한다 -> 응답은 두 번째 요청이 먼저 도착한다 + assertEquals(listOf(7L, 7L), feedApi.postLikesCalls) // 서버가 받은 순서: 1번, 2번 + assertEquals(listOf(7L), feedApi.postLikesCompleted) // 앱이 응답을 받은 순서: 2번이 먼저 끝남 + assertTrue(pendingStore.currentPendingLikes().isEmpty()) // 두 번째 요청 처리로 pending이 정리됨 + + firstPostGate.complete(Unit) // 첫 번째 요청의 응답이 뒤늦게 도착 + advanceUntilIdle() + assertEquals(listOf(7L, 7L), feedApi.postLikesCompleted) + + val finalFeed = repository.sosoAllFeeds.value.feed(7L) + assertTrue(finalFeed.isLiked) // 화면의 마지막 선택: 손실 없이 좋아요 상태 유지 + assertEquals(1, finalFeed.likeCount) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) // 뒤늦은 응답이 지울 대상이 없어도 안전하게 무시된다 + + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(7L, 7L), feedApi.postLikesCalls) // 메모리도 비어 추가 요청이 없다 + } + + @Test + fun `복원이 끝난 뒤 목록을 조회하면 복원된 pending이 즉시 반영된다`() = runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + } + val pendingStore = FakePendingFeedLikeStore(initial = mapOf(1L to true)) + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() // init { restorePendingLikes() } 완료를 기다린다 + + val result = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + + val restoredFeed = result.feeds.feed(1L) + assertTrue(restoredFeed.isLiked) + assertEquals(4, restoredFeed.likeCount) + + val cachedFeed = repository.sosoAllFeeds.value.feed(1L) + assertTrue(cachedFeed.isLiked) + assertEquals(4, cachedFeed.likeCount) + + // 복원된 기록이 이후 명시적 동기화에서 실제로 쓰이는지 확인한다. + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(1L), feedApi.postLikesCalls) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `복원이 끝나기 전에 목록을 조회해도 복원이 끝나면 목록에 뒤늦게 반영된다`() = runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + } + val pendingStore = FakePendingFeedLikeStore(initial = mapOf(1L to true)) + val repository = createRepository(feedApi, pendingStore) + + // advanceUntilIdle()을 호출하지 않아 init { restorePendingLikes() }가 아직 실행되지 않은 상태에서 조회한다. + val result = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + + val beforeRestore = result.feeds.feed(1L) + assertFalse(beforeRestore.isLiked) + assertEquals(3, beforeRestore.likeCount) + + advanceUntilIdle() // restorePendingLikes()가 뒤늦게 끝난다 + + val afterRestore = repository.sosoAllFeeds.value.feed(1L) + assertTrue(afterRestore.isLiked) + assertEquals(4, afterRestore.likeCount) + } + + private fun TestScope.createRepository( + feedApi: FeedApi, + pendingFeedLikeStore: PendingFeedLikeStore, + ): UpdatedFeedRepository = + UpdatedFeedRepository( + feedApi = feedApi, + pendingFeedLikeStore = pendingFeedLikeStore, + multiPartMapper = MultiPartMapper(ContextWrapper(null)), + imageDownloader = ImageDownloader(ContextWrapper(null)), + imageCompressor = ImageCompressor(ContextWrapper(null)), + dispatcher = StandardTestDispatcher(testScheduler), + ) + + private fun List.feed(id: Long): FeedEntity = first { it.id == id } + + private fun feedsResponseOf(vararg feeds: FeedResponseDto): FeedsResponseDto = + FeedsResponseDto(isLoadable = false, feeds = feeds.toList()) + + private fun feedResponse( + feedId: Long, + isLiked: Boolean, + likeCount: Int, + ): FeedResponseDto = FeedResponseDto( + userId = 1L, + nickname = "닉네임", + avatarImage = "avatar.png", + feedId = feedId, + createdDate = "2026-01-01", + feedContent = "content-$feedId", + likeCount = likeCount, + isLiked = isLiked, + commentCount = 0, + novelId = 100L, + title = "novel-title", + novelRating = 4.5f, + novelRatingCount = 10, + isSpoiler = false, + isModified = false, + isMyFeed = false, + isPublic = true, + thumbnailUrl = null, + imageCount = 0, + genreName = "로맨스", + userNovelRating = null, + feedWriterNovelRating = null, + ) +} + +private class FakeFeedApi : FeedApi { + var feedsResponse: FeedsResponseDto = FeedsResponseDto(isLoadable = false, feeds = emptyList()) + val feedDetailResponses = mutableMapOf() + + /** 요청 시작 순서. 서버가 요청을 받은 순서를 나타낸다. */ + val postLikesCalls = mutableListOf() + val deleteLikesCalls = mutableListOf() + + /** 응답 도착 순서. 앱이 응답을 받은 순서를 나타낸다. */ + val postLikesCompleted = mutableListOf() + val deleteLikesCompleted = mutableListOf() + + var failingLikeIds: Set = emptySet() + + private data class Gate(val method: String, val feedId: Long) + + private val gates = mutableMapOf>() + + /** 다음 postLikes(feedId) 호출을 [CompletableDeferred]가 완료될 때까지 보류시킨다. */ + fun holdPostLikes(feedId: Long): CompletableDeferred = hold("POST", feedId) + + /** 다음 deleteLikes(feedId) 호출을 [CompletableDeferred]가 완료될 때까지 보류시킨다. */ + fun holdDeleteLikes(feedId: Long): CompletableDeferred = hold("DELETE", feedId) + + private fun hold( + method: String, + feedId: Long, + ): CompletableDeferred { + val deferred = CompletableDeferred() + gates[Gate(method, feedId)] = deferred + return deferred + } + + override suspend fun getFeeds( + feedsOption: String, + lastFeedId: Long, + size: Int, + ): FeedsResponseDto = feedsResponse + + override suspend fun getFeed(feedId: Long): FeedDetailResponseDto = + feedDetailResponses[feedId] ?: error("등록되지 않은 feedId: $feedId") + + override suspend fun postFeed( + feedRequestDto: MultipartBody.Part, + images: List?, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun putFeed( + feedId: Long, + feedRequestDto: MultipartBody.Part, + images: List?, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun deleteFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postLikes(feedId: Long) { + postLikesCalls += feedId + gates.remove(Gate("POST", feedId))?.await() + postLikesCompleted += feedId + if (feedId in failingLikeIds) throw RuntimeException("postLikes 실패: $feedId") + } + + override suspend fun deleteLikes(feedId: Long) { + deleteLikesCalls += feedId + gates.remove(Gate("DELETE", feedId))?.await() + deleteLikesCompleted += feedId + if (feedId in failingLikeIds) throw RuntimeException("deleteLikes 실패: $feedId") + } + + override suspend fun postSpoilerFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postImpertinenceFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun getComments(feedId: Long): CommentsResponseDto = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postComment( + feedId: Long, + commentRequestDto: CommentRequestDto, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun putComment( + feedId: Long, + commentId: Long, + commentRequestDto: CommentRequestDto, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun deleteComment( + feedId: Long, + commentId: Long, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postSpoilerComment( + feedId: Long, + commentId: Long, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postImpertinenceComment( + feedId: Long, + commentId: Long, + ): Unit = error("좋아요 테스트에서 사용하지 않음") +} + +private class FakePendingFeedLikeStore( + initial: Map = emptyMap(), +) : PendingFeedLikeStore { + private val state = MutableStateFlow(initial) + + override val pendingLikes: Flow> = state.asStateFlow() + + val updateCalls = mutableListOf>() + val deleteCalls = mutableListOf() + val deleteIfMatchedCalls = mutableListOf>() + + fun currentPendingLikes(): Map = state.value + + override suspend fun getPendingLikes(): Map = state.value + + override suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + updateCalls += feedId to isLiked + state.update { it + (feedId to isLiked) } + } + + override suspend fun deletePendingLike(feedId: Long) { + deleteCalls += feedId + state.update { it - feedId } + } + + override suspend fun deletePendingLikeIfMatched( + feedId: Long, + isLiked: Boolean, + ): Boolean { + deleteIfMatchedCalls += feedId to isLiked + if (state.value[feedId] != isLiked) return false + state.update { it - feedId } + return true + } +} From 55913dfe6beaec3b3a36e9a5aebc3b2cae508a90 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 29 Sep 2026 02:23:19 +0900 Subject: [PATCH 24/49] =?UTF-8?q?test:=20=ED=94=BC=EB=93=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20Fak?= =?UTF-8?q?e=20API=20=EA=B5=AC=EC=A1=B0=20=EA=B3=A0=EB=8F=84=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EC=A7=80=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepositoryTest.kt`: - `FakeFeedApi` 내에 `LikeOutcome` 및 `PlannedLikeRequest`를 도입하여 서버 반영 전 실패, 반영 후 타임아웃 등 요청 처리 단계별 시뮬레이션 기능 구현 - 기존 게이트 방식(`holdPostLikes` 등)을 `processingGate`와 `responseGate`로 분리하여 서버 처리 순서와 앱 응답 순서를 독립적으로 제어하도록 고도화 - 서버의 실제 데이터 상태와 로컬 UI 엔티티의 좋아요 상태 및 개수를 비교 검증하는 `assertServerMatches` 헬퍼 함수 추가 - 서버 반영 후 타임아웃 발생 및 중복 POST/취소 요청이 얽히는 상황에 대한 에지 케이스 테스트 2종 추가 - 기존 테스트 케이스들을 신규 Fake API 구조에 맞춰 리팩터링 및 일부 테스트 명칭 구체화 --- .../repository/UpdatedFeedRepositoryTest.kt | 259 ++++++++++++++++-- 1 file changed, 230 insertions(+), 29 deletions(-) diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt index ba351d9a1..023069d8e 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt @@ -27,6 +27,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.net.SocketTimeoutException @OptIn(ExperimentalCoroutinesApi::class) class UpdatedFeedRepositoryTest { @@ -59,6 +60,7 @@ class UpdatedFeedRepositoryTest { val synced = repository.sosoAllFeeds.value.feed(1L) assertTrue(synced.isLiked) assertEquals(4, synced.likeCount) + assertServerMatches(feedApi, synced) // 메모리 pending이 비었는지 간접 확인: 다시 동기화해도 추가 요청이 없어야 한다. repository.syncPendingLikes() @@ -90,6 +92,7 @@ class UpdatedFeedRepositoryTest { assertEquals(listOf(11L), feedApi.deleteLikesCalls) assertTrue(feedApi.postLikesCalls.isEmpty()) assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(11L)) } @Test @@ -120,13 +123,14 @@ class UpdatedFeedRepositoryTest { assertTrue(feedApi.postLikesCalls.isEmpty()) assertTrue(feedApi.deleteLikesCalls.isEmpty()) + assertServerMatches(feedApi, reverted) } @Test fun `동기화 요청이 실패하면 대기 기록이 메모리와 저장소에 그대로 남는다`() = runTest { val feedApi = FakeFeedApi().apply { feedsResponse = feedsResponseOf(feedResponse(feedId = 3L, isLiked = false, likeCount = 2)) - failingLikeIds = setOf(3L) + planLikeRequest("POST", 3L, outcome = LikeOutcome.FAIL_WITHOUT_APPLYING) } val pendingStore = FakePendingFeedLikeStore() val repository = createRepository(feedApi, pendingStore) @@ -147,12 +151,149 @@ class UpdatedFeedRepositoryTest { assertTrue(afterFailure.isLiked) assertEquals(3, afterFailure.likeCount) - // 메모리에도 남아 있는지 간접 확인: 실패 원인을 없애고 다시 동기화하면 같은 요청이 다시 나가고, 이번엔 정리된다. - feedApi.failingLikeIds = emptySet() + // 서버 원본은 false다. 목록 병합은 메모리 pending만 보므로 재조회 결과로 메모리 보존을 확인한다. + assertFalse(feedApi.feedsResponse.feeds.single().isLiked) + assertTrue(feedApi.appliedLikeRequests.isEmpty()) + val refreshed = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(3L) + assertTrue(refreshed.isLiked) + assertEquals(3, refreshed.likeCount) + assertEquals(mapOf(3L to true), pendingStore.currentPendingLikes()) + + // 다음 요청은 성공하도록 두고 명시적 재동기화를 확인한다. repository.syncPendingLikes() advanceUntilIdle() assertEquals(listOf(3L, 3L), feedApi.postLikesCalls) assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(3L)) + } + + @Test + fun `현재 결함 기록 - 서버 반영 후 타임아웃이 나면 이후 취소 기록이 사라지고 재조회 시 좋아요가 켜진다`() = runTest { + // 승인된 재현 예상값을 기록한다. 결함을 허용하는 제품 요구사항이나 수정 완료 테스트가 아니다. + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 8L, isLiked = false, likeCount = 0)) + } + val post = feedApi.planLikeRequest("POST", 8L, outcome = LikeOutcome.TIMEOUT_AFTER_APPLYING) + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(8L) + advanceUntilIdle() + repository.syncPendingLikes() + advanceUntilIdle() + + // 서버는 반영했지만 앱은 성공 응답을 받지 못한 상태다. + assertEquals(listOf(8L), feedApi.postLikesCalls) + assertTrue(feedApi.postLikesCompleted.isEmpty()) + assertEquals(listOf(post), feedApi.appliedLikeRequests) + assertEquals(listOf(post), feedApi.respondedLikeRequests) + assertTrue(feedApi.feedsResponse.feeds.single().isLiked) + assertTrue(repository.sosoAllFeeds.value.feed(8L).isLiked) + assertEquals(mapOf(8L to true), pendingStore.currentPendingLikes()) + + repository.toggleLikeLocal(8L) + advanceUntilIdle() + val cancelled = repository.sosoAllFeeds.value.feed(8L) + assertFalse(cancelled.isLiked) + assertEquals(0, cancelled.likeCount) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + // 저장소가 비었고 재동기화에서도 요청이 없으므로 메모리에도 취소 pending이 남지 않았다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(8L), feedApi.postLikesCalls) + assertTrue(feedApi.deleteLikesCalls.isEmpty()) + assertFalse(repository.sosoAllFeeds.value.feed(8L).isLiked) + val serverAfterSync = feedApi.feedsResponse.feeds.single() + assertTrue(serverAfterSync.isLiked) + assertEquals(1, serverAfterSync.likeCount) + + val refreshed = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(8L) + assertTrue(refreshed.isLiked) + assertEquals(1, refreshed.likeCount) + assertTrue(repository.sosoAllFeeds.value.feed(8L).isLiked) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `현재 결함 기록 - 중복 POST와 취소가 겹치면 서버 처리 순서와 다른 응답 순서가 취소 기록을 지운다`() = runTest { + // 재현 예상: 서버 B-C-A, 앱 응답 B-A-C이면 서버 true / 화면 false / pending 없음이 된다. + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId = 9L, isLiked = false, likeCount = 0)) + } + val processA = CompletableDeferred() + val respondC = CompletableDeferred() + val postA = feedApi.planLikeRequest("POST", 9L, processingGate = processA) + val postB = feedApi.planLikeRequest("POST", 9L) + val deleteC = feedApi.planLikeRequest("DELETE", 9L, responseGate = respondC) + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(9L) + advanceUntilIdle() + repository.syncPendingLikes() // A: 서버 처리부터 보류한다. + advanceUntilIdle() + assertEquals(listOf(9L), feedApi.postLikesCalls) + assertTrue(feedApi.appliedLikeRequests.isEmpty()) + + repository.syncPendingLikes() // B: 먼저 서버 반영 및 성공 응답을 받는다. + advanceUntilIdle() + assertEquals(listOf(9L, 9L), feedApi.postLikesCalls) + assertEquals(listOf(postB), feedApi.appliedLikeRequests) + assertEquals(listOf(postB), feedApi.respondedLikeRequests) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertTrue(repository.sosoAllFeeds.value.feed(9L).isLiked) + assertTrue(feedApi.feedsResponse.feeds.single().isLiked) + + repository.toggleLikeLocal(9L) + advanceUntilIdle() + repository.syncPendingLikes() // C: 서버에는 취소를 반영하지만 응답은 보류한다. + advanceUntilIdle() + assertEquals(listOf(9L), feedApi.deleteLikesCalls) + assertEquals(listOf(postB, deleteC), feedApi.appliedLikeRequests) + assertEquals(listOf(postB), feedApi.respondedLikeRequests) + assertFalse(feedApi.feedsResponse.feeds.single().isLiked) + assertFalse(repository.sosoAllFeeds.value.feed(9L).isLiked) + assertEquals(mapOf(9L to false), pendingStore.currentPendingLikes()) + + processA.complete(Unit) // A: C보다 늦게 서버 반영하고, C보다 먼저 앱에 응답한다. + advanceUntilIdle() + assertEquals(listOf(postB, deleteC, postA), feedApi.appliedLikeRequests) + assertEquals(listOf(postB, postA), feedApi.respondedLikeRequests) + assertTrue(feedApi.feedsResponse.feeds.single().isLiked) + assertFalse(repository.sosoAllFeeds.value.feed(9L).isLiked) + assertEquals(mapOf(9L to false), pendingStore.currentPendingLikes()) + + // 서버 true가 메모리 pending false로 덮이는지, 저장소 재전송과 별개로 확인한다. + val beforeC = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(9L) + assertFalse(beforeC.isLiked) + assertEquals(0, beforeC.likeCount) + + respondC.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(postB, postA, deleteC), feedApi.respondedLikeRequests) + assertFalse(repository.sosoAllFeeds.value.feed(9L).isLiked) + assertEquals(0, repository.sosoAllFeeds.value.feed(9L).likeCount) + assertTrue(feedApi.feedsResponse.feeds.single().isLiked) + assertEquals(1, feedApi.feedsResponse.feeds.single().likeCount) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + // 저장소가 비어 있고 재동기화에도 추가 요청이 없어 메모리 pending도 없음을 확인한다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(9L, 9L), feedApi.postLikesCalls) + assertEquals(listOf(9L), feedApi.deleteLikesCalls) + assertFalse(repository.sosoAllFeeds.value.feed(9L).isLiked) + assertTrue(feedApi.feedsResponse.feeds.single().isLiked) + + val refreshed = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(9L) + assertTrue(refreshed.isLiked) + assertEquals(1, refreshed.likeCount) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) } @Test @@ -162,7 +303,7 @@ class UpdatedFeedRepositoryTest { feedResponse(feedId = 4L, isLiked = false, likeCount = 1), feedResponse(feedId = 5L, isLiked = false, likeCount = 1), ) - failingLikeIds = setOf(5L) + repeat(2) { planLikeRequest("POST", 5L, outcome = LikeOutcome.FAIL_WITHOUT_APPLYING) } } val pendingStore = FakePendingFeedLikeStore() val repository = createRepository(feedApi, pendingStore) @@ -186,8 +327,10 @@ class UpdatedFeedRepositoryTest { assertEquals(2, feed4.likeCount) assertTrue(feed5.isLiked) assertEquals(2, feed5.likeCount) + assertServerMatches(feedApi, feed4) + assertFalse(feedApi.feedsResponse.feeds.first { it.feedId == 5L }.isLiked) - // 메모리 확인: 다시 동기화하면 실패했던 5만 다시 시도된다. + // 다시 동기화하면 실패했던 5만 다시 시도된다. 이 호출만으로 메모리와 저장소를 구분하지는 않는다. repository.syncPendingLikes() advanceUntilIdle() assertEquals(listOf(4L, 5L, 5L), feedApi.postLikesCalls) @@ -206,7 +349,8 @@ class UpdatedFeedRepositoryTest { repository.toggleLikeLocal(6L) advanceUntilIdle() - val postGate = feedApi.holdPostLikes(6L) + val postGate = CompletableDeferred() + feedApi.planLikeRequest("POST", 6L, responseGate = postGate) repository.syncPendingLikes() advanceUntilIdle() assertEquals(listOf(6L), feedApi.postLikesCalls) @@ -231,6 +375,7 @@ class UpdatedFeedRepositoryTest { assertEquals(listOf(6L), feedApi.deleteLikesCalls) assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(6L)) repository.syncPendingLikes() advanceUntilIdle() @@ -238,7 +383,7 @@ class UpdatedFeedRepositoryTest { } @Test - fun `동기화 호출이 겹쳐서 요청이 중복돼도 최신 선택은 손실 없이 반영된다`() = runTest { + fun `같은 방향 POST 두 개의 응답 순서가 바뀌어도 좋아요 상태와 개수가 유지된다`() = runTest { val feedApi = FakeFeedApi().apply { feedsResponse = feedsResponseOf(feedResponse(feedId = 7L, isLiked = false, likeCount = 0)) } @@ -250,7 +395,9 @@ class UpdatedFeedRepositoryTest { repository.toggleLikeLocal(7L) advanceUntilIdle() - val firstPostGate = feedApi.holdPostLikes(7L) + val firstPostGate = CompletableDeferred() + val firstPost = feedApi.planLikeRequest("POST", 7L, responseGate = firstPostGate) + val secondPost = feedApi.planLikeRequest("POST", 7L) repository.syncPendingLikes() // 첫 번째 동기화: 요청은 서버로 보냈지만 응답은 보류된다 advanceUntilIdle() assertEquals(listOf(7L), feedApi.postLikesCalls) // 서버가 받은 순서 1번째 @@ -265,10 +412,13 @@ class UpdatedFeedRepositoryTest { firstPostGate.complete(Unit) // 첫 번째 요청의 응답이 뒤늦게 도착 advanceUntilIdle() assertEquals(listOf(7L, 7L), feedApi.postLikesCompleted) + assertEquals(listOf(firstPost, secondPost), feedApi.appliedLikeRequests) + assertEquals(listOf(secondPost, firstPost), feedApi.respondedLikeRequests) val finalFeed = repository.sosoAllFeeds.value.feed(7L) assertTrue(finalFeed.isLiked) // 화면의 마지막 선택: 손실 없이 좋아요 상태 유지 assertEquals(1, finalFeed.likeCount) + assertServerMatches(feedApi, finalFeed) assertTrue(pendingStore.currentPendingLikes().isEmpty()) // 뒤늦은 응답이 지울 대상이 없어도 안전하게 무시된다 repository.syncPendingLikes() @@ -301,6 +451,7 @@ class UpdatedFeedRepositoryTest { assertEquals(listOf(1L), feedApi.postLikesCalls) assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(1L)) } @Test @@ -340,6 +491,12 @@ class UpdatedFeedRepositoryTest { private fun List.feed(id: Long): FeedEntity = first { it.id == id } + private fun assertServerMatches(feedApi: FakeFeedApi, feed: FeedEntity) { + val serverFeed = feedApi.feedsResponse.feeds.single { it.feedId == feed.id } + assertEquals("서버와 화면의 좋아요 상태", feed.isLiked, serverFeed.isLiked) + assertEquals("서버와 화면의 좋아요 개수", feed.likeCount, serverFeed.likeCount) + } + private fun feedsResponseOf(vararg feeds: FeedResponseDto): FeedsResponseDto = FeedsResponseDto(isLoadable = false, feeds = feeds.toList()) @@ -373,6 +530,20 @@ class UpdatedFeedRepositoryTest { ) } +private enum class LikeOutcome { + SUCCESS, + FAIL_WITHOUT_APPLYING, + TIMEOUT_AFTER_APPLYING, +} + +private class PlannedLikeRequest( + val method: String, + val feedId: Long, + val outcome: LikeOutcome, + val processingGate: CompletableDeferred?, + val responseGate: CompletableDeferred?, +) + private class FakeFeedApi : FeedApi { var feedsResponse: FeedsResponseDto = FeedsResponseDto(isLoadable = false, feeds = emptyList()) val feedDetailResponses = mutableMapOf() @@ -381,29 +552,26 @@ private class FakeFeedApi : FeedApi { val postLikesCalls = mutableListOf() val deleteLikesCalls = mutableListOf() - /** 응답 도착 순서. 앱이 응답을 받은 순서를 나타낸다. */ + /** 성공 응답 도착 순서. 실패 응답은 포함하지 않는다. */ val postLikesCompleted = mutableListOf() val deleteLikesCompleted = mutableListOf() - var failingLikeIds: Set = emptySet() - - private data class Gate(val method: String, val feedId: Long) - - private val gates = mutableMapOf>() - - /** 다음 postLikes(feedId) 호출을 [CompletableDeferred]가 완료될 때까지 보류시킨다. */ - fun holdPostLikes(feedId: Long): CompletableDeferred = hold("POST", feedId) - - /** 다음 deleteLikes(feedId) 호출을 [CompletableDeferred]가 완료될 때까지 보류시킨다. */ - fun holdDeleteLikes(feedId: Long): CompletableDeferred = hold("DELETE", feedId) + val appliedLikeRequests = mutableListOf() + val respondedLikeRequests = mutableListOf() + private val plannedLikeRequests = mutableListOf() - private fun hold( + /** 같은 method/feedId의 다음 요청부터 순서대로 적용한다. 두 gate가 없으면 즉시 처리·응답한다. */ + fun planLikeRequest( method: String, feedId: Long, - ): CompletableDeferred { - val deferred = CompletableDeferred() - gates[Gate(method, feedId)] = deferred - return deferred + outcome: LikeOutcome = LikeOutcome.SUCCESS, + processingGate: CompletableDeferred? = null, + responseGate: CompletableDeferred? = null, + ): PlannedLikeRequest { + require(method == "POST" || method == "DELETE") + return PlannedLikeRequest(method, feedId, outcome, processingGate, responseGate).also { + plannedLikeRequests += it + } } override suspend fun getFeeds( @@ -430,16 +598,49 @@ private class FakeFeedApi : FeedApi { override suspend fun postLikes(feedId: Long) { postLikesCalls += feedId - gates.remove(Gate("POST", feedId))?.await() + executeLikeRequest("POST", feedId) postLikesCompleted += feedId - if (feedId in failingLikeIds) throw RuntimeException("postLikes 실패: $feedId") } override suspend fun deleteLikes(feedId: Long) { deleteLikesCalls += feedId - gates.remove(Gate("DELETE", feedId))?.await() + executeLikeRequest("DELETE", feedId) deleteLikesCompleted += feedId - if (feedId in failingLikeIds) throw RuntimeException("deleteLikes 실패: $feedId") + } + + private suspend fun executeLikeRequest(method: String, feedId: Long) { + val index = plannedLikeRequests.indexOfFirst { it.method == method && it.feedId == feedId } + val request = if (index >= 0) { + plannedLikeRequests.removeAt(index) + } else { + PlannedLikeRequest(method, feedId, LikeOutcome.SUCCESS, null, null) + } + request.processingGate?.await() + if (request.outcome != LikeOutcome.FAIL_WITHOUT_APPLYING) { + applyLikeOnServer(feedId, isLiked = method == "POST") + appliedLikeRequests += request + } + request.responseGate?.await() + // 예외로 전달되는 실패/타임아웃도 앱에 결과가 도착한 순서에 포함한다. + respondedLikeRequests += request + when (request.outcome) { + LikeOutcome.SUCCESS -> Unit + LikeOutcome.FAIL_WITHOUT_APPLYING -> throw RuntimeException("$method 반영 전 실패: $feedId") + LikeOutcome.TIMEOUT_AFTER_APPLYING -> throw SocketTimeoutException("서버 반영 후 응답 유실: $feedId") + } + } + + // 테스트 가정: POST/DELETE는 목표 상태를 설정하고, 같은 상태를 반복 설정해도 개수는 변하지 않는다. + private fun applyLikeOnServer(feedId: Long, isLiked: Boolean) { + feedsResponse = feedsResponse.copy( + feeds = feedsResponse.feeds.map { feed -> + if (feed.feedId == feedId && feed.isLiked != isLiked) { + feed.copy(isLiked = isLiked, likeCount = feed.likeCount + if (isLiked) 1 else -1) + } else { + feed + } + }, + ) } override suspend fun postSpoilerFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") From e4d60ba754e5ebcdb07db66c9d87241f2a05efb8 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 29 Sep 2026 02:24:46 +0900 Subject: [PATCH 25/49] refactor: ktlint formatting --- .../repository/UpdatedFeedRepositoryTest.kt | 992 ++++++++++-------- 1 file changed, 551 insertions(+), 441 deletions(-) diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt index 023069d8e..18fb191af 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt @@ -31,450 +31,548 @@ import java.net.SocketTimeoutException @OptIn(ExperimentalCoroutinesApi::class) class UpdatedFeedRepositoryTest { - @Test - fun `좋아요를 누르고 동기화하면 서버 요청이 성공하고 대기 기록이 정리된다`() = runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + fun `좋아요를 누르고 동기화하면 서버 요청이 성공하고 대기 기록이 정리된다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + + val toggled = repository.sosoAllFeeds.value.feed(1L) + assertTrue(toggled.isLiked) + assertEquals(4, toggled.likeCount) + assertEquals(mapOf(1L to true), pendingStore.currentPendingLikes()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(1L), feedApi.postLikesCalls) + assertTrue(feedApi.deleteLikesCalls.isEmpty()) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + val synced = repository.sosoAllFeeds.value.feed(1L) + assertTrue(synced.isLiked) + assertEquals(4, synced.likeCount) + assertServerMatches(feedApi, synced) + + // 메모리 pending이 비었는지 간접 확인: 다시 동기화해도 추가 요청이 없어야 한다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), feedApi.postLikesCalls) } - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(1L) - advanceUntilIdle() - - val toggled = repository.sosoAllFeeds.value.feed(1L) - assertTrue(toggled.isLiked) - assertEquals(4, toggled.likeCount) - assertEquals(mapOf(1L to true), pendingStore.currentPendingLikes()) - - repository.syncPendingLikes() - advanceUntilIdle() - - assertEquals(listOf(1L), feedApi.postLikesCalls) - assertTrue(feedApi.deleteLikesCalls.isEmpty()) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - - val synced = repository.sosoAllFeeds.value.feed(1L) - assertTrue(synced.isLiked) - assertEquals(4, synced.likeCount) - assertServerMatches(feedApi, synced) - - // 메모리 pending이 비었는지 간접 확인: 다시 동기화해도 추가 요청이 없어야 한다. - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(1L), feedApi.postLikesCalls) - } @Test - fun `좋아요를 취소하고 동기화하면 서버 요청이 성공하고 대기 기록이 정리된다`() = runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 11L, isLiked = true, likeCount = 4)) + fun `좋아요를 취소하고 동기화하면 서버 요청이 성공하고 대기 기록이 정리된다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 11L, isLiked = true, likeCount = 4)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(11L) + advanceUntilIdle() + + val toggled = repository.sosoAllFeeds.value.feed(11L) + assertFalse(toggled.isLiked) + assertEquals(3, toggled.likeCount) + assertEquals(mapOf(11L to false), pendingStore.currentPendingLikes()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(11L), feedApi.deleteLikesCalls) + assertTrue(feedApi.postLikesCalls.isEmpty()) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(11L)) } - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(11L) - advanceUntilIdle() - - val toggled = repository.sosoAllFeeds.value.feed(11L) - assertFalse(toggled.isLiked) - assertEquals(3, toggled.likeCount) - assertEquals(mapOf(11L to false), pendingStore.currentPendingLikes()) - - repository.syncPendingLikes() - advanceUntilIdle() - - assertEquals(listOf(11L), feedApi.deleteLikesCalls) - assertTrue(feedApi.postLikesCalls.isEmpty()) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(11L)) - } @Test - fun `전송 전에 좋아요를 다시 취소하면 서버 요청 없이 원래 상태로 남는다`() = runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 2L, isLiked = false, likeCount = 5)) + fun `전송 전에 좋아요를 다시 취소하면 서버 요청 없이 원래 상태로 남는다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 2L, isLiked = false, likeCount = 5)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + + repository.toggleLikeLocal(2L) + advanceUntilIdle() + assertEquals(mapOf(2L to true), pendingStore.currentPendingLikes()) + + repository.toggleLikeLocal(2L) + advanceUntilIdle() + + val reverted = repository.sosoAllFeeds.value.feed(2L) + assertFalse(reverted.isLiked) + assertEquals(5, reverted.likeCount) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertTrue(feedApi.postLikesCalls.isEmpty()) + assertTrue(feedApi.deleteLikesCalls.isEmpty()) + assertServerMatches(feedApi, reverted) } - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - - repository.toggleLikeLocal(2L) - advanceUntilIdle() - assertEquals(mapOf(2L to true), pendingStore.currentPendingLikes()) - - repository.toggleLikeLocal(2L) - advanceUntilIdle() - - val reverted = repository.sosoAllFeeds.value.feed(2L) - assertFalse(reverted.isLiked) - assertEquals(5, reverted.likeCount) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - - repository.syncPendingLikes() - advanceUntilIdle() - - assertTrue(feedApi.postLikesCalls.isEmpty()) - assertTrue(feedApi.deleteLikesCalls.isEmpty()) - assertServerMatches(feedApi, reverted) - } @Test - fun `동기화 요청이 실패하면 대기 기록이 메모리와 저장소에 그대로 남는다`() = runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 3L, isLiked = false, likeCount = 2)) - planLikeRequest("POST", 3L, outcome = LikeOutcome.FAIL_WITHOUT_APPLYING) + fun `동기화 요청이 실패하면 대기 기록이 메모리와 저장소에 그대로 남는다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 3L, isLiked = false, likeCount = 2)) + planLikeRequest("POST", 3L, outcome = LikeOutcome.FAIL_WITHOUT_APPLYING) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(3L) + advanceUntilIdle() + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(3L), feedApi.postLikesCalls) + assertTrue(pendingStore.deleteIfMatchedCalls.isEmpty()) + assertEquals(mapOf(3L to true), pendingStore.currentPendingLikes()) + + val afterFailure = repository.sosoAllFeeds.value.feed(3L) + assertTrue(afterFailure.isLiked) + assertEquals(3, afterFailure.likeCount) + + // 서버 원본은 false다. 목록 병합은 메모리 pending만 보므로 재조회 결과로 메모리 보존을 확인한다. + assertFalse( + feedApi.feedsResponse.feeds + .single() + .isLiked, + ) + assertTrue(feedApi.appliedLikeRequests.isEmpty()) + val refreshed = + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(3L) + assertTrue(refreshed.isLiked) + assertEquals(3, refreshed.likeCount) + assertEquals(mapOf(3L to true), pendingStore.currentPendingLikes()) + + // 다음 요청은 성공하도록 두고 명시적 재동기화를 확인한다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(3L, 3L), feedApi.postLikesCalls) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(3L)) } - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(3L) - advanceUntilIdle() - - repository.syncPendingLikes() - advanceUntilIdle() - - assertEquals(listOf(3L), feedApi.postLikesCalls) - assertTrue(pendingStore.deleteIfMatchedCalls.isEmpty()) - assertEquals(mapOf(3L to true), pendingStore.currentPendingLikes()) - - val afterFailure = repository.sosoAllFeeds.value.feed(3L) - assertTrue(afterFailure.isLiked) - assertEquals(3, afterFailure.likeCount) - - // 서버 원본은 false다. 목록 병합은 메모리 pending만 보므로 재조회 결과로 메모리 보존을 확인한다. - assertFalse(feedApi.feedsResponse.feeds.single().isLiked) - assertTrue(feedApi.appliedLikeRequests.isEmpty()) - val refreshed = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(3L) - assertTrue(refreshed.isLiked) - assertEquals(3, refreshed.likeCount) - assertEquals(mapOf(3L to true), pendingStore.currentPendingLikes()) - - // 다음 요청은 성공하도록 두고 명시적 재동기화를 확인한다. - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(3L, 3L), feedApi.postLikesCalls) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(3L)) - } @Test - fun `현재 결함 기록 - 서버 반영 후 타임아웃이 나면 이후 취소 기록이 사라지고 재조회 시 좋아요가 켜진다`() = runTest { - // 승인된 재현 예상값을 기록한다. 결함을 허용하는 제품 요구사항이나 수정 완료 테스트가 아니다. - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 8L, isLiked = false, likeCount = 0)) + fun `현재 결함 기록 - 서버 반영 후 타임아웃이 나면 이후 취소 기록이 사라지고 재조회 시 좋아요가 켜진다`() = + runTest { + // 승인된 재현 예상값을 기록한다. 결함을 허용하는 제품 요구사항이나 수정 완료 테스트가 아니다. + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 8L, isLiked = false, likeCount = 0)) + } + val post = feedApi.planLikeRequest("POST", 8L, outcome = LikeOutcome.TIMEOUT_AFTER_APPLYING) + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(8L) + advanceUntilIdle() + repository.syncPendingLikes() + advanceUntilIdle() + + // 서버는 반영했지만 앱은 성공 응답을 받지 못한 상태다. + assertEquals(listOf(8L), feedApi.postLikesCalls) + assertTrue(feedApi.postLikesCompleted.isEmpty()) + assertEquals(listOf(post), feedApi.appliedLikeRequests) + assertEquals(listOf(post), feedApi.respondedLikeRequests) + assertTrue( + feedApi.feedsResponse.feeds + .single() + .isLiked, + ) + assertTrue( + repository.sosoAllFeeds.value + .feed(8L) + .isLiked, + ) + assertEquals(mapOf(8L to true), pendingStore.currentPendingLikes()) + + repository.toggleLikeLocal(8L) + advanceUntilIdle() + val cancelled = repository.sosoAllFeeds.value.feed(8L) + assertFalse(cancelled.isLiked) + assertEquals(0, cancelled.likeCount) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + // 저장소가 비었고 재동기화에서도 요청이 없으므로 메모리에도 취소 pending이 남지 않았다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(8L), feedApi.postLikesCalls) + assertTrue(feedApi.deleteLikesCalls.isEmpty()) + assertFalse( + repository.sosoAllFeeds.value + .feed(8L) + .isLiked, + ) + val serverAfterSync = feedApi.feedsResponse.feeds.single() + assertTrue(serverAfterSync.isLiked) + assertEquals(1, serverAfterSync.likeCount) + + val refreshed = + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(8L) + assertTrue(refreshed.isLiked) + assertEquals(1, refreshed.likeCount) + assertTrue( + repository.sosoAllFeeds.value + .feed(8L) + .isLiked, + ) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) } - val post = feedApi.planLikeRequest("POST", 8L, outcome = LikeOutcome.TIMEOUT_AFTER_APPLYING) - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(8L) - advanceUntilIdle() - repository.syncPendingLikes() - advanceUntilIdle() - - // 서버는 반영했지만 앱은 성공 응답을 받지 못한 상태다. - assertEquals(listOf(8L), feedApi.postLikesCalls) - assertTrue(feedApi.postLikesCompleted.isEmpty()) - assertEquals(listOf(post), feedApi.appliedLikeRequests) - assertEquals(listOf(post), feedApi.respondedLikeRequests) - assertTrue(feedApi.feedsResponse.feeds.single().isLiked) - assertTrue(repository.sosoAllFeeds.value.feed(8L).isLiked) - assertEquals(mapOf(8L to true), pendingStore.currentPendingLikes()) - - repository.toggleLikeLocal(8L) - advanceUntilIdle() - val cancelled = repository.sosoAllFeeds.value.feed(8L) - assertFalse(cancelled.isLiked) - assertEquals(0, cancelled.likeCount) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - - // 저장소가 비었고 재동기화에서도 요청이 없으므로 메모리에도 취소 pending이 남지 않았다. - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(8L), feedApi.postLikesCalls) - assertTrue(feedApi.deleteLikesCalls.isEmpty()) - assertFalse(repository.sosoAllFeeds.value.feed(8L).isLiked) - val serverAfterSync = feedApi.feedsResponse.feeds.single() - assertTrue(serverAfterSync.isLiked) - assertEquals(1, serverAfterSync.likeCount) - - val refreshed = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(8L) - assertTrue(refreshed.isLiked) - assertEquals(1, refreshed.likeCount) - assertTrue(repository.sosoAllFeeds.value.feed(8L).isLiked) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - } @Test - fun `현재 결함 기록 - 중복 POST와 취소가 겹치면 서버 처리 순서와 다른 응답 순서가 취소 기록을 지운다`() = runTest { - // 재현 예상: 서버 B-C-A, 앱 응답 B-A-C이면 서버 true / 화면 false / pending 없음이 된다. - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 9L, isLiked = false, likeCount = 0)) + fun `현재 결함 기록 - 중복 POST와 취소가 겹치면 서버 처리 순서와 다른 응답 순서가 취소 기록을 지운다`() = + runTest { + // 재현 예상: 서버 B-C-A, 앱 응답 B-A-C이면 서버 true / 화면 false / pending 없음이 된다. + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 9L, isLiked = false, likeCount = 0)) + } + val processA = CompletableDeferred() + val respondC = CompletableDeferred() + val postA = feedApi.planLikeRequest("POST", 9L, processingGate = processA) + val postB = feedApi.planLikeRequest("POST", 9L) + val deleteC = feedApi.planLikeRequest("DELETE", 9L, responseGate = respondC) + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(9L) + advanceUntilIdle() + repository.syncPendingLikes() // A: 서버 처리부터 보류한다. + advanceUntilIdle() + assertEquals(listOf(9L), feedApi.postLikesCalls) + assertTrue(feedApi.appliedLikeRequests.isEmpty()) + + repository.syncPendingLikes() // B: 먼저 서버 반영 및 성공 응답을 받는다. + advanceUntilIdle() + assertEquals(listOf(9L, 9L), feedApi.postLikesCalls) + assertEquals(listOf(postB), feedApi.appliedLikeRequests) + assertEquals(listOf(postB), feedApi.respondedLikeRequests) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertTrue( + repository.sosoAllFeeds.value + .feed(9L) + .isLiked, + ) + assertTrue( + feedApi.feedsResponse.feeds + .single() + .isLiked, + ) + + repository.toggleLikeLocal(9L) + advanceUntilIdle() + repository.syncPendingLikes() // C: 서버에는 취소를 반영하지만 응답은 보류한다. + advanceUntilIdle() + assertEquals(listOf(9L), feedApi.deleteLikesCalls) + assertEquals(listOf(postB, deleteC), feedApi.appliedLikeRequests) + assertEquals(listOf(postB), feedApi.respondedLikeRequests) + assertFalse( + feedApi.feedsResponse.feeds + .single() + .isLiked, + ) + assertFalse( + repository.sosoAllFeeds.value + .feed(9L) + .isLiked, + ) + assertEquals(mapOf(9L to false), pendingStore.currentPendingLikes()) + + processA.complete(Unit) // A: C보다 늦게 서버 반영하고, C보다 먼저 앱에 응답한다. + advanceUntilIdle() + assertEquals(listOf(postB, deleteC, postA), feedApi.appliedLikeRequests) + assertEquals(listOf(postB, postA), feedApi.respondedLikeRequests) + assertTrue( + feedApi.feedsResponse.feeds + .single() + .isLiked, + ) + assertFalse( + repository.sosoAllFeeds.value + .feed(9L) + .isLiked, + ) + assertEquals(mapOf(9L to false), pendingStore.currentPendingLikes()) + + // 서버 true가 메모리 pending false로 덮이는지, 저장소 재전송과 별개로 확인한다. + val beforeC = + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(9L) + assertFalse(beforeC.isLiked) + assertEquals(0, beforeC.likeCount) + + respondC.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(postB, postA, deleteC), feedApi.respondedLikeRequests) + assertFalse( + repository.sosoAllFeeds.value + .feed(9L) + .isLiked, + ) + assertEquals( + 0, + repository.sosoAllFeeds.value + .feed(9L) + .likeCount, + ) + assertTrue( + feedApi.feedsResponse.feeds + .single() + .isLiked, + ) + assertEquals( + 1, + feedApi.feedsResponse.feeds + .single() + .likeCount, + ) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + // 저장소가 비어 있고 재동기화에도 추가 요청이 없어 메모리 pending도 없음을 확인한다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(9L, 9L), feedApi.postLikesCalls) + assertEquals(listOf(9L), feedApi.deleteLikesCalls) + assertFalse( + repository.sosoAllFeeds.value + .feed(9L) + .isLiked, + ) + assertTrue( + feedApi.feedsResponse.feeds + .single() + .isLiked, + ) + + val refreshed = + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(9L) + assertTrue(refreshed.isLiked) + assertEquals(1, refreshed.likeCount) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) } - val processA = CompletableDeferred() - val respondC = CompletableDeferred() - val postA = feedApi.planLikeRequest("POST", 9L, processingGate = processA) - val postB = feedApi.planLikeRequest("POST", 9L) - val deleteC = feedApi.planLikeRequest("DELETE", 9L, responseGate = respondC) - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(9L) - advanceUntilIdle() - repository.syncPendingLikes() // A: 서버 처리부터 보류한다. - advanceUntilIdle() - assertEquals(listOf(9L), feedApi.postLikesCalls) - assertTrue(feedApi.appliedLikeRequests.isEmpty()) - - repository.syncPendingLikes() // B: 먼저 서버 반영 및 성공 응답을 받는다. - advanceUntilIdle() - assertEquals(listOf(9L, 9L), feedApi.postLikesCalls) - assertEquals(listOf(postB), feedApi.appliedLikeRequests) - assertEquals(listOf(postB), feedApi.respondedLikeRequests) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - assertTrue(repository.sosoAllFeeds.value.feed(9L).isLiked) - assertTrue(feedApi.feedsResponse.feeds.single().isLiked) - - repository.toggleLikeLocal(9L) - advanceUntilIdle() - repository.syncPendingLikes() // C: 서버에는 취소를 반영하지만 응답은 보류한다. - advanceUntilIdle() - assertEquals(listOf(9L), feedApi.deleteLikesCalls) - assertEquals(listOf(postB, deleteC), feedApi.appliedLikeRequests) - assertEquals(listOf(postB), feedApi.respondedLikeRequests) - assertFalse(feedApi.feedsResponse.feeds.single().isLiked) - assertFalse(repository.sosoAllFeeds.value.feed(9L).isLiked) - assertEquals(mapOf(9L to false), pendingStore.currentPendingLikes()) - - processA.complete(Unit) // A: C보다 늦게 서버 반영하고, C보다 먼저 앱에 응답한다. - advanceUntilIdle() - assertEquals(listOf(postB, deleteC, postA), feedApi.appliedLikeRequests) - assertEquals(listOf(postB, postA), feedApi.respondedLikeRequests) - assertTrue(feedApi.feedsResponse.feeds.single().isLiked) - assertFalse(repository.sosoAllFeeds.value.feed(9L).isLiked) - assertEquals(mapOf(9L to false), pendingStore.currentPendingLikes()) - - // 서버 true가 메모리 pending false로 덮이는지, 저장소 재전송과 별개로 확인한다. - val beforeC = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(9L) - assertFalse(beforeC.isLiked) - assertEquals(0, beforeC.likeCount) - - respondC.complete(Unit) - advanceUntilIdle() - assertEquals(listOf(postB, postA, deleteC), feedApi.respondedLikeRequests) - assertFalse(repository.sosoAllFeeds.value.feed(9L).isLiked) - assertEquals(0, repository.sosoAllFeeds.value.feed(9L).likeCount) - assertTrue(feedApi.feedsResponse.feeds.single().isLiked) - assertEquals(1, feedApi.feedsResponse.feeds.single().likeCount) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - - // 저장소가 비어 있고 재동기화에도 추가 요청이 없어 메모리 pending도 없음을 확인한다. - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(9L, 9L), feedApi.postLikesCalls) - assertEquals(listOf(9L), feedApi.deleteLikesCalls) - assertFalse(repository.sosoAllFeeds.value.feed(9L).isLiked) - assertTrue(feedApi.feedsResponse.feeds.single().isLiked) - - val refreshed = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(9L) - assertTrue(refreshed.isLiked) - assertEquals(1, refreshed.likeCount) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - } @Test - fun `두 건을 동기화할 때 하나만 실패하면 성공한 건만 정리되고 실패한 건만 남는다`() = runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf( - feedResponse(feedId = 4L, isLiked = false, likeCount = 1), - feedResponse(feedId = 5L, isLiked = false, likeCount = 1), + fun `두 건을 동기화할 때 하나만 실패하면 성공한 건만 정리되고 실패한 건만 남는다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf( + feedResponse(feedId = 4L, isLiked = false, likeCount = 1), + feedResponse(feedId = 5L, isLiked = false, likeCount = 1), + ) + repeat(2) { planLikeRequest("POST", 5L, outcome = LikeOutcome.FAIL_WITHOUT_APPLYING) } + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(4L) + repository.toggleLikeLocal(5L) + advanceUntilIdle() + assertEquals(mapOf(4L to true, 5L to true), pendingStore.currentPendingLikes()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(4L, 5L), feedApi.postLikesCalls) + assertEquals(mapOf(5L to true), pendingStore.currentPendingLikes()) + + val feed4 = repository.sosoAllFeeds.value.feed(4L) + val feed5 = repository.sosoAllFeeds.value.feed(5L) + assertTrue(feed4.isLiked) + assertEquals(2, feed4.likeCount) + assertTrue(feed5.isLiked) + assertEquals(2, feed5.likeCount) + assertServerMatches(feedApi, feed4) + assertFalse( + feedApi.feedsResponse.feeds + .first { it.feedId == 5L } + .isLiked, ) - repeat(2) { planLikeRequest("POST", 5L, outcome = LikeOutcome.FAIL_WITHOUT_APPLYING) } + + // 다시 동기화하면 실패했던 5만 다시 시도된다. 이 호출만으로 메모리와 저장소를 구분하지는 않는다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(4L, 5L, 5L), feedApi.postLikesCalls) } - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(4L) - repository.toggleLikeLocal(5L) - advanceUntilIdle() - assertEquals(mapOf(4L to true, 5L to true), pendingStore.currentPendingLikes()) - - repository.syncPendingLikes() - advanceUntilIdle() - - assertEquals(listOf(4L, 5L), feedApi.postLikesCalls) - assertEquals(mapOf(5L to true), pendingStore.currentPendingLikes()) - - val feed4 = repository.sosoAllFeeds.value.feed(4L) - val feed5 = repository.sosoAllFeeds.value.feed(5L) - assertTrue(feed4.isLiked) - assertEquals(2, feed4.likeCount) - assertTrue(feed5.isLiked) - assertEquals(2, feed5.likeCount) - assertServerMatches(feedApi, feed4) - assertFalse(feedApi.feedsResponse.feeds.first { it.feedId == 5L }.isLiked) - - // 다시 동기화하면 실패했던 5만 다시 시도된다. 이 호출만으로 메모리와 저장소를 구분하지는 않는다. - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(4L, 5L, 5L), feedApi.postLikesCalls) - } @Test - fun `전송 중에 취소하면 화면의 취소가 유지되고 취소가 다시 대기열에 쌓인다`() = runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 6L, isLiked = false, likeCount = 0)) + fun `전송 중에 취소하면 화면의 취소가 유지되고 취소가 다시 대기열에 쌓인다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 6L, isLiked = false, likeCount = 0)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(6L) + advanceUntilIdle() + + val postGate = CompletableDeferred() + feedApi.planLikeRequest("POST", 6L, responseGate = postGate) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(6L), feedApi.postLikesCalls) + + repository.toggleLikeLocal(6L) // 좋아요 응답을 받기 전에 취소 + advanceUntilIdle() + + val cancelled = repository.sosoAllFeeds.value.feed(6L) + assertFalse(cancelled.isLiked) + assertEquals(0, cancelled.likeCount) + + postGate.complete(Unit) // 뒤늦게 좋아요 요청이 성공 응답을 받음 + advanceUntilIdle() + + val afterPostSucceeds = repository.sosoAllFeeds.value.feed(6L) + assertFalse(afterPostSucceeds.isLiked) // 화면의 취소가 유지된다 + assertEquals(0, afterPostSucceeds.likeCount) + assertEquals(mapOf(6L to false), pendingStore.currentPendingLikes()) // 취소가 다시 대기열에 남는다 + + repository.syncPendingLikes() // 명시적으로 다시 동기화 + advanceUntilIdle() + + assertEquals(listOf(6L), feedApi.deleteLikesCalls) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(6L)) + + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(6L), feedApi.deleteLikesCalls) // 메모리도 비어 추가 요청이 없다 } - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(6L) - advanceUntilIdle() - - val postGate = CompletableDeferred() - feedApi.planLikeRequest("POST", 6L, responseGate = postGate) - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(6L), feedApi.postLikesCalls) - - repository.toggleLikeLocal(6L) // 좋아요 응답을 받기 전에 취소 - advanceUntilIdle() - - val cancelled = repository.sosoAllFeeds.value.feed(6L) - assertFalse(cancelled.isLiked) - assertEquals(0, cancelled.likeCount) - - postGate.complete(Unit) // 뒤늦게 좋아요 요청이 성공 응답을 받음 - advanceUntilIdle() - - val afterPostSucceeds = repository.sosoAllFeeds.value.feed(6L) - assertFalse(afterPostSucceeds.isLiked) // 화면의 취소가 유지된다 - assertEquals(0, afterPostSucceeds.likeCount) - assertEquals(mapOf(6L to false), pendingStore.currentPendingLikes()) // 취소가 다시 대기열에 남는다 - - repository.syncPendingLikes() // 명시적으로 다시 동기화 - advanceUntilIdle() - - assertEquals(listOf(6L), feedApi.deleteLikesCalls) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(6L)) - - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(6L), feedApi.deleteLikesCalls) // 메모리도 비어 추가 요청이 없다 - } @Test - fun `같은 방향 POST 두 개의 응답 순서가 바뀌어도 좋아요 상태와 개수가 유지된다`() = runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 7L, isLiked = false, likeCount = 0)) + fun `같은 방향 POST 두 개의 응답 순서가 바뀌어도 좋아요 상태와 개수가 유지된다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 7L, isLiked = false, likeCount = 0)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(7L) + advanceUntilIdle() + + val firstPostGate = CompletableDeferred() + val firstPost = feedApi.planLikeRequest("POST", 7L, responseGate = firstPostGate) + val secondPost = feedApi.planLikeRequest("POST", 7L) + repository.syncPendingLikes() // 첫 번째 동기화: 요청은 서버로 보냈지만 응답은 보류된다 + advanceUntilIdle() + assertEquals(listOf(7L), feedApi.postLikesCalls) // 서버가 받은 순서 1번째 + + repository.syncPendingLikes() // 두 번째 동기화(겹침): pending이 아직 남아 있어 다시 요청한다 + advanceUntilIdle() + // 두 번째 요청은 보류 없이 즉시 응답한다 -> 응답은 두 번째 요청이 먼저 도착한다 + assertEquals(listOf(7L, 7L), feedApi.postLikesCalls) // 서버가 받은 순서: 1번, 2번 + assertEquals(listOf(7L), feedApi.postLikesCompleted) // 앱이 응답을 받은 순서: 2번이 먼저 끝남 + assertTrue(pendingStore.currentPendingLikes().isEmpty()) // 두 번째 요청 처리로 pending이 정리됨 + + firstPostGate.complete(Unit) // 첫 번째 요청의 응답이 뒤늦게 도착 + advanceUntilIdle() + assertEquals(listOf(7L, 7L), feedApi.postLikesCompleted) + assertEquals(listOf(firstPost, secondPost), feedApi.appliedLikeRequests) + assertEquals(listOf(secondPost, firstPost), feedApi.respondedLikeRequests) + + val finalFeed = repository.sosoAllFeeds.value.feed(7L) + assertTrue(finalFeed.isLiked) // 화면의 마지막 선택: 손실 없이 좋아요 상태 유지 + assertEquals(1, finalFeed.likeCount) + assertServerMatches(feedApi, finalFeed) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) // 뒤늦은 응답이 지울 대상이 없어도 안전하게 무시된다 + + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(7L, 7L), feedApi.postLikesCalls) // 메모리도 비어 추가 요청이 없다 } - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(7L) - advanceUntilIdle() - - val firstPostGate = CompletableDeferred() - val firstPost = feedApi.planLikeRequest("POST", 7L, responseGate = firstPostGate) - val secondPost = feedApi.planLikeRequest("POST", 7L) - repository.syncPendingLikes() // 첫 번째 동기화: 요청은 서버로 보냈지만 응답은 보류된다 - advanceUntilIdle() - assertEquals(listOf(7L), feedApi.postLikesCalls) // 서버가 받은 순서 1번째 - - repository.syncPendingLikes() // 두 번째 동기화(겹침): pending이 아직 남아 있어 다시 요청한다 - advanceUntilIdle() - // 두 번째 요청은 보류 없이 즉시 응답한다 -> 응답은 두 번째 요청이 먼저 도착한다 - assertEquals(listOf(7L, 7L), feedApi.postLikesCalls) // 서버가 받은 순서: 1번, 2번 - assertEquals(listOf(7L), feedApi.postLikesCompleted) // 앱이 응답을 받은 순서: 2번이 먼저 끝남 - assertTrue(pendingStore.currentPendingLikes().isEmpty()) // 두 번째 요청 처리로 pending이 정리됨 - - firstPostGate.complete(Unit) // 첫 번째 요청의 응답이 뒤늦게 도착 - advanceUntilIdle() - assertEquals(listOf(7L, 7L), feedApi.postLikesCompleted) - assertEquals(listOf(firstPost, secondPost), feedApi.appliedLikeRequests) - assertEquals(listOf(secondPost, firstPost), feedApi.respondedLikeRequests) - - val finalFeed = repository.sosoAllFeeds.value.feed(7L) - assertTrue(finalFeed.isLiked) // 화면의 마지막 선택: 손실 없이 좋아요 상태 유지 - assertEquals(1, finalFeed.likeCount) - assertServerMatches(feedApi, finalFeed) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) // 뒤늦은 응답이 지울 대상이 없어도 안전하게 무시된다 - - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(7L, 7L), feedApi.postLikesCalls) // 메모리도 비어 추가 요청이 없다 - } @Test - fun `복원이 끝난 뒤 목록을 조회하면 복원된 pending이 즉시 반영된다`() = runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + fun `복원이 끝난 뒤 목록을 조회하면 복원된 pending이 즉시 반영된다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + } + val pendingStore = FakePendingFeedLikeStore(initial = mapOf(1L to true)) + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() // init { restorePendingLikes() } 완료를 기다린다 + + val result = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + + val restoredFeed = result.feeds.feed(1L) + assertTrue(restoredFeed.isLiked) + assertEquals(4, restoredFeed.likeCount) + + val cachedFeed = repository.sosoAllFeeds.value.feed(1L) + assertTrue(cachedFeed.isLiked) + assertEquals(4, cachedFeed.likeCount) + + // 복원된 기록이 이후 명시적 동기화에서 실제로 쓰이는지 확인한다. + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(1L), feedApi.postLikesCalls) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(1L)) } - val pendingStore = FakePendingFeedLikeStore(initial = mapOf(1L to true)) - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() // init { restorePendingLikes() } 완료를 기다린다 - - val result = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - - val restoredFeed = result.feeds.feed(1L) - assertTrue(restoredFeed.isLiked) - assertEquals(4, restoredFeed.likeCount) - - val cachedFeed = repository.sosoAllFeeds.value.feed(1L) - assertTrue(cachedFeed.isLiked) - assertEquals(4, cachedFeed.likeCount) - - // 복원된 기록이 이후 명시적 동기화에서 실제로 쓰이는지 확인한다. - repository.syncPendingLikes() - advanceUntilIdle() - - assertEquals(listOf(1L), feedApi.postLikesCalls) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(1L)) - } @Test - fun `복원이 끝나기 전에 목록을 조회해도 복원이 끝나면 목록에 뒤늦게 반영된다`() = runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + fun `복원이 끝나기 전에 목록을 조회해도 복원이 끝나면 목록에 뒤늦게 반영된다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + } + val pendingStore = FakePendingFeedLikeStore(initial = mapOf(1L to true)) + val repository = createRepository(feedApi, pendingStore) + + // advanceUntilIdle()을 호출하지 않아 init { restorePendingLikes() }가 아직 실행되지 않은 상태에서 조회한다. + val result = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + + val beforeRestore = result.feeds.feed(1L) + assertFalse(beforeRestore.isLiked) + assertEquals(3, beforeRestore.likeCount) + + advanceUntilIdle() // restorePendingLikes()가 뒤늦게 끝난다 + + val afterRestore = repository.sosoAllFeeds.value.feed(1L) + assertTrue(afterRestore.isLiked) + assertEquals(4, afterRestore.likeCount) } - val pendingStore = FakePendingFeedLikeStore(initial = mapOf(1L to true)) - val repository = createRepository(feedApi, pendingStore) - - // advanceUntilIdle()을 호출하지 않아 init { restorePendingLikes() }가 아직 실행되지 않은 상태에서 조회한다. - val result = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - - val beforeRestore = result.feeds.feed(1L) - assertFalse(beforeRestore.isLiked) - assertEquals(3, beforeRestore.likeCount) - - advanceUntilIdle() // restorePendingLikes()가 뒤늦게 끝난다 - - val afterRestore = repository.sosoAllFeeds.value.feed(1L) - assertTrue(afterRestore.isLiked) - assertEquals(4, afterRestore.likeCount) - } private fun TestScope.createRepository( feedApi: FeedApi, @@ -491,7 +589,10 @@ class UpdatedFeedRepositoryTest { private fun List.feed(id: Long): FeedEntity = first { it.id == id } - private fun assertServerMatches(feedApi: FakeFeedApi, feed: FeedEntity) { + private fun assertServerMatches( + feedApi: FakeFeedApi, + feed: FeedEntity, + ) { val serverFeed = feedApi.feedsResponse.feeds.single { it.feedId == feed.id } assertEquals("서버와 화면의 좋아요 상태", feed.isLiked, serverFeed.isLiked) assertEquals("서버와 화면의 좋아요 개수", feed.likeCount, serverFeed.likeCount) @@ -504,30 +605,31 @@ class UpdatedFeedRepositoryTest { feedId: Long, isLiked: Boolean, likeCount: Int, - ): FeedResponseDto = FeedResponseDto( - userId = 1L, - nickname = "닉네임", - avatarImage = "avatar.png", - feedId = feedId, - createdDate = "2026-01-01", - feedContent = "content-$feedId", - likeCount = likeCount, - isLiked = isLiked, - commentCount = 0, - novelId = 100L, - title = "novel-title", - novelRating = 4.5f, - novelRatingCount = 10, - isSpoiler = false, - isModified = false, - isMyFeed = false, - isPublic = true, - thumbnailUrl = null, - imageCount = 0, - genreName = "로맨스", - userNovelRating = null, - feedWriterNovelRating = null, - ) + ): FeedResponseDto = + FeedResponseDto( + userId = 1L, + nickname = "닉네임", + avatarImage = "avatar.png", + feedId = feedId, + createdDate = "2026-01-01", + feedContent = "content-$feedId", + likeCount = likeCount, + isLiked = isLiked, + commentCount = 0, + novelId = 100L, + title = "novel-title", + novelRating = 4.5f, + novelRatingCount = 10, + isSpoiler = false, + isModified = false, + isMyFeed = false, + isPublic = true, + thumbnailUrl = null, + imageCount = 0, + genreName = "로맨스", + userNovelRating = null, + feedWriterNovelRating = null, + ) } private enum class LikeOutcome { @@ -580,8 +682,7 @@ private class FakeFeedApi : FeedApi { size: Int, ): FeedsResponseDto = feedsResponse - override suspend fun getFeed(feedId: Long): FeedDetailResponseDto = - feedDetailResponses[feedId] ?: error("등록되지 않은 feedId: $feedId") + override suspend fun getFeed(feedId: Long): FeedDetailResponseDto = feedDetailResponses[feedId] ?: error("등록되지 않은 feedId: $feedId") override suspend fun postFeed( feedRequestDto: MultipartBody.Part, @@ -608,7 +709,10 @@ private class FakeFeedApi : FeedApi { deleteLikesCompleted += feedId } - private suspend fun executeLikeRequest(method: String, feedId: Long) { + private suspend fun executeLikeRequest( + method: String, + feedId: Long, + ) { val index = plannedLikeRequests.indexOfFirst { it.method == method && it.feedId == feedId } val request = if (index >= 0) { plannedLikeRequests.removeAt(index) @@ -631,11 +735,17 @@ private class FakeFeedApi : FeedApi { } // 테스트 가정: POST/DELETE는 목표 상태를 설정하고, 같은 상태를 반복 설정해도 개수는 변하지 않는다. - private fun applyLikeOnServer(feedId: Long, isLiked: Boolean) { + private fun applyLikeOnServer( + feedId: Long, + isLiked: Boolean, + ) { feedsResponse = feedsResponse.copy( feeds = feedsResponse.feeds.map { feed -> if (feed.feedId == feedId && feed.isLiked != isLiked) { - feed.copy(isLiked = isLiked, likeCount = feed.likeCount + if (isLiked) 1 else -1) + feed.copy( + isLiked = isLiked, + likeCount = feed.likeCount + if (isLiked) 1 else -1, + ) } else { feed } From 7fe1221a69de40f4d9492b9bfb85d59e63588e13 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 30 Sep 2026 01:49:38 +0900 Subject: [PATCH 26/49] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B3=B5=ED=86=B5=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/UpdatedFeedRepositoryTest.kt | 260 ----------------- .../UpdatedFeedRepositoryTestSupport.kt | 265 ++++++++++++++++++ 2 files changed, 265 insertions(+), 260 deletions(-) create mode 100644 data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt index 18fb191af..859f3efe8 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt @@ -1,33 +1,14 @@ package com.into.websoso.data.feed.repository -import android.content.ContextWrapper -import com.into.websoso.core.common.image.ImageCompressor -import com.into.websoso.core.network.common.ImageDownloader -import com.into.websoso.core.network.datasource.feed.FeedApi -import com.into.websoso.core.network.datasource.feed.mapper.MultiPartMapper -import com.into.websoso.core.network.datasource.feed.model.request.CommentRequestDto -import com.into.websoso.core.network.datasource.feed.model.response.CommentsResponseDto -import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailResponseDto -import com.into.websoso.core.network.datasource.feed.model.response.FeedResponseDto -import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto import com.into.websoso.data.feed.model.FeedEntity -import com.into.websoso.data.feed.store.PendingFeedLikeStore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import okhttp3.MultipartBody import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import java.net.SocketTimeoutException @OptIn(ExperimentalCoroutinesApi::class) class UpdatedFeedRepositoryTest { @@ -574,21 +555,6 @@ class UpdatedFeedRepositoryTest { assertEquals(4, afterRestore.likeCount) } - private fun TestScope.createRepository( - feedApi: FeedApi, - pendingFeedLikeStore: PendingFeedLikeStore, - ): UpdatedFeedRepository = - UpdatedFeedRepository( - feedApi = feedApi, - pendingFeedLikeStore = pendingFeedLikeStore, - multiPartMapper = MultiPartMapper(ContextWrapper(null)), - imageDownloader = ImageDownloader(ContextWrapper(null)), - imageCompressor = ImageCompressor(ContextWrapper(null)), - dispatcher = StandardTestDispatcher(testScheduler), - ) - - private fun List.feed(id: Long): FeedEntity = first { it.id == id } - private fun assertServerMatches( feedApi: FakeFeedApi, feed: FeedEntity, @@ -597,230 +563,4 @@ class UpdatedFeedRepositoryTest { assertEquals("서버와 화면의 좋아요 상태", feed.isLiked, serverFeed.isLiked) assertEquals("서버와 화면의 좋아요 개수", feed.likeCount, serverFeed.likeCount) } - - private fun feedsResponseOf(vararg feeds: FeedResponseDto): FeedsResponseDto = - FeedsResponseDto(isLoadable = false, feeds = feeds.toList()) - - private fun feedResponse( - feedId: Long, - isLiked: Boolean, - likeCount: Int, - ): FeedResponseDto = - FeedResponseDto( - userId = 1L, - nickname = "닉네임", - avatarImage = "avatar.png", - feedId = feedId, - createdDate = "2026-01-01", - feedContent = "content-$feedId", - likeCount = likeCount, - isLiked = isLiked, - commentCount = 0, - novelId = 100L, - title = "novel-title", - novelRating = 4.5f, - novelRatingCount = 10, - isSpoiler = false, - isModified = false, - isMyFeed = false, - isPublic = true, - thumbnailUrl = null, - imageCount = 0, - genreName = "로맨스", - userNovelRating = null, - feedWriterNovelRating = null, - ) -} - -private enum class LikeOutcome { - SUCCESS, - FAIL_WITHOUT_APPLYING, - TIMEOUT_AFTER_APPLYING, -} - -private class PlannedLikeRequest( - val method: String, - val feedId: Long, - val outcome: LikeOutcome, - val processingGate: CompletableDeferred?, - val responseGate: CompletableDeferred?, -) - -private class FakeFeedApi : FeedApi { - var feedsResponse: FeedsResponseDto = FeedsResponseDto(isLoadable = false, feeds = emptyList()) - val feedDetailResponses = mutableMapOf() - - /** 요청 시작 순서. 서버가 요청을 받은 순서를 나타낸다. */ - val postLikesCalls = mutableListOf() - val deleteLikesCalls = mutableListOf() - - /** 성공 응답 도착 순서. 실패 응답은 포함하지 않는다. */ - val postLikesCompleted = mutableListOf() - val deleteLikesCompleted = mutableListOf() - - val appliedLikeRequests = mutableListOf() - val respondedLikeRequests = mutableListOf() - private val plannedLikeRequests = mutableListOf() - - /** 같은 method/feedId의 다음 요청부터 순서대로 적용한다. 두 gate가 없으면 즉시 처리·응답한다. */ - fun planLikeRequest( - method: String, - feedId: Long, - outcome: LikeOutcome = LikeOutcome.SUCCESS, - processingGate: CompletableDeferred? = null, - responseGate: CompletableDeferred? = null, - ): PlannedLikeRequest { - require(method == "POST" || method == "DELETE") - return PlannedLikeRequest(method, feedId, outcome, processingGate, responseGate).also { - plannedLikeRequests += it - } - } - - override suspend fun getFeeds( - feedsOption: String, - lastFeedId: Long, - size: Int, - ): FeedsResponseDto = feedsResponse - - override suspend fun getFeed(feedId: Long): FeedDetailResponseDto = feedDetailResponses[feedId] ?: error("등록되지 않은 feedId: $feedId") - - override suspend fun postFeed( - feedRequestDto: MultipartBody.Part, - images: List?, - ): Unit = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun putFeed( - feedId: Long, - feedRequestDto: MultipartBody.Part, - images: List?, - ): Unit = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun deleteFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun postLikes(feedId: Long) { - postLikesCalls += feedId - executeLikeRequest("POST", feedId) - postLikesCompleted += feedId - } - - override suspend fun deleteLikes(feedId: Long) { - deleteLikesCalls += feedId - executeLikeRequest("DELETE", feedId) - deleteLikesCompleted += feedId - } - - private suspend fun executeLikeRequest( - method: String, - feedId: Long, - ) { - val index = plannedLikeRequests.indexOfFirst { it.method == method && it.feedId == feedId } - val request = if (index >= 0) { - plannedLikeRequests.removeAt(index) - } else { - PlannedLikeRequest(method, feedId, LikeOutcome.SUCCESS, null, null) - } - request.processingGate?.await() - if (request.outcome != LikeOutcome.FAIL_WITHOUT_APPLYING) { - applyLikeOnServer(feedId, isLiked = method == "POST") - appliedLikeRequests += request - } - request.responseGate?.await() - // 예외로 전달되는 실패/타임아웃도 앱에 결과가 도착한 순서에 포함한다. - respondedLikeRequests += request - when (request.outcome) { - LikeOutcome.SUCCESS -> Unit - LikeOutcome.FAIL_WITHOUT_APPLYING -> throw RuntimeException("$method 반영 전 실패: $feedId") - LikeOutcome.TIMEOUT_AFTER_APPLYING -> throw SocketTimeoutException("서버 반영 후 응답 유실: $feedId") - } - } - - // 테스트 가정: POST/DELETE는 목표 상태를 설정하고, 같은 상태를 반복 설정해도 개수는 변하지 않는다. - private fun applyLikeOnServer( - feedId: Long, - isLiked: Boolean, - ) { - feedsResponse = feedsResponse.copy( - feeds = feedsResponse.feeds.map { feed -> - if (feed.feedId == feedId && feed.isLiked != isLiked) { - feed.copy( - isLiked = isLiked, - likeCount = feed.likeCount + if (isLiked) 1 else -1, - ) - } else { - feed - } - }, - ) - } - - override suspend fun postSpoilerFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun postImpertinenceFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun getComments(feedId: Long): CommentsResponseDto = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun postComment( - feedId: Long, - commentRequestDto: CommentRequestDto, - ): Unit = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun putComment( - feedId: Long, - commentId: Long, - commentRequestDto: CommentRequestDto, - ): Unit = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun deleteComment( - feedId: Long, - commentId: Long, - ): Unit = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun postSpoilerComment( - feedId: Long, - commentId: Long, - ): Unit = error("좋아요 테스트에서 사용하지 않음") - - override suspend fun postImpertinenceComment( - feedId: Long, - commentId: Long, - ): Unit = error("좋아요 테스트에서 사용하지 않음") -} - -private class FakePendingFeedLikeStore( - initial: Map = emptyMap(), -) : PendingFeedLikeStore { - private val state = MutableStateFlow(initial) - - override val pendingLikes: Flow> = state.asStateFlow() - - val updateCalls = mutableListOf>() - val deleteCalls = mutableListOf() - val deleteIfMatchedCalls = mutableListOf>() - - fun currentPendingLikes(): Map = state.value - - override suspend fun getPendingLikes(): Map = state.value - - override suspend fun updatePendingLike( - feedId: Long, - isLiked: Boolean, - ) { - updateCalls += feedId to isLiked - state.update { it + (feedId to isLiked) } - } - - override suspend fun deletePendingLike(feedId: Long) { - deleteCalls += feedId - state.update { it - feedId } - } - - override suspend fun deletePendingLikeIfMatched( - feedId: Long, - isLiked: Boolean, - ): Boolean { - deleteIfMatchedCalls += feedId to isLiked - if (state.value[feedId] != isLiked) return false - state.update { it - feedId } - return true - } } diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt new file mode 100644 index 000000000..6fa696193 --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt @@ -0,0 +1,265 @@ +package com.into.websoso.data.feed.repository + +import android.content.ContextWrapper +import com.into.websoso.core.common.image.ImageCompressor +import com.into.websoso.core.network.common.ImageDownloader +import com.into.websoso.core.network.datasource.feed.FeedApi +import com.into.websoso.core.network.datasource.feed.mapper.MultiPartMapper +import com.into.websoso.core.network.datasource.feed.model.request.CommentRequestDto +import com.into.websoso.core.network.datasource.feed.model.response.CommentsResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto +import com.into.websoso.data.feed.model.FeedEntity +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import okhttp3.MultipartBody +import java.net.SocketTimeoutException + +@OptIn(ExperimentalCoroutinesApi::class) +internal fun TestScope.createRepository( + feedApi: FeedApi, + pendingFeedLikeStore: PendingFeedLikeStore, +): UpdatedFeedRepository = + UpdatedFeedRepository( + feedApi = feedApi, + pendingFeedLikeStore = pendingFeedLikeStore, + multiPartMapper = MultiPartMapper(ContextWrapper(null)), + imageDownloader = ImageDownloader(ContextWrapper(null)), + imageCompressor = ImageCompressor(ContextWrapper(null)), + dispatcher = StandardTestDispatcher(testScheduler), + ) + +internal fun List.feed(id: Long): FeedEntity = first { it.id == id } + +internal fun feedsResponseOf(vararg feeds: FeedResponseDto): FeedsResponseDto = FeedsResponseDto(isLoadable = false, feeds = feeds.toList()) + +internal fun feedResponse( + feedId: Long, + isLiked: Boolean, + likeCount: Int, +): FeedResponseDto = + FeedResponseDto( + userId = 1L, + nickname = "닉네임", + avatarImage = "avatar.png", + feedId = feedId, + createdDate = "2026-01-01", + feedContent = "content-$feedId", + likeCount = likeCount, + isLiked = isLiked, + commentCount = 0, + novelId = 100L, + title = "novel-title", + novelRating = 4.5f, + novelRatingCount = 10, + isSpoiler = false, + isModified = false, + isMyFeed = false, + isPublic = true, + thumbnailUrl = null, + imageCount = 0, + genreName = "로맨스", + userNovelRating = null, + feedWriterNovelRating = null, + ) + +internal enum class LikeOutcome { + SUCCESS, + FAIL_WITHOUT_APPLYING, + TIMEOUT_AFTER_APPLYING, +} + +internal class PlannedLikeRequest( + val method: String, + val feedId: Long, + val outcome: LikeOutcome, + val processingGate: CompletableDeferred?, + val responseGate: CompletableDeferred?, +) + +internal class FakeFeedApi : FeedApi { + var feedsResponse: FeedsResponseDto = FeedsResponseDto(isLoadable = false, feeds = emptyList()) + val feedDetailResponses = mutableMapOf() + + /** 요청 시작 순서. 서버가 요청을 받은 순서를 나타낸다. */ + val postLikesCalls = mutableListOf() + val deleteLikesCalls = mutableListOf() + + /** 성공 응답 도착 순서. 실패 응답은 포함하지 않는다. */ + val postLikesCompleted = mutableListOf() + val deleteLikesCompleted = mutableListOf() + + val appliedLikeRequests = mutableListOf() + val respondedLikeRequests = mutableListOf() + private val plannedLikeRequests = mutableListOf() + + /** 같은 method/feedId의 다음 요청부터 순서대로 적용한다. 두 gate가 없으면 즉시 처리·응답한다. */ + fun planLikeRequest( + method: String, + feedId: Long, + outcome: LikeOutcome = LikeOutcome.SUCCESS, + processingGate: CompletableDeferred? = null, + responseGate: CompletableDeferred? = null, + ): PlannedLikeRequest { + require(method == "POST" || method == "DELETE") + return PlannedLikeRequest(method, feedId, outcome, processingGate, responseGate).also { + plannedLikeRequests += it + } + } + + override suspend fun getFeeds( + feedsOption: String, + lastFeedId: Long, + size: Int, + ): FeedsResponseDto = feedsResponse + + override suspend fun getFeed(feedId: Long): FeedDetailResponseDto = feedDetailResponses[feedId] ?: error("등록되지 않은 feedId: $feedId") + + override suspend fun postFeed( + feedRequestDto: MultipartBody.Part, + images: List?, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun putFeed( + feedId: Long, + feedRequestDto: MultipartBody.Part, + images: List?, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun deleteFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postLikes(feedId: Long) { + postLikesCalls += feedId + executeLikeRequest("POST", feedId) + postLikesCompleted += feedId + } + + override suspend fun deleteLikes(feedId: Long) { + deleteLikesCalls += feedId + executeLikeRequest("DELETE", feedId) + deleteLikesCompleted += feedId + } + + private suspend fun executeLikeRequest( + method: String, + feedId: Long, + ) { + val index = plannedLikeRequests.indexOfFirst { it.method == method && it.feedId == feedId } + val request = if (index >= 0) { + plannedLikeRequests.removeAt(index) + } else { + PlannedLikeRequest(method, feedId, LikeOutcome.SUCCESS, null, null) + } + request.processingGate?.await() + if (request.outcome != LikeOutcome.FAIL_WITHOUT_APPLYING) { + applyLikeOnServer(feedId, isLiked = method == "POST") + appliedLikeRequests += request + } + request.responseGate?.await() + // 예외로 전달되는 실패/타임아웃도 앱에 결과가 도착한 순서에 포함한다. + respondedLikeRequests += request + when (request.outcome) { + LikeOutcome.SUCCESS -> Unit + LikeOutcome.FAIL_WITHOUT_APPLYING -> throw RuntimeException("$method 반영 전 실패: $feedId") + LikeOutcome.TIMEOUT_AFTER_APPLYING -> throw SocketTimeoutException("서버 반영 후 응답 유실: $feedId") + } + } + + // 테스트 가정: POST/DELETE는 목표 상태를 설정하고, 같은 상태를 반복 설정해도 개수는 변하지 않는다. + private fun applyLikeOnServer( + feedId: Long, + isLiked: Boolean, + ) { + feedsResponse = feedsResponse.copy( + feeds = feedsResponse.feeds.map { feed -> + if (feed.feedId == feedId && feed.isLiked != isLiked) { + feed.copy( + isLiked = isLiked, + likeCount = feed.likeCount + if (isLiked) 1 else -1, + ) + } else { + feed + } + }, + ) + } + + override suspend fun postSpoilerFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postImpertinenceFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun getComments(feedId: Long): CommentsResponseDto = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postComment( + feedId: Long, + commentRequestDto: CommentRequestDto, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun putComment( + feedId: Long, + commentId: Long, + commentRequestDto: CommentRequestDto, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun deleteComment( + feedId: Long, + commentId: Long, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postSpoilerComment( + feedId: Long, + commentId: Long, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postImpertinenceComment( + feedId: Long, + commentId: Long, + ): Unit = error("좋아요 테스트에서 사용하지 않음") +} + +internal class FakePendingFeedLikeStore( + initial: Map = emptyMap(), +) : PendingFeedLikeStore { + private val state = MutableStateFlow(initial) + + override val pendingLikes: Flow> = state.asStateFlow() + + val updateCalls = mutableListOf>() + val deleteCalls = mutableListOf() + val deleteIfMatchedCalls = mutableListOf>() + + fun currentPendingLikes(): Map = state.value + + override suspend fun getPendingLikes(): Map = state.value + + override suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + updateCalls += feedId to isLiked + state.update { it + (feedId to isLiked) } + } + + override suspend fun deletePendingLike(feedId: Long) { + deleteCalls += feedId + state.update { it - feedId } + } + + override suspend fun deletePendingLikeIfMatched( + feedId: Long, + isLiked: Boolean, + ): Boolean { + deleteIfMatchedCalls += feedId to isLiked + if (state.value[feedId] != isLiked) return false + state.update { it - feedId } + return true + } +} From 663208c54c8c621ac22bd5c98179bf7e5dc9c5e6 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Wed, 30 Sep 2026 01:52:46 +0900 Subject: [PATCH 27/49] =?UTF-8?q?test:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=A0=84=ED=99=98=20=EA=B2=B0=ED=95=A8=20?= =?UTF-8?q?=EC=9E=AC=ED=98=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UpdatedFeedRepositoryAccountTest.kt | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryAccountTest.kt diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryAccountTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryAccountTest.kt new file mode 100644 index 000000000..54cde0030 --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryAccountTest.kt @@ -0,0 +1,228 @@ +package com.into.websoso.data.feed.repository + +import com.into.websoso.core.network.datasource.feed.FeedApi +import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UpdatedFeedRepositoryAccountTest { + @Test + fun `현재 계정 결함 기록 - A에서 실패한 pending이 B 목록에 반영되고 B 요청으로 전송된다`() = + runTest { + // 정상 기준은 계정 간 선택이 섞이지 않는 것이다. 아래는 현재 결함의 재현 예상값이다. + // 실제 로그인/로그아웃 대신 API 대역의 계정 컨텍스트를 전환한다. + val feedApi = accountAwareFeedApi(12L) + feedApi.accountApis.getValue("A").planLikeRequest("POST", 12L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(12L) + advanceUntilIdle() + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(AccountLikeCall("A", 12L, true)), feedApi.likeCalls) + assertEquals(mapOf(12L to true), pendingStore.currentPendingLikes()) + assertFalse(feedApi.isLikedFor("A", 12L)) + + feedApi.currentAccountId = null + assertTrue( + repository.sosoAllFeeds.value + .feed(12L) + .isLiked, + ) + assertEquals(mapOf(12L to true), pendingStore.currentPendingLikes()) + feedApi.currentAccountId = "B" + assertFalse(feedApi.isLikedFor("B", 12L)) + val refreshed = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(12L) + // B의 서버 원본은 false인데 A의 메모리 pending이 목록에 적용된다. + assertTrue(refreshed.isLiked) + assertEquals(1, refreshed.likeCount) + + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals( + listOf(AccountLikeCall("A", 12L, true), AccountLikeCall("B", 12L, true)), + feedApi.likeCalls, + ) + assertFalse(feedApi.isLikedFor("A", 12L)) + assertTrue(feedApi.isLikedFor("B", 12L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `현재 계정 결함 기록 - 저장소만 비워도 A의 메모리 pending이 B 요청으로 전송된다`() = + runTest { + val feedApi = accountAwareFeedApi(13L) + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(13L) + advanceUntilIdle() + + feedApi.currentAccountId = null + // 실제 로그아웃 구현이 아니다. 저장소만 정리하는 대응으로 충분한지 분리해서 확인한다. + pendingStore.deletePendingLike(13L) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + feedApi.currentAccountId = "B" + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(AccountLikeCall("B", 13L, true)), feedApi.likeCalls) + assertFalse(feedApi.isLikedFor("A", 13L)) + assertTrue(feedApi.isLikedFor("B", 13L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `현재 계정 결함 기록 - B에서 Repository를 재생성해도 저장된 A pending이 복원되어 전송된다`() = + runTest { + val feedApi = accountAwareFeedApi(14L) + val pendingStore = FakePendingFeedLikeStore() + val repositoryA = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repositoryA.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repositoryA.toggleLikeLocal(14L) + advanceUntilIdle() + assertEquals(mapOf(14L to true), pendingStore.currentPendingLikes()) + assertTrue(feedApi.likeCalls.isEmpty()) + + feedApi.currentAccountId = null + feedApi.currentAccountId = "B" + // 같은 가짜 저장소를 읽는 새 인스턴스다. 실제 DataStore/프로세스 재실행 검증은 아니다. + val repositoryB = createRepository(feedApi, pendingStore) + advanceUntilIdle() + val restored = repositoryB.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(14L) + assertTrue(restored.isLiked) + assertFalse(feedApi.isLikedFor("B", 14L)) + + repositoryB.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(AccountLikeCall("B", 14L, true)), feedApi.likeCalls) + assertFalse(feedApi.isLikedFor("A", 14L)) + assertTrue(feedApi.isLikedFor("B", 14L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `현재 계정 결함 기록 - A에서 호출하고 실행을 기다리던 동기화가 B 요청으로 전송된다`() = + runTest { + val feedApi = accountAwareFeedApi(15L) + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(15L) + advanceUntilIdle() + + repository.syncPendingLikes() + // launch된 동기화는 아직 실행하지 않고, 먼저 계정 컨텍스트를 바꾼다. + assertTrue(feedApi.likeCalls.isEmpty()) + feedApi.currentAccountId = null + feedApi.currentAccountId = "B" + advanceUntilIdle() + + assertEquals(listOf(AccountLikeCall("B", 15L, true)), feedApi.likeCalls) + assertFalse(feedApi.isLikedFor("A", 15L)) + assertTrue(feedApi.isLikedFor("B", 15L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `현재 계정 결함 기록 - 저장소 정리 뒤 실행된 A의 저장이 B에서 복원되어 전송된다`() = + runTest { + val feedApi = accountAwareFeedApi(16L) + val pendingStore = FakePendingFeedLikeStore() + val repositoryA = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repositoryA.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repositoryA.toggleLikeLocal(16L) + // 화면과 메모리만 바뀐 시점이다. launch된 저장 작업은 아직 실행하지 않는다. + assertTrue( + repositoryA.sosoAllFeeds.value + .feed(16L) + .isLiked, + ) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + feedApi.currentAccountId = null + // 저장 작업 실행 전 저장소 정리를 모의한다. 실제 로그아웃 구현은 아니다. + pendingStore.deletePendingLike(16L) + feedApi.currentAccountId = "B" + advanceUntilIdle() + assertEquals(mapOf(16L to true), pendingStore.currentPendingLikes()) + + val repositoryB = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repositoryB.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repositoryB.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(AccountLikeCall("B", 16L, true)), feedApi.likeCalls) + assertFalse(feedApi.isLikedFor("A", 16L)) + assertTrue(feedApi.isLikedFor("B", 16L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + private fun accountAwareFeedApi(feedId: Long): AccountAwareFeedApi = + AccountAwareFeedApi( + accountApis = listOf("A", "B").associateWith { + FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId, isLiked = false, likeCount = 0)) + } + }, + currentAccountId = "A", + ) +} + +private data class AccountLikeCall( + val accountId: String, + val feedId: Long, + val isLiked: Boolean, +) + +// 실제 로그인/인증을 실행하지 않고, 요청 시점의 계정 컨텍스트와 계정별 서버 상태를 모델링한다. +private class AccountAwareFeedApi( + val accountApis: Map, + var currentAccountId: String?, +) : FeedApi by accountApis.values.first() { + val likeCalls = mutableListOf() + + fun isLikedFor( + accountId: String, + feedId: Long, + ): Boolean = + accountApis + .getValue(accountId) + .feedsResponse + .feeds + .single { it.feedId == feedId } + .isLiked + + override suspend fun getFeeds( + feedsOption: String, + lastFeedId: Long, + size: Int, + ): FeedsResponseDto = accountApis.getValue(requireNotNull(currentAccountId)).getFeeds(feedsOption, lastFeedId, size) + + override suspend fun postLikes(feedId: Long) = sendLike(feedId, isLiked = true) + + override suspend fun deleteLikes(feedId: Long) = sendLike(feedId, isLiked = false) + + private suspend fun sendLike( + feedId: Long, + isLiked: Boolean, + ) { + val accountId = requireNotNull(currentAccountId) + val api = accountApis.getValue(accountId) + likeCalls += AccountLikeCall(accountId, feedId, isLiked) + if (isLiked) api.postLikes(feedId) else api.deleteLikes(feedId) + } +} From 6ca7a4e14d3e1effa33fc9525449291c15e70cd6 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 10:30:45 +0900 Subject: [PATCH 28/49] =?UTF-8?q?test:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=8B=9C=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepositoryTestSupport.kt`: 피드 좋아요 상태(`isLiked`) 변경 시, `feedDetailResponses`에 저장된 상세 데이터의 좋아요 여부와 좋아요 수(`likeCount`)를 함께 업데이트하도록 개선 --- .../feed/repository/UpdatedFeedRepositoryTestSupport.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt index 6fa696193..e126be806 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt @@ -178,6 +178,14 @@ internal class FakeFeedApi : FeedApi { feedId: Long, isLiked: Boolean, ) { + feedDetailResponses[feedId]?.let { detail -> + if (detail.isLiked != isLiked) { + feedDetailResponses[feedId] = detail.copy( + isLiked = isLiked, + likeCount = detail.likeCount + if (isLiked) 1 else -1, + ) + } + } feedsResponse = feedsResponse.copy( feeds = feedsResponse.feeds.map { feed -> if (feed.feedId == feedId && feed.isLiked != isLiked) { From bb08596a83cef2e12f923ef7273fd270489ff8c3 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 10:34:19 +0900 Subject: [PATCH 29/49] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EB=B3=B5=EA=B5=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: - `LikeSyncStatus`를 도입하여 좋아요 동기화 상태(진행 중, 재시도 필요)를 관리 - `likeStateLock` 및 `Mutex`를 사용하여 스레드 안전한 상태 업데이트 및 네트워크 요청 순차 보장 - 네트워크 지연이나 실패 시 사용자 선택이 유실되지 않도록 리비전 관리 및 재시도 로직 강화 - 피드 상세 정보와 목록 간의 좋아요 상태 동기화가 실시간으로 반영되도록 `feedDetailLikeStates` StateFlow 추가 - `retryPendingLikes` 및 `syncPendingLikes`를 통해 수동/자동 재시도 분리 및 안정성 개선 - `LikeSyncStatus.kt`: 동기화 상태 관리를 위한 신규 Enum 추가 - `CachedFeedLikeState.kt`: 상세 화면 연동을 위해 가시성을 `internal`에서 `public`으로 변경 - `UpdatedFeedRepositoryRecoveryTest.kt` & `UpdatedFeedRepositoryLikeStateTest.kt`: - 타임아웃, 네트워크 실패, 앱 종료 후 복구 등 다양한 에지 케이스에 대한 단위 테스트 추가 - `UpdatedFeedRepositoryTest.kt`: 기존 결함 재현 테스트를 개선된 로직에 맞춰 정상 동작 확인 테스트로 대체 및 정리 --- .../feed/repository/UpdatedFeedRepository.kt | 401 +++++++++++------- .../repository/model/CachedFeedLikeState.kt | 2 +- .../feed/repository/model/LikeSyncStatus.kt | 6 + .../UpdatedFeedRepositoryLikeStateTest.kt | 277 ++++++++++++ .../UpdatedFeedRepositoryRecoveryTest.kt | 366 ++++++++++++++++ .../repository/UpdatedFeedRepositoryTest.kt | 248 ----------- 6 files changed, 894 insertions(+), 406 deletions(-) create mode 100644 data/feed/src/main/java/com/into/websoso/data/feed/repository/model/LikeSyncStatus.kt create mode 100644 data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt create mode 100644 data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index e9aeeeb35..e67978a49 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -16,10 +16,12 @@ import com.into.websoso.data.feed.model.FeedDetailEntity import com.into.websoso.data.feed.model.FeedEntity import com.into.websoso.data.feed.model.FeedsEntity import com.into.websoso.data.feed.repository.model.CachedFeedLikeState +import com.into.websoso.data.feed.repository.model.LikeSyncStatus import com.into.websoso.data.feed.store.PendingFeedLikeStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -60,8 +62,20 @@ class UpdatedFeedRepository private val pendingLikeStates = ConcurrentHashMap() private val originalLikeStates = ConcurrentHashMap() - private val feedDetailStates = ConcurrentHashMap() + private val _feedDetailLikeStates = MutableStateFlow>(emptyMap()) + val feedDetailLikeStates = _feedDetailLikeStates.asStateFlow() private val pendingLikeStoreWriteMutex = Mutex() + private val likeStateLock = Any() + + // ponytail: all feeds share one sender; use per-feed serialization if slow requests delay other feeds excessively. + private val likeSyncMutex = Mutex() + private val likesRestored = CompletableDeferred() + private val unconfirmedLikeIds = mutableSetOf() + private val likeRevisions = mutableMapOf() + private val completedLikeAttempts = mutableMapOf>() + private var likeEventSequence = 0L + private val _likeSyncStates = MutableStateFlow>(emptyMap()) + val likeSyncStates = _likeSyncStates.asStateFlow() init { restorePendingLikes() @@ -69,10 +83,26 @@ class UpdatedFeedRepository private fun restorePendingLikes() { scope.launch { - val pendingLikes = pendingFeedLikeStore.getPendingLikes() - pendingLikeStates.clear() - pendingLikeStates.putAll(pendingLikes) - applyPendingLikeStatesToCachedFeeds() + try { + val pendingLikes = pendingFeedLikeStore.getPendingLikes() + synchronized(likeStateLock) { + pendingLikes.forEach { (id, isLiked) -> + // A click made while storage was loading takes precedence, including a cancelled selection. + pendingLikeStates[id] = if (id in likeRevisions) { + findCurrentLikeState(id) ?: pendingLikeStates[id] ?: isLiked + } else { + isLiked + } + unconfirmedLikeIds += id + setLikeSyncStatus(id, LikeSyncStatus.NEEDS_RETRY) + } + applyPendingLikeStatesToCachedFeeds() + } + likesRestored.complete(Unit) + } catch (error: Exception) { + likesRestored.completeExceptionally(error) + throw error + } } } @@ -201,21 +231,24 @@ class UpdatedFeedRepository size = size, ).toData() - val mergedFeeds = result.feeds.map { feed -> applyPendingLikeState(feed) } + return synchronized(likeStateLock) { + val mergedFeeds = result.feeds.map { feed -> applyPendingLikeState(feed) } + updateCachedLikes(mergedFeeds.associate { it.id to CachedFeedLikeState(it.isLiked, it.likeCount) }) - val isRecommended = feedsOption == "RECOMMENDED" - val targetFlow = if (isRecommended) _sosoRecommendedFeeds else _sosoAllFeeds + val isRecommended = feedsOption == "RECOMMENDED" + val targetFlow = if (isRecommended) _sosoRecommendedFeeds else _sosoAllFeeds - targetFlow.update { currentList -> - if (lastFeedId == 0L) { - mergedFeeds - } else { - val newFeeds = mergedFeeds.filterNot { new -> currentList.any { it.id == new.id } } - currentList + newFeeds + targetFlow.update { currentList -> + if (lastFeedId == 0L) { + mergedFeeds + } else { + val newFeeds = mergedFeeds.filterNot { new -> currentList.any { it.id == new.id } } + currentList + newFeeds + } } - } - return result.copy(feeds = targetFlow.value) + result.copy(feeds = targetFlow.value) + } } /** @@ -225,28 +258,42 @@ class UpdatedFeedRepository feeds: List, isRefreshed: Boolean, ) { - val mergedFeeds = feeds.map { feed -> applyPendingLikeState(feed) } + synchronized(likeStateLock) { + val mergedFeeds = feeds.map { feed -> applyPendingLikeState(feed) } + updateCachedLikes(mergedFeeds.associate { it.id to CachedFeedLikeState(it.isLiked, it.likeCount) }) - _myFeeds.update { current -> - if (isRefreshed) mergedFeeds else (current + mergedFeeds).distinctBy { it.id } + _myFeeds.update { current -> + if (isRefreshed) mergedFeeds else (current + mergedFeeds).distinctBy { it.id } + } } } /** * 서버 데이터보다 로컬의 변경사항(좋아요)을 우선 적용하여 반환합니다. */ - private fun applyPendingLikeState(feed: FeedEntity): FeedEntity { - val localIsLiked = pendingLikeStates[feed.id] ?: return feed - originalLikeStates.putIfAbsent(feed.id, feed.isLiked) - - if (feed.isLiked != localIsLiked) { - val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 - return feed.copy( + private fun applyPendingLikeState(feed: FeedEntity): FeedEntity = + synchronized(likeStateLock) { + val localIsLiked = pendingLikeStates[feed.id] ?: return@synchronized feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) + if (feed.isLiked == localIsLiked) return@synchronized feed + feed.copy( isLiked = localIsLiked, - likeCount = adjustedCount.coerceAtLeast(0), + likeCount = (feed.likeCount + if (localIsLiked) 1 else -1).coerceAtLeast(0), ) } - return feed + + private fun updateCachedLikes(likes: Map) { + listOf(_sosoAllFeeds, _sosoRecommendedFeeds, _myFeeds).forEach { flow -> + flow.update { feeds -> + feeds.map { feed -> + val state = likes[feed.id] + if (state == null) feed else feed.copy(isLiked = state.isLiked, likeCount = state.likeCount) + } + } + } + _feedDetailLikeStates.update { states -> + states.mapValues { (id, state) -> likes[id] ?: state } + } } private fun applyPendingLikeStatesToCachedFeeds() { @@ -257,6 +304,16 @@ class UpdatedFeedRepository _sosoAllFeeds.update(updateAction) _sosoRecommendedFeeds.update(updateAction) _myFeeds.update(updateAction) + _feedDetailLikeStates.update { states -> + states.mapValues { (id, state) -> + val desired = pendingLikeStates[id] + if (desired == null || desired == state.isLiked) { + state + } else { + state.copy(isLiked = desired, likeCount = (state.likeCount + if (desired) 1 else -1).coerceAtLeast(0)) + } + } + } } // ============================================================================================ @@ -267,47 +324,48 @@ class UpdatedFeedRepository * 로컬 캐시의 좋아요 상태를 즉시 토글하고 변경 내역을 기록합니다. */ fun toggleLikeLocal(feedId: Long) { - updateFeedInFlow(_sosoAllFeeds, feedId) - updateFeedInFlow(_sosoRecommendedFeeds, feedId) - updateFeedInFlow(_myFeeds, feedId) - - if (findCachedFeed(feedId) == null) { - updateFeedDetailState(feedId) - } - } - - private fun updateFeedInFlow( - flow: MutableStateFlow>, - feedId: Long, - ) { - flow.update { list -> - val index = list.indexOfFirst { it.id == feedId } - if (index == -1) return@update list - - val target = list[index] - val newLiked = !target.isLiked - val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 - - trackPendingLikeState(feedId, target.isLiked, newLiked) - - val newList = list.toMutableList() - newList[index] = target.copy(isLiked = newLiked, likeCount = newCount) - newList + synchronized(likeStateLock) { + val current = findCurrentLikeState(feedId) ?: return + val newLiked = !current + // Record the user's input once, even when the same feed is cached in several lists. + likeRevisions[feedId] = ++likeEventSequence + trackPendingLikeState(feedId, current, newLiked) + val update: (List) -> List = { feeds -> + feeds.map { feed -> + if (feed.id != feedId || feed.isLiked == newLiked) { + feed + } else { + feed.copy( + isLiked = newLiked, + likeCount = (feed.likeCount + if (newLiked) 1 else -1).coerceAtLeast(0), + ) + } + } + } + _sosoAllFeeds.update(update) + _sosoRecommendedFeeds.update(update) + _myFeeds.update(update) + _feedDetailLikeStates.value[feedId]?.let { detail -> + _feedDetailLikeStates.update { states -> + states + ( + feedId to detail.copy( + isLiked = newLiked, + likeCount = ( + detail.likeCount + if (detail.isLiked == newLiked) { + 0 + } else if (newLiked) { + 1 + } else { + -1 + } + ).coerceAtLeast(0), + ) + ) + } + } } } - private fun updateFeedDetailState(feedId: Long) { - val target = feedDetailStates[feedId] ?: return - val newLiked = !target.isLiked - val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 - - trackPendingLikeState(feedId, target.isLiked, newLiked) - feedDetailStates[feedId] = CachedFeedLikeState( - isLiked = newLiked, - likeCount = newCount.coerceAtLeast(0), - ) - } - private fun findCachedFeed(feedId: Long): FeedEntity? = _sosoAllFeeds.value.find { it.id == feedId } ?: _sosoRecommendedFeeds.value.find { it.id == feedId } @@ -322,109 +380,136 @@ class UpdatedFeedRepository new: Boolean, ) { originalLikeStates.putIfAbsent(feedId, original) - if (originalLikeStates[feedId] == new) { + if (feedId !in unconfirmedLikeIds && originalLikeStates[feedId] == new) { pendingLikeStates.remove(feedId) - deletePendingLike(feedId) + originalLikeStates.remove(feedId) + setLikeSyncStatus(feedId, null) } else { pendingLikeStates[feedId] = new - updatePendingLike(feedId, new) } - } - - private fun updatePendingLike( - feedId: Long, - isLiked: Boolean, - ) { - writePendingLikeStore(feedId, "save") { - pendingFeedLikeStore.updatePendingLike(feedId, isLiked) + scope.launch { + likesRestored.await() + try { + persistPendingLike(feedId) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + synchronized(likeStateLock) { + if (pendingLikeStates.containsKey(feedId)) setLikeSyncStatus(feedId, LikeSyncStatus.NEEDS_RETRY) + } + Log.e("UpdatedFeedRepository", "Failed to save pending feed like $feedId", error) + } } } - private fun deletePendingLike(feedId: Long) { - writePendingLikeStore(feedId, "delete") { - pendingFeedLikeStore.deletePendingLike(feedId) + // Read the latest selection when the write runs; a delayed write must not resurrect an old selection. + private suspend fun persistPendingLike(feedId: Long) { + pendingLikeStoreWriteMutex.withLock { + val latest = synchronized(likeStateLock) { pendingLikeStates[feedId] } + if (latest == null) { + pendingFeedLikeStore.deletePendingLike(feedId) + } else { + pendingFeedLikeStore.updatePendingLike(feedId, latest) + } } } - private fun writePendingLikeStore( - feedId: Long, - actionName: String, - action: suspend () -> Unit, - ) { - scope.launch { - pendingLikeStoreWriteMutex.withLock { - runCatching { - action() - }.onFailure { - Log.e( - "UpdatedFeedRepository", - "Failed to $actionName pending feed like $feedId", - it, - ) - } - } + /** Screen disposal sends all remaining selections, including earlier failures. */ + fun syncPendingLikes() = syncPendingLikes(null) + + /** A manual retry targets only unresolved items, using their latest selection. */ + fun retryPendingLikes(feedIds: Set? = null) { + val targets = synchronized(likeStateLock) { + _likeSyncStates.value + .filter { (id, status) -> status == LikeSyncStatus.NEEDS_RETRY && (feedIds == null || id in feedIds) } + .keys + .toSet() } + if (targets.isNotEmpty()) syncPendingLikes(targets) } - /** - * 서버에 아직 반영되지 않은 좋아요 상태들을 동기화합니다. - */ - fun syncPendingLikes() { + private fun syncPendingLikes(feedIds: Set?) { + val requestedAt = synchronized(likeStateLock) { ++likeEventSequence } scope.launch { - val syncMap = pendingFeedLikeStore.getPendingLikes() + pendingLikeStates.toMap() - if (syncMap.isEmpty()) return@launch - - syncMap.forEach { (id, isLiked) -> - runCatching { - if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) - }.onSuccess { - handleSyncedPendingLike(id, isLiked) - }.onFailure { - Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) + likesRestored.await() + likeSyncMutex.withLock { + val targets = synchronized(likeStateLock) { + pendingLikeStates.keys.filter { feedIds == null || it in feedIds } } + targets.forEach { id -> syncPendingLike(id, requestedAt) } } } } - private suspend fun handleSyncedPendingLike( + private suspend fun syncPendingLike( feedId: Long, - syncedIsLiked: Boolean, + requestedAt: Long, ) { - val currentIsLiked = findCurrentLikeState(feedId) - if (currentIsLiked != null && currentIsLiked != syncedIsLiked) { - originalLikeStates[feedId] = syncedIsLiked - pendingLikeStates[feedId] = currentIsLiked - updatePendingLike(feedId, currentIsLiked) - return + val selection = synchronized(likeStateLock) { + val desired = pendingLikeStates[feedId] ?: return + val revision = likeRevisions[feedId] ?: 0L + val completed = completedLikeAttempts[feedId] + // Coalesce triggers that overlapped the same attempt, including a failed attempt. + if (completed != null && completed.first == revision && completed.second > requestedAt) return + val needsNetwork = feedId in unconfirmedLikeIds || originalLikeStates[feedId] != desired + if (needsNetwork) unconfirmedLikeIds += feedId + setLikeSyncStatus(feedId, LikeSyncStatus.SYNCING) + LikeSyncRequest(desired, revision, needsNetwork) } - - deleteSyncedPendingLikeFromStore(feedId, syncedIsLiked) - deleteSyncedPendingLikeFromMemory(feedId, syncedIsLiked) - } - - private fun findCurrentLikeState(feedId: Long): Boolean? = findCachedFeed(feedId)?.isLiked ?: feedDetailStates[feedId]?.isLiked - - private fun deleteSyncedPendingLikeFromMemory( - feedId: Long, - isLiked: Boolean, - ) { - pendingLikeStates.remove(feedId, isLiked) - if (!pendingLikeStates.containsKey(feedId)) { - originalLikeStates.remove(feedId) + var serverConfirmed = !selection.needsNetwork + try { + persistPendingLike(feedId) + if (selection.needsNetwork) { + if (selection.isLiked) feedApi.postLikes(feedId) else feedApi.deleteLikes(feedId) + serverConfirmed = true + } + synchronized(likeStateLock) { + originalLikeStates[feedId] = selection.isLiked + unconfirmedLikeIds.remove(feedId) + if (pendingLikeStates[feedId] == selection.isLiked) { + pendingLikeStates.remove(feedId) + } + // An older success does not confirm an input made while the request was in flight. + setLikeSyncStatus(feedId, if (pendingLikeStates.containsKey(feedId)) LikeSyncStatus.NEEDS_RETRY else null) + } + persistPendingLike(feedId) + synchronized(likeStateLock) { + if (!pendingLikeStates.containsKey(feedId)) originalLikeStates.remove(feedId) + } + } catch (error: Exception) { + synchronized(likeStateLock) { + // Even cancellation/timeout says nothing about whether the server applied the request. + if (!serverConfirmed) unconfirmedLikeIds += feedId + pendingLikeStates.putIfAbsent(feedId, findCurrentLikeState(feedId) ?: selection.isLiked) + setLikeSyncStatus(feedId, LikeSyncStatus.NEEDS_RETRY) + } + if (error is CancellationException) throw error + Log.e("UpdatedFeedRepository", "Failed to sync feed $feedId", error) + } finally { + synchronized(likeStateLock) { + completedLikeAttempts[feedId] = selection.revision to ++likeEventSequence + } } } - private suspend fun deleteSyncedPendingLikeFromStore( + private fun setLikeSyncStatus( feedId: Long, - isLiked: Boolean, + status: LikeSyncStatus?, ) { - runCatching { - pendingFeedLikeStore.deletePendingLikeIfMatched(feedId, isLiked) - }.onFailure { - Log.e("UpdatedFeedRepository", "Failed to delete synced feed like $feedId", it) + _likeSyncStates.update { states -> + if (status == null) states - feedId else states + (feedId to status) } } + private data class LikeSyncRequest( + val isLiked: Boolean, + val revision: Long, + val needsNetwork: Boolean, + ) + + private fun findCurrentLikeState(feedId: Long): Boolean? = + findCachedFeed(feedId)?.isLiked ?: _feedDetailLikeStates.value[feedId]?.isLiked + // ============================================================================================ // Feed Actions (Remove, Report) // ============================================================================================ @@ -493,27 +578,29 @@ class UpdatedFeedRepository */ suspend fun fetchFeed(feedId: Long): FeedDetailEntity { val rawDetail = feedApi.getFeed(feedId).toData() - val mergedDetail = applyPendingLikeStateToDetail(rawDetail) - feedDetailStates[feedId] = CachedFeedLikeState( - isLiked = mergedDetail.isLiked, - likeCount = mergedDetail.likeCount, - ) - return mergedDetail + return synchronized(likeStateLock) { + val mergedDetail = applyPendingLikeStateToDetail(rawDetail) + val likeState = CachedFeedLikeState(mergedDetail.isLiked, mergedDetail.likeCount) + _feedDetailLikeStates.update { it + (feedId to likeState) } + updateCachedLikes(mapOf(feedId to likeState)) + mergedDetail + } } - private fun applyPendingLikeStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { - val localIsLiked = pendingLikeStates[feed.id] ?: return feed - originalLikeStates.putIfAbsent(feed.id, feed.isLiked) + private fun applyPendingLikeStateToDetail(feed: FeedDetailEntity): FeedDetailEntity = + synchronized(likeStateLock) { + val localIsLiked = pendingLikeStates[feed.id] ?: return@synchronized feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) - if (feed.isLiked != localIsLiked) { - val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 - return feed.copy( - isLiked = localIsLiked, - likeCount = adjustedCount.coerceAtLeast(0), - ) + if (feed.isLiked != localIsLiked) { + val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 + return@synchronized feed.copy( + isLiked = localIsLiked, + likeCount = adjustedCount.coerceAtLeast(0), + ) + } + return@synchronized feed } - return feed - } /** * 댓글 목록을 조회합니다. diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt index 5864dd34e..6083bc6b2 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt @@ -1,6 +1,6 @@ package com.into.websoso.data.feed.repository.model -internal data class CachedFeedLikeState( +data class CachedFeedLikeState( val isLiked: Boolean, val likeCount: Int, ) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/LikeSyncStatus.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/LikeSyncStatus.kt new file mode 100644 index 000000000..7da390664 --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/LikeSyncStatus.kt @@ -0,0 +1,6 @@ +package com.into.websoso.data.feed.repository.model + +enum class LikeSyncStatus { + SYNCING, + NEEDS_RETRY, +} diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt new file mode 100644 index 000000000..dfbf840fd --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt @@ -0,0 +1,277 @@ +package com.into.websoso.data.feed.repository + +import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailResponseDto +import com.into.websoso.data.feed.repository.model.LikeSyncStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UpdatedFeedRepositoryLikeStateTest { + @Test + fun `실패 항목을 재조회해도 안내와 최신 선택이 유지된다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val repository = createRepository(api, FakePendingFeedLikeStore()) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + assertTrue( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + + @Test + fun `복원된 선택은 확인 필요로 표시하고 취소도 보존한다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore(mapOf(1L to true)) + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + repository.retryPendingLikes(setOf(1L)) + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `복원보다 먼저 발생한 클릭과 취소를 오래된 저장 기록으로 덮지 않는다`() = + runTest { + val store = FakePendingFeedLikeStore(mapOf(1L to true)) + val repository = createRepository(server(), store) + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertFalse( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + + @Test + fun `여러 목록에 같은 피드가 있어도 한 클릭을 한 선택으로 기록한다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.fetchFeeds(0L, 10, "RECOMMENDED") + repository.updateMyFeedsCache(repository.sosoAllFeeds.value, true) + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertTrue( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertTrue( + repository.sosoRecommendedFeeds.value + .single() + .isLiked, + ) + assertTrue( + repository.myFeeds.value + .single() + .isLiked, + ) + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(store.currentPendingLikes().isEmpty()) + } + + @Test + fun `목록 없이 상세만 열어도 클릭과 실패 뒤 취소가 상세 상태에 반영된다`() = + runTest { + val api = server() + api.feedDetailResponses[1L] = detailResponse() + api.planLikeRequest("POST", 1L, LikeOutcome.TIMEOUT_AFTER_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeed(1L) + repository.toggleLikeLocal(1L) + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + assertEquals( + 1, + repository.feedDetailLikeStates.value + .getValue(1L) + .likeCount, + ) + repository.syncPendingLikes() + advanceUntilIdle() + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertFalse( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + assertEquals( + 0, + repository.feedDetailLikeStates.value + .getValue(1L) + .likeCount, + ) + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + repository.retryPendingLikes(setOf(1L)) + advanceUntilIdle() + assertFalse(api.getFeed(1L).isLiked) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `상세 조회 뒤 늦게 복원된 선택도 상세 화면이 관찰하는 상태에 반영된다`() = + runTest { + val api = server() + api.feedDetailResponses[1L] = detailResponse() + val repository = createRepository(api, FakePendingFeedLikeStore(mapOf(1L to true))) + repository.fetchFeed(1L) + assertFalse( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + advanceUntilIdle() + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + assertEquals( + 1, + repository.feedDetailLikeStates.value + .getValue(1L) + .likeCount, + ) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + + @Test + fun `전송 전 취소를 끝낸 뒤에는 새로 조회한 서버 값을 다음 선택의 기준으로 삼는다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.toggleLikeLocal(1L) + advanceUntilIdle() + api.feedsResponse = feedsResponseOf(feedResponse(1L, true, 1)) + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + } + + @Test + fun `다른 목록에서 갱신된 좋아요를 모든 캐시에 반영하고 그 값을 기준으로 취소한다`() = + runTest { + val api = server() + api.feedDetailResponses[1L] = detailResponse() + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.updateMyFeedsCache(repository.sosoAllFeeds.value, true) + repository.fetchFeed(1L) + + api.feedsResponse = feedsResponseOf(feedResponse(1L, true, 1)) + repository.fetchFeeds(0L, 10, "RECOMMENDED") + listOf(repository.sosoAllFeeds, repository.sosoRecommendedFeeds, repository.myFeeds).forEach { feeds -> + assertTrue(feeds.value.single().isLiked) + assertEquals(1, feeds.value.single().likeCount) + } + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertTrue(api.postLikesCalls.isEmpty()) + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertTrue(store.currentPendingLikes().isEmpty()) + listOf(repository.sosoAllFeeds, repository.sosoRecommendedFeeds, repository.myFeeds).forEach { feeds -> + assertFalse(feeds.value.single().isLiked) + assertEquals(0, feeds.value.single().likeCount) + } + assertFalse( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + } + + private fun detailResponse() = + FeedDetailResponseDto( + userId = 1L, + feedId = 1L, + nickname = "nickname", + avatarImage = "avatar", + createdDate = "2026-01-01", + feedContent = "feed", + likeCount = 0, + isLiked = false, + commentCount = 0, + novelId = null, + title = null, + novelRating = null, + novelRatingCount = null, + isSpoiler = false, + isModified = false, + isMyFeed = false, + isPublic = true, + images = emptyList(), + novelThumbnailImage = null, + novelGenre = null, + novelAuthor = null, + feedWriterNovelRating = null, + novelDescription = null, + ) + + private fun server() = + FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(1L, false, 0)) + } +} diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt new file mode 100644 index 000000000..d53387330 --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt @@ -0,0 +1,366 @@ +package com.into.websoso.data.feed.repository + +import com.into.websoso.core.network.datasource.feed.FeedApi +import com.into.websoso.data.feed.repository.model.LikeSyncStatus +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class UpdatedFeedRepositoryRecoveryTest { + @Test + fun `서버 반영 후 타임아웃과 취소에도 마지막 선택을 보존하고 수동 재시도로 복구한다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.TIMEOUT_AFTER_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertTrue(repository.likeSyncStates.value.isEmpty()) + repository.syncPendingLikes() + advanceUntilIdle() + assertTrue( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertFalse( + repository + .fetchFeeds(0L, 10, "ALL") + .feeds + .single() + .isLiked, + ) + assertEquals( + 0, + repository.sosoAllFeeds.value + .single() + .likeCount, + ) + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertTrue(store.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + } + + @Test + fun `응답 대기 중 취소한 뒤 타임아웃이 와도 취소 기록이 남는다`() = + runTest { + val api = server() + val response = CompletableDeferred() + api.planLikeRequest("POST", 1L, LikeOutcome.TIMEOUT_AFTER_APPLYING, responseGate = response) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + runCurrent() + assertEquals(LikeSyncStatus.SYNCING, repository.likeSyncStates.value[1L]) + repository.toggleLikeLocal(1L) + runCurrent() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + response.complete(Unit) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + } + + @Test + fun `타임아웃 뒤 여러 번 눌러도 처음 상태와 같다는 이유로 기록을 지우지 않는다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.TIMEOUT_AFTER_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + listOf(false, true, false).forEach { expected -> + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to expected), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + } + + @Test + fun `중복 동기화와 취소가 겹쳐도 POST 하나가 끝난 뒤 최신 DELETE만 보낸다`() = + runTest { + val api = server() + val postResponse = CompletableDeferred() + val deleteResponse = CompletableDeferred() + api.planLikeRequest("POST", 1L, responseGate = postResponse) + api.planLikeRequest("DELETE", 1L, responseGate = deleteResponse) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + runCurrent() + repository.syncPendingLikes() + runCurrent() + assertEquals(listOf(1L), api.postLikesCalls) + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + runCurrent() + assertTrue(api.deleteLikesCalls.isEmpty()) + postResponse.complete(Unit) + runCurrent() + assertEquals(listOf(1L), api.postLikesCalls) + assertEquals(listOf(1L), api.deleteLikesCalls) + assertFalse( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + deleteResponse.complete(Unit) + advanceUntilIdle() + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertEquals( + 0, + api.feedsResponse.feeds + .single() + .likeCount, + ) + assertTrue(store.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + } + + @Test + fun `실패하는 요청에 동기화 호출이 겹쳐도 즉시 재시도하지 않는다`() = + runTest { + val api = server() + val response = CompletableDeferred() + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING, responseGate = response) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + runCurrent() + repository.syncPendingLikes() + runCurrent() + response.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertEquals(mapOf(1L to true), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L, 1L), api.postLikesCalls) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `실패 항목 수동 재시도와 화면 종료가 겹쳐도 한 번만 전송한다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + val response = CompletableDeferred() + api.planLikeRequest("POST", 1L, responseGate = response) + repository.retryPendingLikes() + runCurrent() + repository.retryPendingLikes() + repository.syncPendingLikes() + runCurrent() + assertEquals(listOf(1L, 1L), api.postLikesCalls) + response.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(1L, 1L), api.postLikesCalls) + assertTrue(store.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `수동 재시도는 실패 항목만 보내고 화면 종료는 새 선택도 보낸다`() = + runTest { + val api = server() + api.feedsResponse = feedsResponseOf(feedResponse(1L, false, 0), feedResponse(2L, false, 0)) + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + repository.toggleLikeLocal(2L) + advanceUntilIdle() + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L, 1L), api.postLikesCalls) + assertEquals(mapOf(2L to true), store.currentPendingLikes()) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L, 1L, 2L), api.postLikesCalls) + assertTrue(api.feedsResponse.feeds.all { it.isLiked && it.likeCount == 1 }) + } + + @Test + fun `이전 재시도가 성공해도 그동안 바뀐 최신 선택의 안내를 지우지 않는다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + val response = CompletableDeferred() + api.planLikeRequest("POST", 1L, responseGate = response) + repository.retryPendingLikes() + runCurrent() + repository.toggleLikeLocal(1L) + response.complete(Unit) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + assertTrue(api.deleteLikesCalls.isEmpty()) + repository.retryPendingLikes() + advanceUntilIdle() + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `요청 취소도 결과 미확정으로 남겨 이후 취소 선택을 보존한다`() = + runTest { + val api = object : FeedApi by server() { + override suspend fun postLikes(feedId: Long): Unit = throw CancellationException("cancelled response") + } + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + + @Test + fun `저장에 실패하면 서버로 보내지 않고 안내를 남겨 다시 시도할 수 있다`() = + runTest { + val api = server() + val backing = FakePendingFeedLikeStore() + var failSave = true + val store = object : PendingFeedLikeStore by backing { + override suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + if (failSave) throw IOException("test storage unavailable") + backing.updatePendingLike(feedId, isLiked) + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertTrue(api.postLikesCalls.isEmpty()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + failSave = false + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `성공 후 저장 기록 삭제만 실패하면 재시도는 서버 요청을 반복하지 않는다`() = + runTest { + val api = server() + val backing = FakePendingFeedLikeStore() + var failDelete = true + val store = object : PendingFeedLikeStore by backing { + override suspend fun deletePendingLike(feedId: Long) { + if (failDelete) throw IOException("test storage unavailable") + backing.deletePendingLike(feedId) + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + failDelete = false + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(backing.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + private fun server() = + FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(1L, false, 0)) + } +} diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt index 859f3efe8..f3356bfa3 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt @@ -160,208 +160,6 @@ class UpdatedFeedRepositoryTest { assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(3L)) } - @Test - fun `현재 결함 기록 - 서버 반영 후 타임아웃이 나면 이후 취소 기록이 사라지고 재조회 시 좋아요가 켜진다`() = - runTest { - // 승인된 재현 예상값을 기록한다. 결함을 허용하는 제품 요구사항이나 수정 완료 테스트가 아니다. - val feedApi = FakeFeedApi().apply { - feedsResponse = - feedsResponseOf(feedResponse(feedId = 8L, isLiked = false, likeCount = 0)) - } - val post = feedApi.planLikeRequest("POST", 8L, outcome = LikeOutcome.TIMEOUT_AFTER_APPLYING) - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(8L) - advanceUntilIdle() - repository.syncPendingLikes() - advanceUntilIdle() - - // 서버는 반영했지만 앱은 성공 응답을 받지 못한 상태다. - assertEquals(listOf(8L), feedApi.postLikesCalls) - assertTrue(feedApi.postLikesCompleted.isEmpty()) - assertEquals(listOf(post), feedApi.appliedLikeRequests) - assertEquals(listOf(post), feedApi.respondedLikeRequests) - assertTrue( - feedApi.feedsResponse.feeds - .single() - .isLiked, - ) - assertTrue( - repository.sosoAllFeeds.value - .feed(8L) - .isLiked, - ) - assertEquals(mapOf(8L to true), pendingStore.currentPendingLikes()) - - repository.toggleLikeLocal(8L) - advanceUntilIdle() - val cancelled = repository.sosoAllFeeds.value.feed(8L) - assertFalse(cancelled.isLiked) - assertEquals(0, cancelled.likeCount) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - - // 저장소가 비었고 재동기화에서도 요청이 없으므로 메모리에도 취소 pending이 남지 않았다. - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(8L), feedApi.postLikesCalls) - assertTrue(feedApi.deleteLikesCalls.isEmpty()) - assertFalse( - repository.sosoAllFeeds.value - .feed(8L) - .isLiked, - ) - val serverAfterSync = feedApi.feedsResponse.feeds.single() - assertTrue(serverAfterSync.isLiked) - assertEquals(1, serverAfterSync.likeCount) - - val refreshed = - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(8L) - assertTrue(refreshed.isLiked) - assertEquals(1, refreshed.likeCount) - assertTrue( - repository.sosoAllFeeds.value - .feed(8L) - .isLiked, - ) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - } - - @Test - fun `현재 결함 기록 - 중복 POST와 취소가 겹치면 서버 처리 순서와 다른 응답 순서가 취소 기록을 지운다`() = - runTest { - // 재현 예상: 서버 B-C-A, 앱 응답 B-A-C이면 서버 true / 화면 false / pending 없음이 된다. - val feedApi = FakeFeedApi().apply { - feedsResponse = - feedsResponseOf(feedResponse(feedId = 9L, isLiked = false, likeCount = 0)) - } - val processA = CompletableDeferred() - val respondC = CompletableDeferred() - val postA = feedApi.planLikeRequest("POST", 9L, processingGate = processA) - val postB = feedApi.planLikeRequest("POST", 9L) - val deleteC = feedApi.planLikeRequest("DELETE", 9L, responseGate = respondC) - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(9L) - advanceUntilIdle() - repository.syncPendingLikes() // A: 서버 처리부터 보류한다. - advanceUntilIdle() - assertEquals(listOf(9L), feedApi.postLikesCalls) - assertTrue(feedApi.appliedLikeRequests.isEmpty()) - - repository.syncPendingLikes() // B: 먼저 서버 반영 및 성공 응답을 받는다. - advanceUntilIdle() - assertEquals(listOf(9L, 9L), feedApi.postLikesCalls) - assertEquals(listOf(postB), feedApi.appliedLikeRequests) - assertEquals(listOf(postB), feedApi.respondedLikeRequests) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - assertTrue( - repository.sosoAllFeeds.value - .feed(9L) - .isLiked, - ) - assertTrue( - feedApi.feedsResponse.feeds - .single() - .isLiked, - ) - - repository.toggleLikeLocal(9L) - advanceUntilIdle() - repository.syncPendingLikes() // C: 서버에는 취소를 반영하지만 응답은 보류한다. - advanceUntilIdle() - assertEquals(listOf(9L), feedApi.deleteLikesCalls) - assertEquals(listOf(postB, deleteC), feedApi.appliedLikeRequests) - assertEquals(listOf(postB), feedApi.respondedLikeRequests) - assertFalse( - feedApi.feedsResponse.feeds - .single() - .isLiked, - ) - assertFalse( - repository.sosoAllFeeds.value - .feed(9L) - .isLiked, - ) - assertEquals(mapOf(9L to false), pendingStore.currentPendingLikes()) - - processA.complete(Unit) // A: C보다 늦게 서버 반영하고, C보다 먼저 앱에 응답한다. - advanceUntilIdle() - assertEquals(listOf(postB, deleteC, postA), feedApi.appliedLikeRequests) - assertEquals(listOf(postB, postA), feedApi.respondedLikeRequests) - assertTrue( - feedApi.feedsResponse.feeds - .single() - .isLiked, - ) - assertFalse( - repository.sosoAllFeeds.value - .feed(9L) - .isLiked, - ) - assertEquals(mapOf(9L to false), pendingStore.currentPendingLikes()) - - // 서버 true가 메모리 pending false로 덮이는지, 저장소 재전송과 별개로 확인한다. - val beforeC = - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(9L) - assertFalse(beforeC.isLiked) - assertEquals(0, beforeC.likeCount) - - respondC.complete(Unit) - advanceUntilIdle() - assertEquals(listOf(postB, postA, deleteC), feedApi.respondedLikeRequests) - assertFalse( - repository.sosoAllFeeds.value - .feed(9L) - .isLiked, - ) - assertEquals( - 0, - repository.sosoAllFeeds.value - .feed(9L) - .likeCount, - ) - assertTrue( - feedApi.feedsResponse.feeds - .single() - .isLiked, - ) - assertEquals( - 1, - feedApi.feedsResponse.feeds - .single() - .likeCount, - ) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - - // 저장소가 비어 있고 재동기화에도 추가 요청이 없어 메모리 pending도 없음을 확인한다. - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(9L, 9L), feedApi.postLikesCalls) - assertEquals(listOf(9L), feedApi.deleteLikesCalls) - assertFalse( - repository.sosoAllFeeds.value - .feed(9L) - .isLiked, - ) - assertTrue( - feedApi.feedsResponse.feeds - .single() - .isLiked, - ) - - val refreshed = - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(9L) - assertTrue(refreshed.isLiked) - assertEquals(1, refreshed.likeCount) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) - } - @Test fun `두 건을 동기화할 때 하나만 실패하면 성공한 건만 정리되고 실패한 건만 남는다`() = runTest { @@ -455,52 +253,6 @@ class UpdatedFeedRepositoryTest { assertEquals(listOf(6L), feedApi.deleteLikesCalls) // 메모리도 비어 추가 요청이 없다 } - @Test - fun `같은 방향 POST 두 개의 응답 순서가 바뀌어도 좋아요 상태와 개수가 유지된다`() = - runTest { - val feedApi = FakeFeedApi().apply { - feedsResponse = - feedsResponseOf(feedResponse(feedId = 7L, isLiked = false, likeCount = 0)) - } - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) - advanceUntilIdle() - - repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") - repository.toggleLikeLocal(7L) - advanceUntilIdle() - - val firstPostGate = CompletableDeferred() - val firstPost = feedApi.planLikeRequest("POST", 7L, responseGate = firstPostGate) - val secondPost = feedApi.planLikeRequest("POST", 7L) - repository.syncPendingLikes() // 첫 번째 동기화: 요청은 서버로 보냈지만 응답은 보류된다 - advanceUntilIdle() - assertEquals(listOf(7L), feedApi.postLikesCalls) // 서버가 받은 순서 1번째 - - repository.syncPendingLikes() // 두 번째 동기화(겹침): pending이 아직 남아 있어 다시 요청한다 - advanceUntilIdle() - // 두 번째 요청은 보류 없이 즉시 응답한다 -> 응답은 두 번째 요청이 먼저 도착한다 - assertEquals(listOf(7L, 7L), feedApi.postLikesCalls) // 서버가 받은 순서: 1번, 2번 - assertEquals(listOf(7L), feedApi.postLikesCompleted) // 앱이 응답을 받은 순서: 2번이 먼저 끝남 - assertTrue(pendingStore.currentPendingLikes().isEmpty()) // 두 번째 요청 처리로 pending이 정리됨 - - firstPostGate.complete(Unit) // 첫 번째 요청의 응답이 뒤늦게 도착 - advanceUntilIdle() - assertEquals(listOf(7L, 7L), feedApi.postLikesCompleted) - assertEquals(listOf(firstPost, secondPost), feedApi.appliedLikeRequests) - assertEquals(listOf(secondPost, firstPost), feedApi.respondedLikeRequests) - - val finalFeed = repository.sosoAllFeeds.value.feed(7L) - assertTrue(finalFeed.isLiked) // 화면의 마지막 선택: 손실 없이 좋아요 상태 유지 - assertEquals(1, finalFeed.likeCount) - assertServerMatches(feedApi, finalFeed) - assertTrue(pendingStore.currentPendingLikes().isEmpty()) // 뒤늦은 응답이 지울 대상이 없어도 안전하게 무시된다 - - repository.syncPendingLikes() - advanceUntilIdle() - assertEquals(listOf(7L, 7L), feedApi.postLikesCalls) // 메모리도 비어 추가 요청이 없다 - } - @Test fun `복원이 끝난 뒤 목록을 조회하면 복원된 pending이 즉시 반영된다`() = runTest { From 6fca46f8652dcbcf788333049ef7892cef27cb88 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 10:35:06 +0900 Subject: [PATCH 30/49] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `FeedLikeSyncNotice.kt`: - 좋아요 동기화 중이거나 실패한 경우 표시되는 알림 바 컴포저블 추가 - `LiveRegionMode.Polite`를 적용하여 접근성 지원 및 재시도 버튼 연결 - `FeedSection.kt`: - 각 피드 아이템 내 개별 좋아요 동기화 상태(`확인 필요`, `좋아요 반영 중`) 표시 로직 추가 - `FeedViewModel.kt` & `FeedRoute.kt`: - `feedRepository`로부터 `likeSyncStates`를 관찰하여 UI에 전달 - 실패한 좋아요 요청을 다시 시도하는 `retryLikes` 함수 구현 - `FeedScreen.kt`: `FeedLikeSyncNotice`를 상단에 배치하고 동기화 상태에 따른 노출 로직 연결 - `strings.xml`: 좋아요 동기화 상태 및 재시도 관련 다국어 리소스 추가 --- core/resource/src/main/res/values/strings.xml | 4 ++ .../into/websoso/feature/feed/FeedRoute.kt | 3 ++ .../into/websoso/feature/feed/FeedScreen.kt | 11 +++++ .../websoso/feature/feed/FeedViewModel.kt | 3 ++ .../feed/component/FeedLikeSyncNotice.kt | 47 +++++++++++++++++++ .../feature/feed/component/FeedSection.kt | 19 ++++++++ 6 files changed, 87 insertions(+) create mode 100644 feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index f23681432..1b1266ae3 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -441,4 +441,8 @@ 서재 + 반영을 확인하지 못한 좋아요가 있어요. + 확인 필요 + 좋아요 반영 중 + 다시 시도 diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt index 85f5492b8..2d90598da 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt @@ -28,6 +28,7 @@ fun FeedRoute( ) { val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val likeSyncStates by viewModel.likeSyncStates.collectAsStateWithLifecycle() val bottomSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, confirmValueChange = { newValue -> @@ -38,6 +39,8 @@ fun FeedRoute( FeedScreen( uiState = uiState, + likeSyncStates = likeSyncStates, + onRetryLikes = viewModel::retryLikes, bottomSheetState = bottomSheetState, onTabSelected = viewModel::updateTab, onSortSelected = viewModel::updateMyFeedSort, diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt index 2dedcf288..8bd8bb253 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt @@ -37,7 +37,9 @@ import com.into.websoso.core.designsystem.theme.Gray80 import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.designsystem.theme.White import com.into.websoso.core.resource.R +import com.into.websoso.data.feed.repository.model.LikeSyncStatus import com.into.websoso.feature.feed.component.FeedFilterChip +import com.into.websoso.feature.feed.component.FeedLikeSyncNotice import com.into.websoso.feature.feed.component.FeedSection import com.into.websoso.feature.feed.component.MyFeedFilterModal import com.into.websoso.feature.feed.model.FeedOrder @@ -68,6 +70,8 @@ internal fun FeedScreen( onRefreshPull: () -> Unit, onWriteFeedClick: () -> Unit, onLoadMore: () -> Unit, + likeSyncStates: Map = emptyMap(), + onRetryLikes: () -> Unit = {}, ) { Scaffold(containerColor = White) { _ -> Column(modifier = Modifier.statusBarsPadding()) { @@ -77,6 +81,12 @@ internal fun FeedScreen( onWriteClick = onWriteClick, ) + FeedLikeSyncNotice( + needsRetry = likeSyncStates.values.any { it == LikeSyncStatus.NEEDS_RETRY }, + isSyncing = likeSyncStates.values.any { it == LikeSyncStatus.SYNCING }, + onRetry = onRetryLikes, + ) + Spacer(modifier = Modifier.height(height = 2.dp)) Row( @@ -159,6 +169,7 @@ internal fun FeedScreen( } FeedSection( + likeSyncStates = likeSyncStates, currentTab = uiState.selectedTab, feeds = when (uiState.selectedTab) { FeedTab.MY_FEED -> uiState.myFeedData.feeds diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt index 56a0add78..d8a53d17f 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt @@ -28,6 +28,9 @@ class FeedViewModel ) : ViewModel() { private val _uiState = MutableStateFlow(FeedUiState()) val uiState = _uiState.asStateFlow() + val likeSyncStates = feedRepository.likeSyncStates + + fun retryLikes() = feedRepository.retryPendingLikes() init { collectFeedRefreshEvent() diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt new file mode 100644 index 000000000..606fe99ec --- /dev/null +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt @@ -0,0 +1,47 @@ +package com.into.websoso.feature.feed.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.into.websoso.core.designsystem.theme.Black +import com.into.websoso.core.designsystem.theme.Gray50 +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R + +@Composable +fun FeedLikeSyncNotice( + needsRetry: Boolean, + isSyncing: Boolean, + onRetry: () -> Unit, +) { + if (!needsRetry && !isSyncing) return + Row( + modifier = Modifier + .fillMaxWidth() + .background(Gray50) + .padding(horizontal = 20.dp, vertical = 4.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(if (needsRetry) R.string.feed_like_unconfirmed_message else R.string.feed_like_syncing), + style = WebsosoTheme.typography.body4, + color = Black, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onRetry, enabled = needsRetry && !isSyncing) { + Text(text = stringResource(R.string.feed_like_retry)) + } + } +} diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt index 8f3911f80..0e57395d8 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -55,6 +56,7 @@ import com.into.websoso.core.resource.R import com.into.websoso.core.resource.R.drawable.ic_link import com.into.websoso.core.resource.R.drawable.ic_navigate_right import com.into.websoso.core.resource.R.drawable.ic_star +import com.into.websoso.data.feed.repository.model.LikeSyncStatus import com.into.websoso.feature.feed.model.FeedTab import com.into.websoso.feature.feed.model.FeedUiModel import kotlinx.collections.immutable.ImmutableList @@ -77,6 +79,7 @@ internal fun FeedSection( onLoadMore: () -> Unit, isLoading: Boolean, isRefreshing: Boolean, + likeSyncStates: Map = emptyMap(), ) { val view = LocalView.current PullToRefreshBox( @@ -124,6 +127,7 @@ internal fun FeedSection( itemsIndexed(items = feeds) { index, feed -> FeedItem( feed = feed, + likeSyncStatus = likeSyncStates[feed.id], currentTab = currentTab, onProfileClick = onProfileClick, onNovelClick = onNovelClick, @@ -157,6 +161,7 @@ internal fun FeedSection( @Composable private fun FeedItem( feed: FeedUiModel, + likeSyncStatus: LikeSyncStatus?, currentTab: FeedTab, onProfileClick: (userId: Long, feedTab: FeedTab) -> Unit, onNovelClick: (novelId: Long) -> Unit, @@ -384,6 +389,20 @@ private fun FeedItem( } } } + + if (likeSyncStatus != null) { + Text( + text = stringResource( + if (likeSyncStatus == LikeSyncStatus.NEEDS_RETRY) { + R.string.feed_like_needs_retry + } else { + R.string.feed_like_syncing + }, + ), + style = WebsosoTheme.typography.body4, + color = Black, + ) + } } } From d322b58fc98cb46a429abc16126cdf7c930b2e6e Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 10:36:17 +0900 Subject: [PATCH 31/49] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=91=9C=EC=8B=9C=20UI=20=EB=B0=8F=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `activity_feed_detail.xml`: - 좋아요 동기화 상태를 표시하기 위한 `ComposeView`(`cv_feed_like_sync`) 추가 - `RecyclerView`의 상단 제약 조건을 신규 추가된 `ComposeView` 하단으로 변경 - `FeedDetailActivity.kt`: - `ComposeView`에 `FeedLikeSyncNotice` 컴포넌트 연결 및 좋아요 동기화 상태(`likeSyncStates`) 관찰 로직 추가 - `FeedDetailClickListener` 내의 수동 UI 업데이트 및 중복 클릭 방지(`SingleEventHandler`) 로직을 제거하고, ViewModel을 통한 반응형 상태 업데이트 방식으로 변경 - `UpdatedFeedDetailViewModel.kt`: - Repository의 `likeSyncStates`를 노출하고, 실패한 좋아요 요청을 재시도하는 `retryLike` 함수 추가 - `feedDetailUiState` 수집 로직에 `feedDetailLikeStates`를 결합하여 최신 좋아요 상태(좋아요 여부 및 개수)가 UI에 즉시 반영되도록 개선 --- .../ui/feedDetail/FeedDetailActivity.kt | 49 +++++++++---------- .../feedDetail/UpdatedFeedDetailViewModel.kt | 23 ++++++--- .../main/res/layout/activity_feed_detail.xml | 10 +++- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt index a44b1f11c..4fd31f035 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt @@ -1,6 +1,5 @@ package com.into.websoso.ui.feedDetail -import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.os.Bundle @@ -10,16 +9,16 @@ import android.view.LayoutInflater import android.view.View import android.view.WindowManager.LayoutParams.WRAP_CONTENT import android.widget.PopupWindow -import android.widget.TextView import androidx.activity.addCallback import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.databinding.ViewDataBinding -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.load import coil.transform.RoundedCornersTransformation -import com.into.websoso.R.id.tv_feed_thumb_up_count import com.into.websoso.R.layout.activity_feed_detail import com.into.websoso.core.common.ui.base.BaseActivity import com.into.websoso.core.common.ui.model.ResultFrom.BlockUser @@ -30,12 +29,12 @@ import com.into.websoso.core.common.ui.model.ResultFrom.FeedDetailRefreshed import com.into.websoso.core.common.ui.model.ResultFrom.NovelDetailBack import com.into.websoso.core.common.ui.model.ResultFrom.OtherUserProfileBack import com.into.websoso.core.common.ui.model.ResultFrom.WithdrawUser -import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.core.common.util.getS3ImageUrl import com.into.websoso.core.common.util.hideKeyboard import com.into.websoso.core.common.util.showWebsosoSnackBar import com.into.websoso.core.common.util.toFloatPxFromDp import com.into.websoso.core.common.util.tracker.Tracker +import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.resource.R.drawable.ic_blocked_user_snack_bar import com.into.websoso.core.resource.R.drawable.ic_novel_detail_check import com.into.websoso.core.resource.R.string.block_user_success_message @@ -44,10 +43,12 @@ import com.into.websoso.core.resource.R.string.feed_popup_menu_content_report_is import com.into.websoso.core.resource.R.string.feed_removed_feed_snackbar import com.into.websoso.core.resource.R.string.feed_server_error import com.into.websoso.core.resource.R.string.other_user_page_withdraw_user +import com.into.websoso.data.feed.repository.model.LikeSyncStatus import com.into.websoso.databinding.ActivityFeedDetailBinding import com.into.websoso.databinding.DialogRemovePopupMenuBinding import com.into.websoso.databinding.DialogReportPopupMenuBinding import com.into.websoso.databinding.MenuFeedPopupBinding +import com.into.websoso.feature.feed.component.FeedLikeSyncNotice import com.into.websoso.ui.createFeed.CreateFeedActivity import com.into.websoso.ui.expandedFeedImage.ExpandedFeedImageActivity import com.into.websoso.ui.feedDetail.FeedDetailActivity.MenuType.COMMENT @@ -88,7 +89,6 @@ class FeedDetailActivity : BaseActivity(activity_feed onCommentClick(), ) } - private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } private lateinit var activityResultCallback: ActivityResultLauncher private val popupBinding: MenuFeedPopupBinding by lazy { MenuFeedPopupBinding.inflate(LayoutInflater.from(this)) @@ -99,30 +99,12 @@ class FeedDetailActivity : BaseActivity(activity_feed private fun onFeedContentClick(): FeedDetailClickListener = object : FeedDetailClickListener { - @SuppressLint("CutPasteId") override fun onLikeButtonClick( view: View, feedId: Long, ) { - val likeCount: Int = - view - .findViewById(tv_feed_thumb_up_count) - .text - .toString() - .toInt() - val updatedLikeCount: Int = when (view.isSelected) { - true -> if (likeCount > 0) likeCount - 1 else 0 - false -> likeCount + 1 - } - - view.findViewById(tv_feed_thumb_up_count).text = - updatedLikeCount.toString() - view.isSelected = !view.isSelected - - singleEventHandler.debounce(timeMillis = 100L, coroutineScope = lifecycleScope) { - tracker.trackEvent("feed_detail_like") - feedDetailViewModel.updateLike() - } + tracker.trackEvent("feed_detail_like") + feedDetailViewModel.updateLike() } override fun onNovelInfoClick(novelId: Long) { @@ -357,6 +339,19 @@ class FeedDetailActivity : BaseActivity(activity_feed super.onCreate(savedInstanceState) setupView() + binding.cvFeedLikeSync.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val states by feedDetailViewModel.likeSyncStates.collectAsStateWithLifecycle() + WebsosoTheme { + FeedLikeSyncNotice( + needsRetry = states[feedId] == LikeSyncStatus.NEEDS_RETRY, + isSyncing = states[feedId] == LikeSyncStatus.SYNCING, + onRetry = feedDetailViewModel::retryLike, + ) + } + } + } setupObserver() onFeedDetailClick() refreshView() @@ -371,7 +366,7 @@ class FeedDetailActivity : BaseActivity(activity_feed NovelDetailBack.RESULT_OK, CreateFeed.RESULT_OK, OtherUserProfileBack.RESULT_OK, - -> { + -> { feedDetailViewModel.updateFeedDetail(feedId, CreateFeed) } diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt index eeff5bb93..d27142918 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt @@ -38,6 +38,11 @@ class UpdatedFeedDetailViewModel private val _feedDetailUiState: MutableLiveData = MutableLiveData(FeedDetailUiState()) val feedDetailUiState: LiveData get() = _feedDetailUiState + val likeSyncStates = feedRepository.likeSyncStates + + fun retryLike() { + if (feedId != -1L) feedRepository.retryPendingLikes(setOf(feedId)) + } fun updateCommentId(newCommentId: Long) { this.commentId = newCommentId @@ -75,19 +80,25 @@ class UpdatedFeedDetailViewModel feedRepository.sosoAllFeeds, feedRepository.sosoRecommendedFeeds, feedRepository.myFeeds, - ) { sosoAllFeeds, sosoRecommendedFeeds, myFeeds -> - sosoAllFeeds.find { it.id == targetFeedId } + feedRepository.feedDetailLikeStates, + ) { sosoAllFeeds, sosoRecommendedFeeds, myFeeds, detailLikes -> + val feed = sosoAllFeeds.find { it.id == targetFeedId } ?: sosoRecommendedFeeds.find { it.id == targetFeedId } ?: myFeeds.find { it.id == targetFeedId } + feed to detailLikes[targetFeedId] }.distinctUntilChanged() - .collect { feedEntity -> - if (feedEntity != null) { - val currentUiState = _feedDetailUiState.value ?: FeedDetailUiState() + .collect { (feedEntity, likeState) -> + val currentUiState = _feedDetailUiState.value ?: FeedDetailUiState() + val feed = feedEntity?.toFeedModel() ?: currentUiState.feedDetail.feed + if (feed != null) { _feedDetailUiState.value = currentUiState.copy( loading = false, isRefreshed = true, feedDetail = currentUiState.feedDetail.copy( - feed = feedEntity.toFeedModel(), + feed = feed.copy( + isLiked = likeState?.isLiked ?: feed.isLiked, + likeCount = likeState?.likeCount ?: feed.likeCount, + ), novel = currentUiState.feedDetail.novel, ), ) diff --git a/app/src/main/res/layout/activity_feed_detail.xml b/app/src/main/res/layout/activity_feed_detail.xml index 785256a69..3ac6a71f7 100644 --- a/app/src/main/res/layout/activity_feed_detail.xml +++ b/app/src/main/res/layout/activity_feed_detail.xml @@ -56,6 +56,14 @@ + + + app:layout_constraintTop_toBottomOf="@id/cv_feed_like_sync" /> Date: Mon, 5 Oct 2026 21:14:32 +0900 Subject: [PATCH 32/49] =?UTF-8?q?refactor:=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=A4=91=20=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EB=85=B8=EC=B6=9C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `strings.xml`: "좋아요 반영 중"(`feed_like_syncing`) 문자열 리소스 제거 - `FeedSection.kt`: - 좋아요 동기화 중일 때 표시되던 텍스트 로직을 제거 - `LikeSyncStatus.NEEDS_RETRY` 상태일 때만 "확인 필요" 메시지가 노출되도록 수정 - `FeedLikeSyncNotice.kt`: - 단순 동기화 중(`isSyncing`)일 때는 알림 바를 노출하지 않고, 재시도가 필요한 경우(`needsRetry`)에만 노출되도록 변경 - 상단 알림 바의 텍스트를 "반영을 확인하지 못한 좋아요가 있어요"로 고정하고, 버튼 활성화 조건 정리 --- core/resource/src/main/res/values/strings.xml | 1 - .../feature/feed/component/FeedLikeSyncNotice.kt | 6 +++--- .../into/websoso/feature/feed/component/FeedSection.kt | 10 ++-------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 1b1266ae3..4e44e3721 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -443,6 +443,5 @@ 서재 반영을 확인하지 못한 좋아요가 있어요. 확인 필요 - 좋아요 반영 중 다시 시도 diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt index 606fe99ec..fdd1bf081 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt @@ -25,7 +25,7 @@ fun FeedLikeSyncNotice( isSyncing: Boolean, onRetry: () -> Unit, ) { - if (!needsRetry && !isSyncing) return + if (!needsRetry) return Row( modifier = Modifier .fillMaxWidth() @@ -35,12 +35,12 @@ fun FeedLikeSyncNotice( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(if (needsRetry) R.string.feed_like_unconfirmed_message else R.string.feed_like_syncing), + text = stringResource(R.string.feed_like_unconfirmed_message), style = WebsosoTheme.typography.body4, color = Black, modifier = Modifier.weight(1f), ) - TextButton(onClick = onRetry, enabled = needsRetry && !isSyncing) { + TextButton(onClick = onRetry, enabled = !isSyncing) { Text(text = stringResource(R.string.feed_like_retry)) } } diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt index 0e57395d8..94b91f593 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt @@ -390,15 +390,9 @@ private fun FeedItem( } } - if (likeSyncStatus != null) { + if (likeSyncStatus == LikeSyncStatus.NEEDS_RETRY) { Text( - text = stringResource( - if (likeSyncStatus == LikeSyncStatus.NEEDS_RETRY) { - R.string.feed_like_needs_retry - } else { - R.string.feed_like_syncing - }, - ), + text = stringResource(R.string.feed_like_needs_retry), style = WebsosoTheme.typography.body4, color = Black, ) From 8fb0988afe2bbfc10ae8f3edcd44a997a729b6b6 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 04:01:38 +0900 Subject: [PATCH 33/49] =?UTF-8?q?fix:=20=EC=A0=80=EC=9E=A5=EB=90=9C=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=B3=B5=EC=9B=90=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=ED=81=AC=EB=9E=98=EC=8B=9C=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EC=A0=80=EC=9E=A5=C2=B7=EC=A0=84=EC=86=A1=EC=9D=84?= =?UTF-8?q?=20=EB=B3=B4=EB=A5=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: - `CompletableDeferred` 대신 `Mutex`와 상태 플래그(`isLikesRestored`)를 사용하여 저장된 좋아요 데이터 복원 로직을 스레드 안전하게 관리 - `ensureLikesRestored`를 도입하여 복원 실패 시 예외를 던지는 대신 `false`를 반환하고, 저장 및 전송 작업을 보류하도록 개선 - 초기 복원 실패 시 `retryPendingLikes` 호출 시점에 복원을 재시도하고, 성공할 경우 중단되었던 좋아요 요청을 이어서 처리하도록 수정 - 복원 실패 상태를 추적하기 위한 `isLikeRestoreFailed` 상태 추가 및 로그 기록 보강 - `UpdatedFeedRepositoryRecoveryTest.kt`: - 저장소 접근 불가(IOException) 상황에서 예외 발생 없이 요청을 보류하는 시나리오 테스트 추가 - 저장소가 정상화된 후 `retryPendingLikes`를 통해 복원 및 전송이 정상적으로 재개되는지 검증하는 테스트 추가 --- .../feed/repository/UpdatedFeedRepository.kt | 68 ++++++++++++------- .../UpdatedFeedRepositoryRecoveryTest.kt | 54 +++++++++++++++ 2 files changed, 98 insertions(+), 24 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index e67978a49..4549a4c5b 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -19,7 +19,6 @@ import com.into.websoso.data.feed.repository.model.CachedFeedLikeState import com.into.websoso.data.feed.repository.model.LikeSyncStatus import com.into.websoso.data.feed.store.PendingFeedLikeStore import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -69,7 +68,9 @@ class UpdatedFeedRepository // ponytail: all feeds share one sender; use per-feed serialization if slow requests delay other feeds excessively. private val likeSyncMutex = Mutex() - private val likesRestored = CompletableDeferred() + private val restoreMutex = Mutex() + private var isLikesRestored = false + private val isLikeRestoreFailed = MutableStateFlow(false) private val unconfirmedLikeIds = mutableSetOf() private val likeRevisions = mutableMapOf() private val completedLikeAttempts = mutableMapOf>() @@ -78,31 +79,43 @@ class UpdatedFeedRepository val likeSyncStates = _likeSyncStates.asStateFlow() init { - restorePendingLikes() + scope.launch { ensureLikesRestored() } } - private fun restorePendingLikes() { - scope.launch { + /** + * 저장된 좋아요를 아직 불러오지 못했다면 다시 불러옵니다. + * 실패해도 예외를 던지지 않고 false를 반환해, 호출한 쪽이 저장·전송을 보류하게 합니다. + */ + private suspend fun ensureLikesRestored(): Boolean = + restoreMutex.withLock { + if (isLikesRestored) return@withLock true try { - val pendingLikes = pendingFeedLikeStore.getPendingLikes() - synchronized(likeStateLock) { - pendingLikes.forEach { (id, isLiked) -> - // A click made while storage was loading takes precedence, including a cancelled selection. - pendingLikeStates[id] = if (id in likeRevisions) { - findCurrentLikeState(id) ?: pendingLikeStates[id] ?: isLiked - } else { - isLiked - } - unconfirmedLikeIds += id - setLikeSyncStatus(id, LikeSyncStatus.NEEDS_RETRY) - } - applyPendingLikeStatesToCachedFeeds() - } - likesRestored.complete(Unit) - } catch (error: Exception) { - likesRestored.completeExceptionally(error) + restorePendingLikes() + isLikesRestored = true + isLikeRestoreFailed.value = false + } catch (error: CancellationException) { throw error + } catch (error: Exception) { + isLikeRestoreFailed.value = true + Log.e("UpdatedFeedRepository", "Failed to restore pending feed likes", error) + } + isLikesRestored + } + + private suspend fun restorePendingLikes() { + val pendingLikes = pendingFeedLikeStore.getPendingLikes() + synchronized(likeStateLock) { + pendingLikes.forEach { (id, isLiked) -> + // A click made while storage was loading takes precedence, including a cancelled selection. + pendingLikeStates[id] = if (id in likeRevisions) { + findCurrentLikeState(id) ?: pendingLikeStates[id] ?: isLiked + } else { + isLiked + } + unconfirmedLikeIds += id + setLikeSyncStatus(id, LikeSyncStatus.NEEDS_RETRY) } + applyPendingLikeStatesToCachedFeeds() } } @@ -388,7 +401,8 @@ class UpdatedFeedRepository pendingLikeStates[feedId] = new } scope.launch { - likesRestored.await() + // Restore failed: keep the selection in memory and hold the write. + if (!ensureLikesRestored()) return@launch try { persistPendingLike(feedId) } catch (error: CancellationException) { @@ -419,6 +433,11 @@ class UpdatedFeedRepository /** A manual retry targets only unresolved items, using their latest selection. */ fun retryPendingLikes(feedIds: Set? = null) { + if (isLikeRestoreFailed.value) { + // Restore failed earlier: restore first, then retry including the restored items. + scope.launch { if (ensureLikesRestored()) retryPendingLikes(feedIds) } + return + } val targets = synchronized(likeStateLock) { _likeSyncStates.value .filter { (id, status) -> status == LikeSyncStatus.NEEDS_RETRY && (feedIds == null || id in feedIds) } @@ -431,7 +450,8 @@ class UpdatedFeedRepository private fun syncPendingLikes(feedIds: Set?) { val requestedAt = synchronized(likeStateLock) { ++likeEventSequence } scope.launch { - likesRestored.await() + // Restore failed: hold sending until a later sync or retry restores the saved likes. + if (!ensureLikesRestored()) return@launch likeSyncMutex.withLock { val targets = synchronized(likeStateLock) { pendingLikeStates.keys.filter { feedIds == null || it in feedIds } diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt index d53387330..d9b37abfe 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt @@ -359,6 +359,60 @@ class UpdatedFeedRepositoryRecoveryTest { assertTrue(repository.likeSyncStates.value.isEmpty()) } + @Test + fun `저장된 좋아요를 읽지 못해도 예외 없이 저장과 전송을 보류한다`() = + runTest { + val api = server() + val backing = FakePendingFeedLikeStore() + val store = object : PendingFeedLikeStore by backing { + override suspend fun getPendingLikes(): Map = throw IOException("test storage unavailable") + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertTrue( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertTrue(backing.updateCalls.isEmpty()) + assertTrue(api.postLikesCalls.isEmpty()) + } + + @Test + fun `저장소를 다시 읽을 수 있게 되면 다시 시도로 복원하고 이어서 전송한다`() = + runTest { + val api = server() + val backing = FakePendingFeedLikeStore(initial = mapOf(1L to true)) + var failRead = true + val store = object : PendingFeedLikeStore by backing { + override suspend fun getPendingLikes(): Map { + if (failRead) throw IOException("test storage unavailable") + return backing.getPendingLikes() + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.retryPendingLikes() + advanceUntilIdle() + assertFalse( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertTrue(api.postLikesCalls.isEmpty()) + failRead = false + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(backing.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + private fun server() = FakeFeedApi().apply { feedsResponse = feedsResponseOf(feedResponse(1L, false, 0)) From 161b9e22fbd46fa014671d9298ac4f68d6d7d91b Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 04:23:40 +0900 Subject: [PATCH 34/49] =?UTF-8?q?test:=20`core:datastore`=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=82=B4=20JUnit=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/datastore/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 537747926..a92b6d3a0 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -17,4 +17,6 @@ dependencies { implementation(projects.data.user) implementation(libs.datastore.preferences) implementation(libs.serialization.json) + + testImplementation(libs.junit) } From 1fce8445c16d194dacf56b1213260c6d2ba53919 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 04:26:48 +0900 Subject: [PATCH 35/49] =?UTF-8?q?fix:=20=EC=86=90=EC=83=81=EB=90=9C=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=A0=80=EC=9E=A5=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EC=B4=88=EA=B8=B0=ED=99=94=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=B4=88=EA=B8=B0=ED=99=94=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=82=A8=EA=B8=B0=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultPendingFeedLikeStore.kt`: - DataStore 파일 손상 시 빈 상태로 교체하고 초기화 여부를 기록하는 `corruptionHandler` 추가 - 초기화 여부 확인 및 플래그를 소비하는 `consumeResetNotice()` 함수 구현 - `PENDING_FEED_LIKES_RESET_KEY`를 통해 파일 초기화 상태 관리 - `PendingFeedLikeStore.kt`: 외부 모듈에서 초기화 상태를 확인할 수 있도록 인터페이스 내 `consumeResetNotice()` 정의 추가 - `DataStoreModule.kt`: `pendingFeedLikeDataStore` 생성 시 정의된 `corruptionHandler`를 적용하도록 의존성 주입 설정 수정 - `UpdatedFeedRepositoryTestSupport.kt`: 테스트 환경에서 파일 초기화 시나리오를 검증할 수 있도록 `resetNotice` 상태 및 관련 모의 로직 추가 --- .../feed/DefaultPendingFeedLikeStore.kt | 18 ++++++++++++++++++ .../core/datastore/di/DataStoreModule.kt | 2 ++ .../data/feed/store/PendingFeedLikeStore.kt | 3 +++ .../UpdatedFeedRepositoryTestSupport.kt | 9 +++++++++ 4 files changed, 32 insertions(+) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt index 07af7e3d9..939682079 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -1,8 +1,11 @@ package com.into.websoso.core.datastore.datasource.feed import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.preferencesOf import androidx.datastore.preferences.core.stringPreferencesKey import com.into.websoso.core.common.dispatchers.Dispatcher import com.into.websoso.core.common.dispatchers.WebsosoDispatchers @@ -92,6 +95,15 @@ internal class DefaultPendingFeedLikeStore return deleted } + override suspend fun consumeResetNotice(): Boolean { + var wasReset = false + pendingFeedLikeDataStore.edit { preferences -> + wasReset = preferences[PENDING_FEED_LIKES_RESET_KEY] == true + preferences.remove(PENDING_FEED_LIKES_RESET_KEY) + } + return wasReset + } + private fun decodePendingLikes(jsonString: String?): Map { if (jsonString == null) return emptyMap() @@ -117,6 +129,12 @@ internal class DefaultPendingFeedLikeStore companion object { private val PENDING_FEED_LIKES_KEY = stringPreferencesKey("PENDING_FEED_LIKES_KEY") + private val PENDING_FEED_LIKES_RESET_KEY = booleanPreferencesKey("PENDING_FEED_LIKES_RESET_KEY") + + /** 손상된 파일은 빈 기록으로 바꾸고, 초기화했다는 표시를 남깁니다. */ + internal val corruptionHandler = ReplaceFileCorruptionHandler { + preferencesOf(PENDING_FEED_LIKES_RESET_KEY to true) + } } } diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt index 7c533b837..d173fdd45 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore +import com.into.websoso.core.datastore.datasource.feed.DefaultPendingFeedLikeStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -30,6 +31,7 @@ internal object DataStoreModule { private const val PENDING_FEED_LIKE_DATASTORE = "PENDING_FEED_LIKE_DATASTORE" private val Context.pendingFeedLikeDataStore: DataStore by preferencesDataStore( name = PENDING_FEED_LIKE_DATASTORE, + corruptionHandler = DefaultPendingFeedLikeStore.corruptionHandler, ) @Provides diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt index 7bc64175a..f44a46ec9 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -23,4 +23,7 @@ interface PendingFeedLikeStore { feedId: Long, isLiked: Boolean, ): Boolean + + /** 손상된 저장 파일을 초기화한 표시가 있으면 지우고 true를 반환합니다. */ + suspend fun consumeResetNotice(): Boolean } diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt index e126be806..a33a7e322 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt @@ -244,6 +244,9 @@ internal class FakePendingFeedLikeStore( val deleteCalls = mutableListOf() val deleteIfMatchedCalls = mutableListOf>() + /** true면 손상된 파일을 초기화한 직후처럼 동작한다. 한 번 확인하면 false로 바뀐다. */ + var resetNotice: Boolean = false + fun currentPendingLikes(): Map = state.value override suspend fun getPendingLikes(): Map = state.value @@ -270,4 +273,10 @@ internal class FakePendingFeedLikeStore( state.update { it - feedId } return true } + + override suspend fun consumeResetNotice(): Boolean { + val wasReset = resetNotice + resetNotice = false + return wasReset + } } From d1ea2a8afd77da4ebfa29852f5f25e3adc24c4a9 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 04:27:15 +0900 Subject: [PATCH 36/49] =?UTF-8?q?test:=20DefaultPendingFeedLikeStore=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=86=90=EC=83=81=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=A1=9C=EC=A7=81=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultPendingFeedLikeStoreTest.kt`: - DataStore 파일이 손상된 상황에서 `corruptionHandler`를 통한 자동 초기화 로직 검증 - 초기화 이후 `consumeResetNotice`가 최초 1회만 발생하고 이후 소비되는지 확인 - 손상 복구된 스토어에서 데이터 업데이트(`updatePendingLike`) 및 조회(`getPendingLikes`)가 정상 작동하는지 확인하는 시나리오 추가 --- .../feed/DefaultPendingFeedLikeStoreTest.kt | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt diff --git a/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt new file mode 100644 index 000000000..91a0ecd35 --- /dev/null +++ b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt @@ -0,0 +1,39 @@ +package com.into.websoso.core.datastore.datasource.feed + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultPendingFeedLikeStoreTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `손상된 파일은 빈 기록으로 초기화하고 초기화 표시를 한 번만 알린다`() = + runTest { + val file = temporaryFolder.newFile("pending_feed_like.preferences_pb") + // 길이 127짜리 값이 온다고 적혀 있지만 내용이 없어 해석할 수 없는 파일 + file.writeBytes(byteArrayOf(0x0A, 0x7F)) + val dataStore = PreferenceDataStoreFactory.create( + corruptionHandler = DefaultPendingFeedLikeStore.corruptionHandler, + scope = backgroundScope, + produceFile = { file }, + ) + val store = DefaultPendingFeedLikeStore(dataStore, StandardTestDispatcher(testScheduler)) + + assertTrue(store.getPendingLikes().isEmpty()) + assertTrue(store.consumeResetNotice()) + assertFalse(store.consumeResetNotice()) + + store.updatePendingLike(feedId = 1L, isLiked = true) + assertEquals(mapOf(1L to true), store.getPendingLikes()) + } +} From d621daa488c4b033d76cb3caf10506dc100302b7 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 04:27:48 +0900 Subject: [PATCH 37/49] =?UTF-8?q?fix:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=20=EC=8B=9C=20=EC=A0=80=EC=9E=A5=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=97=AC=EB=B6=80?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EB=A1=9D=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: - 저장소 파일 손상으로 인해 기존 좋아요 요청 내역이 초기화된 경우를 감지하는 `hasUnrestoredLikes` 상태(StateFlow) 추가 - `restorePendingLikes` 수행 시 저장소로부터 초기화 여부(`consumeResetNotice`)를 확인하여 유실 상태를 업데이트하는 로직 구현 - `UpdatedFeedRepositoryRecoveryTest.kt`: - 손상된 파일 초기화 시 미복원 안내 상태가 활성화되는지 확인하는 테스트 케이스 추가 - 데이터 유실 발생 이후에도 신규 좋아요 요청 및 동기화가 정상적으로 동작하는지 검증하는 테스트 추가 --- .../feed/repository/UpdatedFeedRepository.kt | 7 +++++++ .../UpdatedFeedRepositoryRecoveryTest.kt | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 4549a4c5b..d030de542 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -71,6 +71,8 @@ class UpdatedFeedRepository private val restoreMutex = Mutex() private var isLikesRestored = false private val isLikeRestoreFailed = MutableStateFlow(false) + private val _hasUnrestoredLikes = MutableStateFlow(false) + val hasUnrestoredLikes = _hasUnrestoredLikes.asStateFlow() private val unconfirmedLikeIds = mutableSetOf() private val likeRevisions = mutableMapOf() private val completedLikeAttempts = mutableMapOf>() @@ -104,6 +106,11 @@ class UpdatedFeedRepository private suspend fun restorePendingLikes() { val pendingLikes = pendingFeedLikeStore.getPendingLikes() + if (pendingFeedLikeStore.consumeResetNotice()) { + // The corrupted file was reset to empty, so saved changes could not be restored. + Log.e("UpdatedFeedRepository", "Pending feed likes were reset after the file was corrupted") + _hasUnrestoredLikes.value = true + } synchronized(likeStateLock) { pendingLikes.forEach { (id, isLiked) -> // A click made while storage was loading takes precedence, including a cancelled selection. diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt index d9b37abfe..2e4ef96d5 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt @@ -413,6 +413,24 @@ class UpdatedFeedRepositoryRecoveryTest { assertTrue(repository.likeSyncStates.value.isEmpty()) } + @Test + fun `손상된 저장 파일이 초기화됐으면 복원 후 미복원 안내 상태를 남기고 이어서 동작한다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore().apply { resetNotice = true } + val repository = createRepository(api, store) + advanceUntilIdle() + assertTrue(repository.hasUnrestoredLikes.value) + assertFalse(store.resetNotice) + + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(store.currentPendingLikes().isEmpty()) + } + private fun server() = FakeFeedApi().apply { feedsResponse = feedsResponseOf(feedResponse(1L, false, 0)) From 46924e2976c1e1936e7d1f4bdb400156a6bed014 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 04:40:51 +0900 Subject: [PATCH 38/49] =?UTF-8?q?fix:=20=ED=95=B4=EC=84=9D=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=97=86=EB=8A=94=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EB=8D=AE=EC=96=B4=EC=93=B0?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=B3=B4=EA=B4=80=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: - 데이터 복원 실패 시의 로그 메시지를 상황에 맞게 더 구체적으로 수정 - `PendingFeedLikeStore.kt`: - `consumeResetNotice` 메서드에 대해 파일 손상뿐만 아니라 해석 불가 상황을 포함하도록 주석 수정 - `DefaultPendingFeedLikeStore.kt`: - 해석할 수 없는 JSON 데이터를 삭제하는 대신 `PENDING_FEED_LIKES_UNREADABLE_KEY`에 따로 저장하여 백업하는 `setAsideUnreadableLikes` 로직 구현 - `decodePendingLikes`가 실패 시 `emptyMap()` 대신 `null`을 반환하도록 하여 데이터 부재와 해석 실패 상황을 명확히 구분 - 보류 중인 좋아요 데이터를 읽거나 수정(추가/삭제)하기 전, 기존 데이터의 해석 가능 여부를 확인하고 실패 시 백업 및 리셋 플래그를 설정하도록 개선 - 형식 변경 등에 대비하여 해석하지 못한 원본 데이터를 보관할 수 있는 `stringSetPreferencesKey` 추가 --- .../feed/DefaultPendingFeedLikeStore.kt | 43 ++++++++++++------- .../feed/repository/UpdatedFeedRepository.kt | 4 +- .../data/feed/store/PendingFeedLikeStore.kt | 2 +- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt index 939682079..9ed9027d0 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -2,11 +2,13 @@ package com.into.websoso.core.datastore.datasource.feed import androidx.datastore.core.DataStore import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.preferencesOf import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import com.into.websoso.core.common.dispatchers.Dispatcher import com.into.websoso.core.common.dispatchers.WebsosoDispatchers import com.into.websoso.core.datastore.datasource.feed.model.PendingFeedLikePreferences @@ -37,7 +39,7 @@ internal class DefaultPendingFeedLikeStore get() = pendingFeedLikeDataStore.data .map { preferences -> withContext(dispatcher) { - decodePendingLikes(preferences[PENDING_FEED_LIKES_KEY]) + preferences.readPendingLikes() } }.distinctUntilChanged() @@ -48,9 +50,8 @@ internal class DefaultPendingFeedLikeStore isLiked: Boolean, ) { pendingFeedLikeDataStore.edit { preferences -> - val pendingLikes: MutableMap = decodePendingLikes( - preferences[PENDING_FEED_LIKES_KEY], - ).toMutableMap() + preferences.setAsideUnreadableLikes() + val pendingLikes: MutableMap = preferences.readPendingLikes().toMutableMap() pendingLikes[feedId] = isLiked preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) } @@ -58,9 +59,8 @@ internal class DefaultPendingFeedLikeStore override suspend fun deletePendingLike(feedId: Long) { pendingFeedLikeDataStore.edit { preferences -> - val pendingLikes: MutableMap = decodePendingLikes( - preferences[PENDING_FEED_LIKES_KEY], - ).toMutableMap() + preferences.setAsideUnreadableLikes() + val pendingLikes: MutableMap = preferences.readPendingLikes().toMutableMap() pendingLikes.remove(feedId) if (pendingLikes.isEmpty()) { @@ -78,9 +78,8 @@ internal class DefaultPendingFeedLikeStore var deleted = false pendingFeedLikeDataStore.edit { preferences -> - val pendingLikes: MutableMap = decodePendingLikes( - preferences[PENDING_FEED_LIKES_KEY], - ).toMutableMap() + preferences.setAsideUnreadableLikes() + val pendingLikes: MutableMap = preferences.readPendingLikes().toMutableMap() if (pendingLikes[feedId] != isLiked) return@edit pendingLikes.remove(feedId) @@ -98,22 +97,33 @@ internal class DefaultPendingFeedLikeStore override suspend fun consumeResetNotice(): Boolean { var wasReset = false pendingFeedLikeDataStore.edit { preferences -> + preferences.setAsideUnreadableLikes() wasReset = preferences[PENDING_FEED_LIKES_RESET_KEY] == true preferences.remove(PENDING_FEED_LIKES_RESET_KEY) } return wasReset } - private fun decodePendingLikes(jsonString: String?): Map { - if (jsonString == null) return emptyMap() + /** 해석할 수 없는 기록은 지우지 않고 보관함으로 옮긴 뒤, 복원하지 못했다는 표시를 남깁니다. */ + private fun MutablePreferences.setAsideUnreadableLikes() { + val jsonString = this[PENDING_FEED_LIKES_KEY] ?: return + if (decodePendingLikes(jsonString) != null) return + this[PENDING_FEED_LIKES_UNREADABLE_KEY] = this[PENDING_FEED_LIKES_UNREADABLE_KEY].orEmpty() + jsonString + remove(PENDING_FEED_LIKES_KEY) + this[PENDING_FEED_LIKES_RESET_KEY] = true + } + + private fun Preferences.readPendingLikes(): Map = + this[PENDING_FEED_LIKES_KEY]?.let { jsonString -> decodePendingLikes(jsonString) }.orEmpty() - return runCatching { + /** 해석하지 못하면 null을 반환해, 저장된 기록이 없는 경우와 구분합니다. */ + private fun decodePendingLikes(jsonString: String): Map? = + runCatching { Json .decodeFromString(jsonString) .likes .associate { pendingLike -> pendingLike.feedId to pendingLike.isLiked } - }.getOrDefault(emptyMap()) - } + }.getOrNull() private fun encodePendingLikes(pendingLikes: Map): String = Json.encodeToString( @@ -131,6 +141,9 @@ internal class DefaultPendingFeedLikeStore private val PENDING_FEED_LIKES_KEY = stringPreferencesKey("PENDING_FEED_LIKES_KEY") private val PENDING_FEED_LIKES_RESET_KEY = booleanPreferencesKey("PENDING_FEED_LIKES_RESET_KEY") + // 형식 변경이나 해석 코드 문제를 고친 뒤 되살릴 수 있도록, 해석하지 못한 원본을 모아 둡니다. + private val PENDING_FEED_LIKES_UNREADABLE_KEY = stringSetPreferencesKey("PENDING_FEED_LIKES_UNREADABLE_KEY") + /** 손상된 파일은 빈 기록으로 바꾸고, 초기화했다는 표시를 남깁니다. */ internal val corruptionHandler = ReplaceFileCorruptionHandler { preferencesOf(PENDING_FEED_LIKES_RESET_KEY to true) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index d030de542..2eff045f0 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -107,8 +107,8 @@ class UpdatedFeedRepository private suspend fun restorePendingLikes() { val pendingLikes = pendingFeedLikeStore.getPendingLikes() if (pendingFeedLikeStore.consumeResetNotice()) { - // The corrupted file was reset to empty, so saved changes could not be restored. - Log.e("UpdatedFeedRepository", "Pending feed likes were reset after the file was corrupted") + // Saved changes were corrupted or unreadable and were reset, so they could not be restored. + Log.e("UpdatedFeedRepository", "Saved pending feed likes could not be restored and were reset") _hasUnrestoredLikes.value = true } synchronized(likeStateLock) { diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt index f44a46ec9..2c9c2280d 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -24,6 +24,6 @@ interface PendingFeedLikeStore { isLiked: Boolean, ): Boolean - /** 손상된 저장 파일을 초기화한 표시가 있으면 지우고 true를 반환합니다. */ + /** 저장된 기록을 복원하지 못해 비운 표시(파일 손상, 해석 불가)가 있으면 지우고 true를 반환합니다. */ suspend fun consumeResetNotice(): Boolean } From cab3637add7d34719073e187ef6911c590d9e5ae Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 04:41:03 +0900 Subject: [PATCH 39/49] =?UTF-8?q?test:=20DefaultPendingFeedLikeStore?= =?UTF-8?q?=EC=9D=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=86=90=EC=83=81=20?= =?UTF-8?q?=EB=B0=8F=20=ED=95=B4=EC=84=9D=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=EB=8C=80=EC=9D=91=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DefaultPendingFeedLikeStoreTest.kt`: - 해석할 수 없는 형식의 기존 기록이 존재할 때, 이를 삭제하지 않고 `PENDING_FEED_LIKES_UNREADABLE_KEY`에 보관하는 로직 검증 추가 - 데이터 복구 실패 시 빈 기록으로 초기화하고 초기화 알림(`consumeResetNotice`)을 1회만 노출하는지에 대한 단위 테스트 추가 - 해석 불가능한 데이터가 있는 상태에서 신규 데이터 저장 및 조회 기능이 정상 작동하는지 확인 - `createDataStore`, `createStore` 유틸리티 함수 및 테스트용 상수를 도입하여 테스트 코드 구조 개선 및 가독성 강화 --- .../feed/DefaultPendingFeedLikeStoreTest.kt | 65 +++++++++++++++++-- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt index 91a0ecd35..ec0e077c0 100644 --- a/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt +++ b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt @@ -1,8 +1,15 @@ package com.into.websoso.core.datastore.datasource.feed +import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -10,6 +17,7 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import java.io.File @OptIn(ExperimentalCoroutinesApi::class) class DefaultPendingFeedLikeStoreTest { @@ -19,15 +27,10 @@ class DefaultPendingFeedLikeStoreTest { @Test fun `손상된 파일은 빈 기록으로 초기화하고 초기화 표시를 한 번만 알린다`() = runTest { - val file = temporaryFolder.newFile("pending_feed_like.preferences_pb") + val file = temporaryFolder.newFile(FILE_NAME) // 길이 127짜리 값이 온다고 적혀 있지만 내용이 없어 해석할 수 없는 파일 file.writeBytes(byteArrayOf(0x0A, 0x7F)) - val dataStore = PreferenceDataStoreFactory.create( - corruptionHandler = DefaultPendingFeedLikeStore.corruptionHandler, - scope = backgroundScope, - produceFile = { file }, - ) - val store = DefaultPendingFeedLikeStore(dataStore, StandardTestDispatcher(testScheduler)) + val store = createStore(createDataStore(file)) assertTrue(store.getPendingLikes().isEmpty()) assertTrue(store.consumeResetNotice()) @@ -36,4 +39,52 @@ class DefaultPendingFeedLikeStoreTest { store.updatePendingLike(feedId = 1L, isLiked = true) assertEquals(mapOf(1L to true), store.getPendingLikes()) } + + @Test + fun `해석할 수 없는 기록은 덮어쓰지 않고 보관한 뒤 새 기록을 저장한다`() = + runTest { + val dataStore = createDataStore(temporaryFolder.newFile(FILE_NAME)) + dataStore.edit { it[PENDING_FEED_LIKES_KEY] = UNREADABLE_JSON } + val store = createStore(dataStore) + + store.updatePendingLike(feedId = 1L, isLiked = true) + + assertEquals(mapOf(1L to true), store.getPendingLikes()) + assertEquals(setOf(UNREADABLE_JSON), dataStore.data.first()[PENDING_FEED_LIKES_UNREADABLE_KEY]) + assertTrue(store.consumeResetNotice()) + } + + @Test + fun `해석할 수 없는 기록은 복원 시 빈 기록으로 보고 보관한 뒤 초기화 표시를 한 번만 알린다`() = + runTest { + val dataStore = createDataStore(temporaryFolder.newFile(FILE_NAME)) + dataStore.edit { it[PENDING_FEED_LIKES_KEY] = UNREADABLE_JSON } + val store = createStore(dataStore) + + assertTrue(store.getPendingLikes().isEmpty()) + assertTrue(store.consumeResetNotice()) + assertFalse(store.consumeResetNotice()) + assertEquals(setOf(UNREADABLE_JSON), dataStore.data.first()[PENDING_FEED_LIKES_UNREADABLE_KEY]) + } + + private fun TestScope.createDataStore(file: File): DataStore = + PreferenceDataStoreFactory.create( + corruptionHandler = DefaultPendingFeedLikeStore.corruptionHandler, + scope = backgroundScope, + produceFile = { file }, + ) + + private fun TestScope.createStore(dataStore: DataStore): DefaultPendingFeedLikeStore = + DefaultPendingFeedLikeStore(dataStore, StandardTestDispatcher(testScheduler)) + + companion object { + private const val FILE_NAME = "pending_feed_like.preferences_pb" + + // 저장 형식이 바뀐 경우처럼, JSON이지만 지금 코드가 해석할 수 없는 값 + private const val UNREADABLE_JSON = """{"likes":"not a list"}""" + + // 저장소 내부 키와 같은 이름으로, 파일에 실제로 무엇이 남는지 확인한다. + private val PENDING_FEED_LIKES_KEY = stringPreferencesKey("PENDING_FEED_LIKES_KEY") + private val PENDING_FEED_LIKES_UNREADABLE_KEY = stringSetPreferencesKey("PENDING_FEED_LIKES_UNREADABLE_KEY") + } } From 0be6311863216ea355f3b0919972f32495f41a61 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 05:02:14 +0900 Subject: [PATCH 40/49] =?UTF-8?q?fix:=20=EB=B3=B5=EC=9B=90=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=A4=91=20=EB=B3=B4=EB=A5=98=ED=95=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=EB=A5=BC=20=EB=B3=B5=EC=9B=90=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=ED=9B=84=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: - `ensureLikesRestored` 함수 내에서 저장된 좋아요 데이터의 복원 성공 시, 복원 실패 기간 동안 메모리에만 유지되던 변경 사항을 저장소에 강제로 영속화(`persistHeldLikes`)하는 로직 추가 - `persistPendingLikeOrMarkRetry` 함수를 분리하여 데이터 저장 실패 시 재시도 상태(`NEEDS_RETRY`)로 표시하는 로직의 재사용성 개선 - 초기 복원 실패 시 사용자 선택이 유실되지 않도록 보장하고, 복원 성공 즉시 대기 중인 상태를 동기화하도록 개선 --- .../feed/repository/UpdatedFeedRepository.kt | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 2eff045f0..a03508011 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -88,12 +88,15 @@ class UpdatedFeedRepository * 저장된 좋아요를 아직 불러오지 못했다면 다시 불러옵니다. * 실패해도 예외를 던지지 않고 false를 반환해, 호출한 쪽이 저장·전송을 보류하게 합니다. */ - private suspend fun ensureLikesRestored(): Boolean = - restoreMutex.withLock { + private suspend fun ensureLikesRestored(): Boolean { + var recovered = false + val restored = restoreMutex.withLock { if (isLikesRestored) return@withLock true try { restorePendingLikes() isLikesRestored = true + // An earlier attempt failed, so selections made meanwhile may be held only in memory. + recovered = isLikeRestoreFailed.value isLikeRestoreFailed.value = false } catch (error: CancellationException) { throw error @@ -103,6 +106,15 @@ class UpdatedFeedRepository } isLikesRestored } + if (recovered) persistHeldLikes() + return restored + } + + /** 복원에 실패한 동안 메모리에만 남은 선택을, 복원에 성공한 뒤 저장소에 저장합니다. */ + private suspend fun persistHeldLikes() { + val feedIds = synchronized(likeStateLock) { pendingLikeStates.keys.toList() } + feedIds.forEach { feedId -> persistPendingLikeOrMarkRetry(feedId) } + } private suspend fun restorePendingLikes() { val pendingLikes = pendingFeedLikeStore.getPendingLikes() @@ -410,16 +422,20 @@ class UpdatedFeedRepository scope.launch { // Restore failed: keep the selection in memory and hold the write. if (!ensureLikesRestored()) return@launch - try { - persistPendingLike(feedId) - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - synchronized(likeStateLock) { - if (pendingLikeStates.containsKey(feedId)) setLikeSyncStatus(feedId, LikeSyncStatus.NEEDS_RETRY) - } - Log.e("UpdatedFeedRepository", "Failed to save pending feed like $feedId", error) + persistPendingLikeOrMarkRetry(feedId) + } + } + + private suspend fun persistPendingLikeOrMarkRetry(feedId: Long) { + try { + persistPendingLike(feedId) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + synchronized(likeStateLock) { + if (pendingLikeStates.containsKey(feedId)) setLikeSyncStatus(feedId, LikeSyncStatus.NEEDS_RETRY) } + Log.e("UpdatedFeedRepository", "Failed to save pending feed like $feedId", error) } } From 3fe6f0ede195e5087bc8f2625de20346e528b7d8 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 05:02:43 +0900 Subject: [PATCH 41/49] =?UTF-8?q?test:=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EC=8B=9C=20=EB=B3=B4=EB=A5=98=EB=90=98?= =?UTF-8?q?=EC=97=88=EB=8D=98=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=9D=98=20=EB=88=84=EC=A0=81=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepositoryRecoveryTest.kt`: - 저장소 접근 실패 중에 발생한 좋아요 조작이, 이후 저장소가 복구된 상태에서 새로운 조작이 발생했을 때 이전 기록까지 포함하여 함께 저장되는지 확인하는 단위 테스트 추가 --- .../UpdatedFeedRepositoryRecoveryTest.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt index 2e4ef96d5..7b8884efd 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt @@ -431,6 +431,33 @@ class UpdatedFeedRepositoryRecoveryTest { assertTrue(store.currentPendingLikes().isEmpty()) } + @Test + fun `복원에 실패한 동안 누른 좋아요는 복원에 성공하면 함께 저장된다`() = + runTest { + val api = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(1L, false, 0), feedResponse(2L, false, 0)) + } + val backing = FakePendingFeedLikeStore() + var failRead = true + val store = object : PendingFeedLikeStore by backing { + override suspend fun getPendingLikes(): Map { + if (failRead) throw IOException("test storage unavailable") + return backing.getPendingLikes() + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertTrue(backing.currentPendingLikes().isEmpty()) + + failRead = false + repository.toggleLikeLocal(2L) + advanceUntilIdle() + assertEquals(mapOf(1L to true, 2L to true), backing.currentPendingLikes()) + } + private fun server() = FakeFeedApi().apply { feedsResponse = feedsResponseOf(feedResponse(1L, false, 0)) From 7cb260ad8ae434df0b8896fdc7a176ea49578478 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 13:12:57 +0900 Subject: [PATCH 42/49] =?UTF-8?q?refactor:=20`UpdatedFeedRepository`=20?= =?UTF-8?q?=EB=82=B4=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=83=81=ED=83=9C=EB=A5=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=B3=BC=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EA=B2=8C=20=EA=B3=B5=EA=B0=9C=20=ED=95=98=EB=8A=94=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: - `isLikeRestoreFailed`를 외부에서 관찰 가능하도록 `MutableStateFlow`를 캡슐화하고 `asStateFlow()`로 노출 - 내부 상태 변경 시 프라이빗 프로퍼티(`_isLikeRestoreFailed`)를 사용하도록 로직 수정 - `retryPendingLikes` 및 `ensureLikesRestored` 내에서 변경된 프로퍼티 참조 반영 --- .../data/feed/repository/UpdatedFeedRepository.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index a03508011..1038edb60 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -70,7 +70,8 @@ class UpdatedFeedRepository private val likeSyncMutex = Mutex() private val restoreMutex = Mutex() private var isLikesRestored = false - private val isLikeRestoreFailed = MutableStateFlow(false) + private val _isLikeRestoreFailed = MutableStateFlow(false) + val isLikeRestoreFailed = _isLikeRestoreFailed.asStateFlow() private val _hasUnrestoredLikes = MutableStateFlow(false) val hasUnrestoredLikes = _hasUnrestoredLikes.asStateFlow() private val unconfirmedLikeIds = mutableSetOf() @@ -96,12 +97,12 @@ class UpdatedFeedRepository restorePendingLikes() isLikesRestored = true // An earlier attempt failed, so selections made meanwhile may be held only in memory. - recovered = isLikeRestoreFailed.value - isLikeRestoreFailed.value = false + recovered = _isLikeRestoreFailed.value + _isLikeRestoreFailed.value = false } catch (error: CancellationException) { throw error } catch (error: Exception) { - isLikeRestoreFailed.value = true + _isLikeRestoreFailed.value = true Log.e("UpdatedFeedRepository", "Failed to restore pending feed likes", error) } isLikesRestored @@ -456,7 +457,7 @@ class UpdatedFeedRepository /** A manual retry targets only unresolved items, using their latest selection. */ fun retryPendingLikes(feedIds: Set? = null) { - if (isLikeRestoreFailed.value) { + if (_isLikeRestoreFailed.value) { // Restore failed earlier: restore first, then retry including the restored items. scope.launch { if (ensureLikesRestored()) retryPendingLikes(feedIds) } return From ebdad7b1e32a6373d2d9d428abbdf83e6f1b75b9 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 13:13:48 +0900 Subject: [PATCH 43/49] =?UTF-8?q?test:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EC=8B=A4=ED=8C=A8=20=EC=83=81=ED=83=9C(`i?= =?UTF-8?q?sLikeRestoreFailed`)=EC=97=90=20=EB=8C=80=ED=95=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepositoryRecoveryTest.kt`: - 좋아요 복구 작업 실패 시 `isLikeRestoreFailed` 플래그가 `true`로 설정되는지 확인하는 단언문 추가 - `retryPendingLikes`를 호출하여 복구에 성공한 후, 해당 플래그가 다시 `false`로 거두어지는지 검증하는 로직 추가 --- .../data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt index 7b8884efd..33b653b5a 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt @@ -380,6 +380,7 @@ class UpdatedFeedRepositoryRecoveryTest { ) assertTrue(backing.updateCalls.isEmpty()) assertTrue(api.postLikesCalls.isEmpty()) + assertTrue(repository.isLikeRestoreFailed.value) } @Test @@ -405,12 +406,14 @@ class UpdatedFeedRepositoryRecoveryTest { .isLiked, ) assertTrue(api.postLikesCalls.isEmpty()) + assertTrue(repository.isLikeRestoreFailed.value) failRead = false repository.retryPendingLikes() advanceUntilIdle() assertEquals(listOf(1L), api.postLikesCalls) assertTrue(backing.currentPendingLikes().isEmpty()) assertTrue(repository.likeSyncStates.value.isEmpty()) + assertFalse(repository.isLikeRestoreFailed.value) } @Test From 600e5950d4fb36dcd6914fe0e26a223e16a3d964 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 13:14:48 +0900 Subject: [PATCH 44/49] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EC=97=90=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `FeedLikeSyncNotice.kt`: - 좋아요 복구 실패 여부(`isRestoreFailed`)에 따른 UI 노출 조건 및 메시지 분기 로직 추가 - `FeedViewModel.kt` & `UpdatedFeedDetailViewModel.kt`: - `feedRepository`로부터 `isLikeRestoreFailed` 상태를 구독하여 UI 레이어로 전달 - `FeedScreen.kt` & `FeedRoute.kt`: - 좋아요 복구 실패 상태를 관찰하고 `FeedLikeSyncNotice` 컴포넌트에 연결 - `FeedDetailActivity.kt`: - 상세 화면 내 `FeedLikeSyncNotice`에 `isRestoreFailed` 상태를 연동 - `strings.xml`: 좋아요 복구 실패 시 표시할 안내 문구 리소스 추가 --- .../com/into/websoso/ui/feedDetail/FeedDetailActivity.kt | 2 ++ .../websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt | 1 + core/resource/src/main/res/values/strings.xml | 1 + .../main/java/com/into/websoso/feature/feed/FeedRoute.kt | 2 ++ .../main/java/com/into/websoso/feature/feed/FeedScreen.kt | 2 ++ .../java/com/into/websoso/feature/feed/FeedViewModel.kt | 1 + .../websoso/feature/feed/component/FeedLikeSyncNotice.kt | 7 +++++-- 7 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt index 4fd31f035..19922ff7a 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt @@ -343,8 +343,10 @@ class FeedDetailActivity : BaseActivity(activity_feed setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val states by feedDetailViewModel.likeSyncStates.collectAsStateWithLifecycle() + val isRestoreFailed by feedDetailViewModel.isLikeRestoreFailed.collectAsStateWithLifecycle() WebsosoTheme { FeedLikeSyncNotice( + isRestoreFailed = isRestoreFailed, needsRetry = states[feedId] == LikeSyncStatus.NEEDS_RETRY, isSyncing = states[feedId] == LikeSyncStatus.SYNCING, onRetry = feedDetailViewModel::retryLike, diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt index d27142918..56cd763c9 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt @@ -39,6 +39,7 @@ class UpdatedFeedDetailViewModel MutableLiveData(FeedDetailUiState()) val feedDetailUiState: LiveData get() = _feedDetailUiState val likeSyncStates = feedRepository.likeSyncStates + val isLikeRestoreFailed = feedRepository.isLikeRestoreFailed fun retryLike() { if (feedId != -1L) feedRepository.retryPendingLikes(setOf(feedId)) diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 4e44e3721..911db14a0 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -444,4 +444,5 @@ 반영을 확인하지 못한 좋아요가 있어요. 확인 필요 다시 시도 + 좋아요를 반영하는 데 문제가 생겼어요. diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt index 2d90598da..90b44a9e9 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt @@ -29,6 +29,7 @@ fun FeedRoute( val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsStateWithLifecycle() val likeSyncStates by viewModel.likeSyncStates.collectAsStateWithLifecycle() + val isLikeRestoreFailed by viewModel.isLikeRestoreFailed.collectAsStateWithLifecycle() val bottomSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, confirmValueChange = { newValue -> @@ -40,6 +41,7 @@ fun FeedRoute( FeedScreen( uiState = uiState, likeSyncStates = likeSyncStates, + isLikeRestoreFailed = isLikeRestoreFailed, onRetryLikes = viewModel::retryLikes, bottomSheetState = bottomSheetState, onTabSelected = viewModel::updateTab, diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt index 8bd8bb253..d1f2d34c2 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt @@ -71,6 +71,7 @@ internal fun FeedScreen( onWriteFeedClick: () -> Unit, onLoadMore: () -> Unit, likeSyncStates: Map = emptyMap(), + isLikeRestoreFailed: Boolean = false, onRetryLikes: () -> Unit = {}, ) { Scaffold(containerColor = White) { _ -> @@ -82,6 +83,7 @@ internal fun FeedScreen( ) FeedLikeSyncNotice( + isRestoreFailed = isLikeRestoreFailed, needsRetry = likeSyncStates.values.any { it == LikeSyncStatus.NEEDS_RETRY }, isSyncing = likeSyncStates.values.any { it == LikeSyncStatus.SYNCING }, onRetry = onRetryLikes, diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt index d8a53d17f..4b8d4898b 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt @@ -29,6 +29,7 @@ class FeedViewModel private val _uiState = MutableStateFlow(FeedUiState()) val uiState = _uiState.asStateFlow() val likeSyncStates = feedRepository.likeSyncStates + val isLikeRestoreFailed = feedRepository.isLikeRestoreFailed fun retryLikes() = feedRepository.retryPendingLikes() diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt index fdd1bf081..8f58e7837 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt @@ -21,11 +21,12 @@ import com.into.websoso.core.resource.R @Composable fun FeedLikeSyncNotice( + isRestoreFailed: Boolean, needsRetry: Boolean, isSyncing: Boolean, onRetry: () -> Unit, ) { - if (!needsRetry) return + if (!isRestoreFailed && !needsRetry) return Row( modifier = Modifier .fillMaxWidth() @@ -35,7 +36,9 @@ fun FeedLikeSyncNotice( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.feed_like_unconfirmed_message), + text = stringResource( + if (isRestoreFailed) R.string.feed_like_restore_failed_message else R.string.feed_like_unconfirmed_message, + ), style = WebsosoTheme.typography.body4, color = Black, modifier = Modifier.weight(1f), From 9e996f6eab34dfca1f43831e4735b2ca3d82c08c Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 13:47:02 +0900 Subject: [PATCH 45/49] =?UTF-8?q?fix:=20=EB=AF=B8=EB=B3=B5=EC=9B=90=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=97=90=EA=B2=8C=20=EC=95=8C=EB=A6=B0=20?= =?UTF-8?q?=EB=92=A4=20=EC=A7=80=EC=9A=B0=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: - 복원 실패 안내를 확인했음을 알리는 `acknowledgeUnrestoredLikes()` 메서드 추가 - `restorePendingLikes` 시 `consumeResetNotice` 대신 `readResetNotice`를 사용하여 안내 상태가 자동으로 초기화되지 않도록 수정 - `PendingFeedLikeStore.kt` & `DefaultPendingFeedLikeStore.kt`: - 안내 상태를 단순히 확인하는 `readResetNotice()`와 명시적으로 삭제하는 `clearResetNotice()`로 기능 분리 - `strings.xml`: 좋아요 복원 실패 시 사용자에게 보여줄 안내 문구(`feed_like_unrestored_message`) 추가 - `DefaultPendingFeedLikeStoreTest.kt` & `UpdatedFeedRepositoryRecoveryTest.kt`: - 안내 표시가 명시적으로 지워지기 전까지는 저장소를 재생성해도 유지되는지 검증하는 테스트 케이스 추가 및 기존 테스트 수정 --- .../feed/DefaultPendingFeedLikeStore.kt | 13 ++++++---- .../feed/DefaultPendingFeedLikeStoreTest.kt | 17 +++++++------ core/resource/src/main/res/values/strings.xml | 1 + .../feed/repository/UpdatedFeedRepository.kt | 17 ++++++++++++- .../data/feed/store/PendingFeedLikeStore.kt | 7 ++++-- .../UpdatedFeedRepositoryRecoveryTest.kt | 24 ++++++++++++++++++- .../UpdatedFeedRepositoryTestSupport.kt | 8 +++---- 7 files changed, 68 insertions(+), 19 deletions(-) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt index 9ed9027d0..ffbbd72aa 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -94,14 +94,19 @@ internal class DefaultPendingFeedLikeStore return deleted } - override suspend fun consumeResetNotice(): Boolean { - var wasReset = false + override suspend fun readResetNotice(): Boolean { + var hasNotice = false pendingFeedLikeDataStore.edit { preferences -> preferences.setAsideUnreadableLikes() - wasReset = preferences[PENDING_FEED_LIKES_RESET_KEY] == true + hasNotice = preferences[PENDING_FEED_LIKES_RESET_KEY] == true + } + return hasNotice + } + + override suspend fun clearResetNotice() { + pendingFeedLikeDataStore.edit { preferences -> preferences.remove(PENDING_FEED_LIKES_RESET_KEY) } - return wasReset } /** 해석할 수 없는 기록은 지우지 않고 보관함으로 옮긴 뒤, 복원하지 못했다는 표시를 남깁니다. */ diff --git a/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt index ec0e077c0..32db1a770 100644 --- a/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt +++ b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt @@ -25,7 +25,7 @@ class DefaultPendingFeedLikeStoreTest { val temporaryFolder = TemporaryFolder() @Test - fun `손상된 파일은 빈 기록으로 초기화하고 초기화 표시를 한 번만 알린다`() = + fun `손상된 파일은 빈 기록으로 초기화하고 지울 때까지 초기화 표시를 남긴다`() = runTest { val file = temporaryFolder.newFile(FILE_NAME) // 길이 127짜리 값이 온다고 적혀 있지만 내용이 없어 해석할 수 없는 파일 @@ -33,8 +33,10 @@ class DefaultPendingFeedLikeStoreTest { val store = createStore(createDataStore(file)) assertTrue(store.getPendingLikes().isEmpty()) - assertTrue(store.consumeResetNotice()) - assertFalse(store.consumeResetNotice()) + assertTrue(store.readResetNotice()) + assertTrue(store.readResetNotice()) + store.clearResetNotice() + assertFalse(store.readResetNotice()) store.updatePendingLike(feedId = 1L, isLiked = true) assertEquals(mapOf(1L to true), store.getPendingLikes()) @@ -51,19 +53,20 @@ class DefaultPendingFeedLikeStoreTest { assertEquals(mapOf(1L to true), store.getPendingLikes()) assertEquals(setOf(UNREADABLE_JSON), dataStore.data.first()[PENDING_FEED_LIKES_UNREADABLE_KEY]) - assertTrue(store.consumeResetNotice()) + assertTrue(store.readResetNotice()) } @Test - fun `해석할 수 없는 기록은 복원 시 빈 기록으로 보고 보관한 뒤 초기화 표시를 한 번만 알린다`() = + fun `해석할 수 없는 기록은 복원 시 빈 기록으로 보고 보관한 뒤 지울 때까지 초기화 표시를 남긴다`() = runTest { val dataStore = createDataStore(temporaryFolder.newFile(FILE_NAME)) dataStore.edit { it[PENDING_FEED_LIKES_KEY] = UNREADABLE_JSON } val store = createStore(dataStore) assertTrue(store.getPendingLikes().isEmpty()) - assertTrue(store.consumeResetNotice()) - assertFalse(store.consumeResetNotice()) + assertTrue(store.readResetNotice()) + store.clearResetNotice() + assertFalse(store.readResetNotice()) assertEquals(setOf(UNREADABLE_JSON), dataStore.data.first()[PENDING_FEED_LIKES_UNREADABLE_KEY]) } diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 911db14a0..83e1b531b 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -445,4 +445,5 @@ 확인 필요 다시 시도 좋아요를 반영하는 데 문제가 생겼어요. + 좋아요 일부가 반영되지 않았을 수 있어요. diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 1038edb60..be324f469 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -111,6 +111,21 @@ class UpdatedFeedRepository return restored } + /** 복원하지 못한 좋아요 안내를 보여 준 뒤 호출해, 같은 안내가 반복되지 않게 합니다. */ + fun acknowledgeUnrestoredLikes() { + _hasUnrestoredLikes.value = false + scope.launch { + try { + pendingFeedLikeStore.clearResetNotice() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + // If the notice stays, the user is told once more on the next launch. + Log.e("UpdatedFeedRepository", "Failed to clear the unrestored likes notice", error) + } + } + } + /** 복원에 실패한 동안 메모리에만 남은 선택을, 복원에 성공한 뒤 저장소에 저장합니다. */ private suspend fun persistHeldLikes() { val feedIds = synchronized(likeStateLock) { pendingLikeStates.keys.toList() } @@ -119,7 +134,7 @@ class UpdatedFeedRepository private suspend fun restorePendingLikes() { val pendingLikes = pendingFeedLikeStore.getPendingLikes() - if (pendingFeedLikeStore.consumeResetNotice()) { + if (pendingFeedLikeStore.readResetNotice()) { // Saved changes were corrupted or unreadable and were reset, so they could not be restored. Log.e("UpdatedFeedRepository", "Saved pending feed likes could not be restored and were reset") _hasUnrestoredLikes.value = true diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt index 2c9c2280d..b164dfde9 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -24,6 +24,9 @@ interface PendingFeedLikeStore { isLiked: Boolean, ): Boolean - /** 저장된 기록을 복원하지 못해 비운 표시(파일 손상, 해석 불가)가 있으면 지우고 true를 반환합니다. */ - suspend fun consumeResetNotice(): Boolean + /** 저장된 기록을 복원하지 못해 비운 표시(파일 손상, 해석 불가)가 있는지 반환합니다. 표시는 지우지 않습니다. */ + suspend fun readResetNotice(): Boolean + + /** 사용자에게 안내한 뒤 표시를 지웁니다. */ + suspend fun clearResetNotice() } diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt index 33b653b5a..fdab8fdf0 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt @@ -424,7 +424,7 @@ class UpdatedFeedRepositoryRecoveryTest { val repository = createRepository(api, store) advanceUntilIdle() assertTrue(repository.hasUnrestoredLikes.value) - assertFalse(store.resetNotice) + assertTrue(store.resetNotice) repository.fetchFeeds(0L, 10, "ALL") repository.toggleLikeLocal(1L) @@ -434,6 +434,28 @@ class UpdatedFeedRepositoryRecoveryTest { assertTrue(store.currentPendingLikes().isEmpty()) } + @Test + fun `미복원 안내 표시는 봤음 처리 전까지 저장소를 다시 만들어도 남는다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore().apply { resetNotice = true } + createRepository(api, store) + advanceUntilIdle() + + val recreated = createRepository(api, store) + advanceUntilIdle() + assertTrue(recreated.hasUnrestoredLikes.value) + + recreated.acknowledgeUnrestoredLikes() + advanceUntilIdle() + assertFalse(recreated.hasUnrestoredLikes.value) + assertFalse(store.resetNotice) + + val afterAcknowledged = createRepository(api, store) + advanceUntilIdle() + assertFalse(afterAcknowledged.hasUnrestoredLikes.value) + } + @Test fun `복원에 실패한 동안 누른 좋아요는 복원에 성공하면 함께 저장된다`() = runTest { diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt index a33a7e322..125aff49d 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt @@ -244,7 +244,7 @@ internal class FakePendingFeedLikeStore( val deleteCalls = mutableListOf() val deleteIfMatchedCalls = mutableListOf>() - /** true면 손상된 파일을 초기화한 직후처럼 동작한다. 한 번 확인하면 false로 바뀐다. */ + /** true면 손상된 파일을 초기화한 직후처럼 동작한다. clearResetNotice()를 호출해야 false로 바뀐다. */ var resetNotice: Boolean = false fun currentPendingLikes(): Map = state.value @@ -274,9 +274,9 @@ internal class FakePendingFeedLikeStore( return true } - override suspend fun consumeResetNotice(): Boolean { - val wasReset = resetNotice + override suspend fun readResetNotice(): Boolean = resetNotice + + override suspend fun clearResetNotice() { resetNotice = false - return wasReset } } From a251502ceb7a1d08b18aec0dc2149ce70f4ff186 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 13:47:38 +0900 Subject: [PATCH 46/49] =?UTF-8?q?feat:=20=EB=B0=98=EC=98=81=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=95=98=EC=9D=84=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EC=A2=8B=EC=95=84=EC=9A=94=EB=A5=BC=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EB=AA=A9=EB=A1=9D=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `FeedFragment.kt`: - `hasUnrestoredLikes` 상태를 관찰하여 복구되지 않은 좋아요가 있을 경우 토스트 메시지를 표시하는 `collectUnrestoredLikes` 로직 추가 - 토스트 노출 후 `acknowledgeUnrestoredLikes`를 호출하여 알림 확인 상태를 업데이트하도록 처리 - `FeedViewModel.kt`: - Repository로부터 `hasUnrestoredLikes` 상태를 노출하고, 알림 확인 처리를 위한 `acknowledgeUnrestoredLikes` 함수 추가 --- .../com/into/websoso/ui/main/feed/FeedFragment.kt | 14 ++++++++++++++ .../com/into/websoso/feature/feed/FeedViewModel.kt | 3 +++ 2 files changed, 17 insertions(+) diff --git a/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt b/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt index 4793349b0..c7b9b8702 100644 --- a/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt @@ -11,8 +11,12 @@ import androidx.fragment.app.viewModels import com.into.websoso.R import com.into.websoso.R.layout.fragment_feed import com.into.websoso.core.common.ui.base.BaseFragment +import com.into.websoso.core.common.util.collectWithLifecycle +import com.into.websoso.core.common.util.showWebsosoToast import com.into.websoso.core.common.util.tracker.Tracker import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R.drawable.ic_novel_rating_alert +import com.into.websoso.core.resource.R.string.feed_like_unrestored_message import com.into.websoso.databinding.DialogRemovePopupMenuBinding import com.into.websoso.databinding.DialogReportPopupMenuBinding import com.into.websoso.databinding.FragmentFeedBinding @@ -189,6 +193,16 @@ class FeedFragment : BaseFragment(fragment_feed) { super.onViewCreated(view, savedInstanceState) tracker.trackEvent("feed_all") + collectUnrestoredLikes() + } + + private fun collectUnrestoredLikes() { + feedViewModel.hasUnrestoredLikes.collectWithLifecycle(viewLifecycleOwner) { hasUnrestoredLikes -> + if (hasUnrestoredLikes) { + showWebsosoToast(requireContext(), getString(feed_like_unrestored_message), ic_novel_rating_alert) + feedViewModel.acknowledgeUnrestoredLikes() + } + } } companion object { diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt index 4b8d4898b..762457130 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt @@ -30,9 +30,12 @@ class FeedViewModel val uiState = _uiState.asStateFlow() val likeSyncStates = feedRepository.likeSyncStates val isLikeRestoreFailed = feedRepository.isLikeRestoreFailed + val hasUnrestoredLikes = feedRepository.hasUnrestoredLikes fun retryLikes() = feedRepository.retryPendingLikes() + fun acknowledgeUnrestoredLikes() = feedRepository.acknowledgeUnrestoredLikes() + init { collectFeedRefreshEvent() collectFeedFlows() From 05d9319b6dfd53c2a70a810ddd00353da328a7cb Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 16:11:55 +0900 Subject: [PATCH 47/49] =?UTF-8?q?fix:=20=EB=8A=A6=EA=B2=8C=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=ED=95=9C=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=B4=20=EC=B5=9C=EC=8B=A0=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=8D=AE=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: - `likeChangedAt` 맵과 `likeStateVersion`을 도입하여 피드별 좋아요 상태 변경 시점을 추적 - API 요청 시작 시점의 버전 정보를 응답 처리 시 비교하여, 요청 중에 발생한 최신 좋아요 상태가 이전 응답 데이터로 인해 덮어씌워지는 현상 방지 (`applyLatestLikeState`) - 상세 정보 조회(`fetchFeed`) 및 내 피드 데이터 갱신 시에도 버전 기반 상태 병합 로직 적용 - `toggleLikeLocal` 및 서버 응답 처리 시 `likeEventSequence`를 업데이트하여 상태 변경 시점 기록 강화 - `GetMyFeedsUseCase.kt`: `fetchMyActivities` 호출 전 현재 좋아요 상태 버전을 획득하고, 캐시 업데이트 시 이를 전달하도록 수정 - `UpdatedFeedRepositoryLikeStateTest.kt`: - 네트워크 지연 시나리오(상세 조회, 목록 조회, 내 피드 갱신)에서 최신 좋아요 상태가 유지되는지 검증하는 단위 테스트 4종 추가 - `heldFeedsApi` 헬퍼 함수를 통한 응답 지연 시뮬레이션 로직 구현 - 기존 테스트 케이스의 `updateMyFeedsCache` 호출부에 버전 파라미터 적용 --- .../feed/repository/UpdatedFeedRepository.kt | 52 +++++- .../UpdatedFeedRepositoryLikeStateTest.kt | 174 +++++++++++++++++- .../into/websoso/feed/GetMyFeedsUseCase.kt | 2 + 3 files changed, 222 insertions(+), 6 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index be324f469..853697b8c 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -76,6 +76,9 @@ class UpdatedFeedRepository val hasUnrestoredLikes = _hasUnrestoredLikes.asStateFlow() private val unconfirmedLikeIds = mutableSetOf() private val likeRevisions = mutableMapOf() + + // When a feed's like last changed by user input or server confirmation; fetched data older than this is ignored. + private val likeChangedAt = mutableMapOf() private val completedLikeAttempts = mutableMapOf>() private var likeEventSequence = 0L private val _likeSyncStates = MutableStateFlow>(emptyMap()) @@ -272,6 +275,7 @@ class UpdatedFeedRepository size: Int, feedsOption: String, ): FeedsEntity { + val version = likeStateVersion() val result = feedApi .getFeeds( feedsOption = feedsOption, @@ -280,7 +284,7 @@ class UpdatedFeedRepository ).toData() return synchronized(likeStateLock) { - val mergedFeeds = result.feeds.map { feed -> applyPendingLikeState(feed) } + val mergedFeeds = result.feeds.map { feed -> applyLatestLikeState(feed, version) } updateCachedLikes(mergedFeeds.associate { it.id to CachedFeedLikeState(it.isLiked, it.likeCount) }) val isRecommended = feedsOption == "RECOMMENDED" @@ -301,13 +305,15 @@ class UpdatedFeedRepository /** * 외부에서 가져온 내 피드 데이터를 캐시에 주입합니다. + * likeStateVersion에는 조회를 시작하기 직전에 받아 둔 좋아요 상태 번호를 넘깁니다. */ fun updateMyFeedsCache( feeds: List, isRefreshed: Boolean, + likeStateVersion: Long, ) { synchronized(likeStateLock) { - val mergedFeeds = feeds.map { feed -> applyPendingLikeState(feed) } + val mergedFeeds = feeds.map { feed -> applyLatestLikeState(feed, likeStateVersion) } updateCachedLikes(mergedFeeds.associate { it.id to CachedFeedLikeState(it.isLiked, it.likeCount) }) _myFeeds.update { current -> @@ -376,7 +382,9 @@ class UpdatedFeedRepository val current = findCurrentLikeState(feedId) ?: return val newLiked = !current // Record the user's input once, even when the same feed is cached in several lists. - likeRevisions[feedId] = ++likeEventSequence + val sequence = ++likeEventSequence + likeRevisions[feedId] = sequence + likeChangedAt[feedId] = sequence trackPendingLikeState(feedId, current, newLiked) val update: (List) -> List = { feeds -> feeds.map { feed -> @@ -528,6 +536,8 @@ class UpdatedFeedRepository if (pendingLikeStates[feedId] == selection.isLiked) { pendingLikeStates.remove(feedId) } + // Mark the server change in the same lock as the pending cleanup so an older fetch cannot slip in. + likeChangedAt[feedId] = ++likeEventSequence // An older success does not confirm an input made while the request was in flight. setLikeSyncStatus(feedId, if (pendingLikeStates.containsKey(feedId)) LikeSyncStatus.NEEDS_RETRY else null) } @@ -569,6 +579,39 @@ class UpdatedFeedRepository private fun findCurrentLikeState(feedId: Long): Boolean? = findCachedFeed(feedId)?.isLiked ?: _feedDetailLikeStates.value[feedId]?.isLiked + /** 조회를 시작하기 직전에 받아 두는 번호입니다. 응답을 반영할 때 그 뒤에 좋아요가 바뀌었는지 비교합니다. */ + fun likeStateVersion(): Long = synchronized(likeStateLock) { likeEventSequence } + + private fun hasLikeChangedSince( + feedId: Long, + version: Long, + ): Boolean = (likeChangedAt[feedId] ?: 0L) > version + + private fun findCurrentLike(feedId: Long): CachedFeedLikeState? = + findCachedFeed(feedId)?.let { CachedFeedLikeState(it.isLiked, it.likeCount) } ?: _feedDetailLikeStates.value[feedId] + + private fun applyLatestLikeState( + feed: FeedEntity, + version: Long, + ): FeedEntity { + if (hasLikeChangedSince(feed.id, version)) { + // The like changed while this response was in flight, so keep what the user sees now. + findCurrentLike(feed.id)?.let { return feed.copy(isLiked = it.isLiked, likeCount = it.likeCount) } + } + return applyPendingLikeState(feed) + } + + private fun applyLatestLikeStateToDetail( + feed: FeedDetailEntity, + version: Long, + ): FeedDetailEntity { + if (hasLikeChangedSince(feed.id, version)) { + // The like changed while this response was in flight, so keep what the user sees now. + findCurrentLike(feed.id)?.let { return feed.copy(isLiked = it.isLiked, likeCount = it.likeCount) } + } + return applyPendingLikeStateToDetail(feed) + } + // ============================================================================================ // Feed Actions (Remove, Report) // ============================================================================================ @@ -636,9 +679,10 @@ class UpdatedFeedRepository * 피드 상세 정보를 조회하고 로컬 상태를 병합하여 반환합니다. */ suspend fun fetchFeed(feedId: Long): FeedDetailEntity { + val version = likeStateVersion() val rawDetail = feedApi.getFeed(feedId).toData() return synchronized(likeStateLock) { - val mergedDetail = applyPendingLikeStateToDetail(rawDetail) + val mergedDetail = applyLatestLikeStateToDetail(rawDetail, version) val likeState = CachedFeedLikeState(mergedDetail.isLiked, mergedDetail.likeCount) _feedDetailLikeStates.update { it + (feedId to likeState) } updateCachedLikes(mapOf(feedId to likeState)) diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt index dfbf840fd..cf1686ca3 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt @@ -1,9 +1,15 @@ package com.into.websoso.data.feed.repository +import com.into.websoso.core.network.datasource.feed.FeedApi import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto import com.into.websoso.data.feed.repository.model.LikeSyncStatus +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -77,7 +83,7 @@ class UpdatedFeedRepositoryLikeStateTest { advanceUntilIdle() repository.fetchFeeds(0L, 10, "ALL") repository.fetchFeeds(0L, 10, "RECOMMENDED") - repository.updateMyFeedsCache(repository.sosoAllFeeds.value, true) + repository.updateMyFeedsCache(repository.sosoAllFeeds.value, true, repository.likeStateVersion()) repository.toggleLikeLocal(1L) repository.syncPendingLikes() advanceUntilIdle() @@ -206,7 +212,7 @@ class UpdatedFeedRepositoryLikeStateTest { val repository = createRepository(api, store) advanceUntilIdle() repository.fetchFeeds(0L, 10, "ALL") - repository.updateMyFeedsCache(repository.sosoAllFeeds.value, true) + repository.updateMyFeedsCache(repository.sosoAllFeeds.value, true, repository.likeStateVersion()) repository.fetchFeed(1L) api.feedsResponse = feedsResponseOf(feedResponse(1L, true, 1)) @@ -243,6 +249,170 @@ class UpdatedFeedRepositoryLikeStateTest { ) } + @Test + fun `상세 조회 중에 좋아요를 누르고 전송이 끝나면 늦게 온 응답이 좋아요를 덮지 않는다`() = + runTest { + val fakeServer = server().apply { feedDetailResponses[1L] = detailResponse() } + val response = CompletableDeferred() + val api = object : FeedApi by fakeServer { + override suspend fun getFeed(feedId: Long): FeedDetailResponseDto { + val staleDetail = fakeServer.getFeed(feedId) + response.await() + return staleDetail + } + } + val repository = createRepository(api, FakePendingFeedLikeStore()) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + val detail = async { repository.fetchFeed(1L) } + runCurrent() + + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + response.complete(Unit) + advanceUntilIdle() + + assertTrue(detail.await().isLiked) + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + assertTrue( + repository.sosoAllFeeds.value + .feed(1L) + .isLiked, + ) + } + + @Test + fun `좋아요를 누른 뒤 시작한 목록 조회 중에 전송이 끝나면 늦게 온 응답이 좋아요를 덮지 않는다`() = + runTest { + val fakeServer = server() + val response = CompletableDeferred() + var holdFeeds = false + val api = heldFeedsApi(fakeServer, response) { holdFeeds } + val repository = createRepository(api, FakePendingFeedLikeStore()) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + + holdFeeds = true + val refreshed = async { repository.fetchFeeds(0L, 10, "ALL") } + runCurrent() + repository.syncPendingLikes() + advanceUntilIdle() + response.complete(Unit) + advanceUntilIdle() + + assertTrue( + refreshed + .await() + .feeds + .feed(1L) + .isLiked, + ) + assertTrue( + repository.sosoAllFeeds.value + .feed(1L) + .isLiked, + ) + } + + @Test + fun `전송 확인 후 저장 정리가 끝나기 전에 늦게 온 목록 응답도 좋아요를 덮지 않는다`() = + runTest { + val fakeServer = server() + val response = CompletableDeferred() + var holdFeeds = false + val api = heldFeedsApi(fakeServer, response) { holdFeeds } + val backing = FakePendingFeedLikeStore() + val cleanup = CompletableDeferred() + val store = object : PendingFeedLikeStore by backing { + override suspend fun deletePendingLike(feedId: Long) { + cleanup.await() + backing.deletePendingLike(feedId) + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + + holdFeeds = true + val refreshed = async { repository.fetchFeeds(0L, 10, "ALL") } + runCurrent() + repository.syncPendingLikes() + advanceUntilIdle() + // POST는 끝났고 pending도 정리됐지만, 저장 정리(삭제)는 아직 멈춰 있는 상태 + assertEquals(listOf(1L), fakeServer.postLikesCompleted) + response.complete(Unit) + advanceUntilIdle() + + assertTrue( + refreshed + .await() + .feeds + .feed(1L) + .isLiked, + ) + assertTrue( + repository.sosoAllFeeds.value + .feed(1L) + .isLiked, + ) + cleanup.complete(Unit) + advanceUntilIdle() + assertTrue(backing.currentPendingLikes().isEmpty()) + } + + @Test + fun `내 피드 조회를 시작한 뒤 좋아요가 바뀌면 오래된 내 피드 데이터가 좋아요를 덮지 않는다`() = + runTest { + val repository = createRepository(server(), FakePendingFeedLikeStore()) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + val staleFeeds = repository.sosoAllFeeds.value + val version = repository.likeStateVersion() + + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + repository.updateMyFeedsCache(staleFeeds, isRefreshed = true, likeStateVersion = version) + + assertTrue( + repository.myFeeds.value + .feed(1L) + .isLiked, + ) + assertTrue( + repository.sosoAllFeeds.value + .feed(1L) + .isLiked, + ) + } + + // 조회를 시작한 순간의 서버 값으로 응답을 만들어 두고, 도착만 늦춘다. + private fun heldFeedsApi( + fakeServer: FakeFeedApi, + response: CompletableDeferred, + shouldHold: () -> Boolean, + ): FeedApi = + object : FeedApi by fakeServer { + override suspend fun getFeeds( + feedsOption: String, + lastFeedId: Long, + size: Int, + ): FeedsResponseDto { + val staleFeeds = fakeServer.getFeeds(feedsOption, lastFeedId, size) + if (shouldHold()) response.await() + return staleFeeds + } + } + private fun detailResponse() = FeedDetailResponseDto( userId = 1L, diff --git a/domain/feed/src/main/java/com/into/websoso/feed/GetMyFeedsUseCase.kt b/domain/feed/src/main/java/com/into/websoso/feed/GetMyFeedsUseCase.kt index eb17345a9..97a0bbe41 100644 --- a/domain/feed/src/main/java/com/into/websoso/feed/GetMyFeedsUseCase.kt +++ b/domain/feed/src/main/java/com/into/websoso/feed/GetMyFeedsUseCase.kt @@ -36,6 +36,7 @@ class GetMyFeedsUseCase val profile = myProfile ?: userRepository.fetchMyProfile().also { myProfile = it } val myId = myId ?: userRepository.fetchUserInfo().userId.also { myId = it } + val likeStateVersion = feedRepository.likeStateVersion() val myFeedsEntity: UserFeedsEntity = userRepository.fetchMyActivities( lastFeedId = lastFeedId, size = if (isFeedRefreshed) INITIAL_REQUEST_SIZE else ADDITIONAL_REQUEST_SIZE, @@ -52,6 +53,7 @@ class GetMyFeedsUseCase feedRepository.updateMyFeedsCache( feeds = convertedFeeds, isRefreshed = isFeedRefreshed, + likeStateVersion = likeStateVersion, ) return Feeds( From f785c37c3a28223f78ba4dc0565f8a25b111ef26 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 16:47:05 +0900 Subject: [PATCH 48/49] =?UTF-8?q?fix:=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=ED=94=BC=EB=93=9C=EB=8F=84=20=EB=8A=A6?= =?UTF-8?q?=EA=B2=8C=20=EC=98=A8=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=B4=20=EC=A2=8B=EC=95=84=EC=9A=94=EB=A5=BC=20=EB=8D=AE?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UpdatedFeedRepository.kt`: - `LikeChange` 데이터 클래스를 추가하여 좋아요 변경 시점(sequence)과 상태(`isLiked`)를 함께 관리하도록 개선 - 기존 `likeChangedAt`을 `lastLikeChanges`로 변경하여 마지막 변경 당시의 실제 좋아요 값 유지 - `applyLatestLikeState`: 네트워크 요청 중에 발생한 좋아요 변경 사항뿐만 아니라, 화면에 보이지 않는 피드에 대해서도 마지막으로 확인된 좋아요 상태와 카운트를 계산하여 반영하도록 수정 - `adjustedLikeCount`: 이전 상태와 비교하여 좋아요 수를 안전하게 가감하는 유틸리티 함수 추가 - `UpdatedFeedRepositoryLikeStateTest.kt`: - 화면에 노출되지 않은 피드의 좋아요 복구 작업이 목록/상세 조회 응답보다 늦게 완료될 때, 최신 상태가 덮어씌워지지 않는지 검증하는 테스트 케이스 추가 - 테스트용 Mock API 생성을 위한 `heldDetailApi` 헬퍼 함수 추가 및 기존 로직 리팩토링 --- .../feed/repository/UpdatedFeedRepository.kt | 50 +++++++++---- .../UpdatedFeedRepositoryLikeStateTest.kt | 73 +++++++++++++++++-- 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 853697b8c..1188fc54b 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -77,8 +77,9 @@ class UpdatedFeedRepository private val unconfirmedLikeIds = mutableSetOf() private val likeRevisions = mutableMapOf() - // When a feed's like last changed by user input or server confirmation; fetched data older than this is ignored. - private val likeChangedAt = mutableMapOf() + // A feed's last like change by user input or server confirmation, with the like value at that point. + // Fetched data requested before this change must not overwrite it. + private val lastLikeChanges = mutableMapOf() private val completedLikeAttempts = mutableMapOf>() private var likeEventSequence = 0L private val _likeSyncStates = MutableStateFlow>(emptyMap()) @@ -384,7 +385,7 @@ class UpdatedFeedRepository // Record the user's input once, even when the same feed is cached in several lists. val sequence = ++likeEventSequence likeRevisions[feedId] = sequence - likeChangedAt[feedId] = sequence + lastLikeChanges[feedId] = LikeChange(sequence, newLiked) trackPendingLikeState(feedId, current, newLiked) val update: (List) -> List = { feeds -> feeds.map { feed -> @@ -537,7 +538,7 @@ class UpdatedFeedRepository pendingLikeStates.remove(feedId) } // Mark the server change in the same lock as the pending cleanup so an older fetch cannot slip in. - likeChangedAt[feedId] = ++likeEventSequence + lastLikeChanges[feedId] = LikeChange(++likeEventSequence, pendingLikeStates[feedId] ?: selection.isLiked) // An older success does not confirm an input made while the request was in flight. setLikeSyncStatus(feedId, if (pendingLikeStates.containsKey(feedId)) LikeSyncStatus.NEEDS_RETRY else null) } @@ -576,40 +577,57 @@ class UpdatedFeedRepository val needsNetwork: Boolean, ) + private data class LikeChange( + val sequence: Long, + val isLiked: Boolean, + ) + private fun findCurrentLikeState(feedId: Long): Boolean? = findCachedFeed(feedId)?.isLiked ?: _feedDetailLikeStates.value[feedId]?.isLiked /** 조회를 시작하기 직전에 받아 두는 번호입니다. 응답을 반영할 때 그 뒤에 좋아요가 바뀌었는지 비교합니다. */ fun likeStateVersion(): Long = synchronized(likeStateLock) { likeEventSequence } - private fun hasLikeChangedSince( + private fun likeChangeSince( feedId: Long, version: Long, - ): Boolean = (likeChangedAt[feedId] ?: 0L) > version + ): LikeChange? = lastLikeChanges[feedId]?.takeIf { it.sequence > version } private fun findCurrentLike(feedId: Long): CachedFeedLikeState? = findCachedFeed(feedId)?.let { CachedFeedLikeState(it.isLiked, it.likeCount) } ?: _feedDetailLikeStates.value[feedId] + private fun adjustedLikeCount( + likeCount: Int, + isLiked: Boolean, + newLiked: Boolean, + ): Int = if (isLiked == newLiked) likeCount else (likeCount + if (newLiked) 1 else -1).coerceAtLeast(0) + private fun applyLatestLikeState( feed: FeedEntity, version: Long, ): FeedEntity { - if (hasLikeChangedSince(feed.id, version)) { - // The like changed while this response was in flight, so keep what the user sees now. - findCurrentLike(feed.id)?.let { return feed.copy(isLiked = it.isLiked, likeCount = it.likeCount) } - } - return applyPendingLikeState(feed) + val change = likeChangeSince(feed.id, version) ?: return applyPendingLikeState(feed) + // The like changed while this response was in flight, so keep what the user sees now. + findCurrentLike(feed.id)?.let { return feed.copy(isLiked = it.isLiked, likeCount = it.likeCount) } + // Not on screen: use the last known like instead of the older response. + return feed.copy( + isLiked = change.isLiked, + likeCount = adjustedLikeCount(feed.likeCount, feed.isLiked, change.isLiked), + ) } private fun applyLatestLikeStateToDetail( feed: FeedDetailEntity, version: Long, ): FeedDetailEntity { - if (hasLikeChangedSince(feed.id, version)) { - // The like changed while this response was in flight, so keep what the user sees now. - findCurrentLike(feed.id)?.let { return feed.copy(isLiked = it.isLiked, likeCount = it.likeCount) } - } - return applyPendingLikeStateToDetail(feed) + val change = likeChangeSince(feed.id, version) ?: return applyPendingLikeStateToDetail(feed) + // The like changed while this response was in flight, so keep what the user sees now. + findCurrentLike(feed.id)?.let { return feed.copy(isLiked = it.isLiked, likeCount = it.likeCount) } + // Not on screen: use the last known like instead of the older response. + return feed.copy( + isLiked = change.isLiked, + likeCount = adjustedLikeCount(feed.likeCount, feed.isLiked, change.isLiked), + ) } // ============================================================================================ diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt index cf1686ca3..14bd260b8 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt @@ -254,13 +254,7 @@ class UpdatedFeedRepositoryLikeStateTest { runTest { val fakeServer = server().apply { feedDetailResponses[1L] = detailResponse() } val response = CompletableDeferred() - val api = object : FeedApi by fakeServer { - override suspend fun getFeed(feedId: Long): FeedDetailResponseDto { - val staleDetail = fakeServer.getFeed(feedId) - response.await() - return staleDetail - } - } + val api = heldDetailApi(fakeServer, response) val repository = createRepository(api, FakePendingFeedLikeStore()) advanceUntilIdle() repository.fetchFeeds(0L, 10, "ALL") @@ -395,6 +389,71 @@ class UpdatedFeedRepositoryLikeStateTest { ) } + @Test + fun `화면에 없는 피드도 복원한 좋아요가 목록 조회 중에 전송되면 늦게 온 응답이 덮지 않는다`() = + runTest { + val fakeServer = server() + val response = CompletableDeferred() + val api = heldFeedsApi(fakeServer, response) { true } + val repository = createRepository(api, FakePendingFeedLikeStore(mapOf(1L to true))) + advanceUntilIdle() + + val firstPage = async { repository.fetchFeeds(0L, 10, "ALL") } + runCurrent() + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), fakeServer.postLikesCompleted) + response.complete(Unit) + advanceUntilIdle() + + val feed = firstPage + .await() + .feeds + .feed(1L) + assertTrue(feed.isLiked) + assertEquals(1, feed.likeCount) + } + + @Test + fun `화면에 없는 피드도 복원한 좋아요가 상세 조회 중에 전송되면 늦게 온 응답이 덮지 않는다`() = + runTest { + val fakeServer = server().apply { feedDetailResponses[1L] = detailResponse() } + val response = CompletableDeferred() + val api = heldDetailApi(fakeServer, response) + val repository = createRepository(api, FakePendingFeedLikeStore(mapOf(1L to true))) + advanceUntilIdle() + + val detail = async { repository.fetchFeed(1L) } + runCurrent() + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), fakeServer.postLikesCompleted) + response.complete(Unit) + advanceUntilIdle() + + val result = detail.await() + assertTrue(result.isLiked) + assertEquals(1, result.likeCount) + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + } + + // 조회를 시작한 순간의 서버 값으로 응답을 만들어 두고, 도착만 늦춘다. + private fun heldDetailApi( + fakeServer: FakeFeedApi, + response: CompletableDeferred, + ): FeedApi = + object : FeedApi by fakeServer { + override suspend fun getFeed(feedId: Long): FeedDetailResponseDto { + val staleDetail = fakeServer.getFeed(feedId) + response.await() + return staleDetail + } + } + // 조회를 시작한 순간의 서버 값으로 응답을 만들어 두고, 도착만 늦춘다. private fun heldFeedsApi( fakeServer: FakeFeedApi, From 10c064ed2c31d002d75c78fdca38ba62224cefb3 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sat, 10 Oct 2026 00:58:39 +0900 Subject: [PATCH 49/49] =?UTF-8?q?fix:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=B2=84=ED=8A=BC=EC=9D=98=20?= =?UTF-8?q?=EA=B8=80=EA=BC=B4=EA=B3=BC=20=EC=83=89=EC=83=81=EC=9D=84=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/component/FeedLikeSyncNotice.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt index 8f58e7837..af00bd33f 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -15,7 +16,9 @@ import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.into.websoso.core.designsystem.theme.Black +import com.into.websoso.core.designsystem.theme.Gray200 import com.into.websoso.core.designsystem.theme.Gray50 +import com.into.websoso.core.designsystem.theme.Primary100 import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.resource.R @@ -43,8 +46,18 @@ fun FeedLikeSyncNotice( color = Black, modifier = Modifier.weight(1f), ) - TextButton(onClick = onRetry, enabled = !isSyncing) { - Text(text = stringResource(R.string.feed_like_retry)) + TextButton( + onClick = onRetry, + enabled = !isSyncing, + colors = ButtonDefaults.textButtonColors( + contentColor = Primary100, + disabledContentColor = Gray200, + ), + ) { + Text( + text = stringResource(R.string.feed_like_retry), + style = WebsosoTheme.typography.label1, + ) } } }