diff --git a/core/datastore/src/main/java/com/yapp/datastore/FortunePreferences.kt b/core/datastore/src/main/java/com/yapp/datastore/FortunePreferences.kt new file mode 100644 index 00000000..98ebc7c5 --- /dev/null +++ b/core/datastore/src/main/java/com/yapp/datastore/FortunePreferences.kt @@ -0,0 +1,231 @@ +package com.yapp.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest +import java.time.Clock +import java.time.Instant +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FortunePreferences @Inject constructor( + private val dataStore: DataStore, + private val clock: Clock, +) { + private object Keys { + val ID = longPreferencesKey("fortune_id") + val DATE = longPreferencesKey("fortune_date_epoch") + val IMAGE_ID = intPreferencesKey("fortune_image_id") + val SCORE = intPreferencesKey("fortune_score") + val SEEN = booleanPreferencesKey("fortune_seen") + val TOOLTIP_SHOWN = booleanPreferencesKey("fortune_tooltip_shown") + + val CREATING = booleanPreferencesKey("fortune_creating") + val FAILED = booleanPreferencesKey("fortune_failed") + + val ATTEMPT_ID = stringPreferencesKey("fortune_attempt_id") + val STARTED_AT = longPreferencesKey("fortune_started_at") + val EXPIRES_AT = longPreferencesKey("fortune_expires_at") + + val FIRST_ALARM_DISMISSED_TODAY = booleanPreferencesKey("first_alarm_dismissed_today") + val FIRST_ALARM_DISMISSED_DATE_EPOCH = longPreferencesKey("first_alarm_dismissed_date_epoch") + } + + private fun todayEpoch(): Long = LocalDate.now(clock).toEpochDay() + private fun nowMillis(): Long = Instant.now(clock).toEpochMilli() + + val fortuneIdFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { it[Keys.ID] } + .distinctUntilChanged() + + val fortuneDateEpochFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { it[Keys.DATE] } + .distinctUntilChanged() + + val fortuneImageIdFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { it[Keys.IMAGE_ID] } + .distinctUntilChanged() + + val fortuneScoreFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { it[Keys.SCORE] } + .distinctUntilChanged() + + val hasUnseenFortuneFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { pref -> + val isToday = pref[Keys.DATE] == todayEpoch() + isToday && (pref[Keys.ID] != null) && (pref[Keys.SEEN] != true) + } + .distinctUntilChanged() + + val shouldShowFortuneToolTipFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { pref -> + val hasTodayFortune = (pref[Keys.DATE] == todayEpoch()) && (pref[Keys.ID] != null) + val tooltipShown = pref[Keys.TOOLTIP_SHOWN] ?: false + hasTodayFortune && !tooltipShown + } + .distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + val isFortuneCreatingFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { pref -> + Triple( + pref[Keys.CREATING] ?: false, + pref[Keys.EXPIRES_AT] ?: 0L, + pref[Keys.ATTEMPT_ID], + ) + } + .transformLatest { (creating, expiresAt, attemptId) -> + if (creating) { + val legacy = (expiresAt <= 0L) || attemptId.isNullOrEmpty() + val expired = (!legacy && nowMillis() > expiresAt) + + if (legacy || expired) { + // 레거시(만료정보 없음) 또는 만료 → 실패로 교정 + dataStore.edit { pref -> + pref[Keys.CREATING] = false + pref[Keys.FAILED] = true + } + emit(false) + return@transformLatest + } + } + emit(creating) + } + .distinctUntilChanged() + + val isFortuneFailedFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { it[Keys.FAILED] ?: false } + .distinctUntilChanged() + + val isFirstAlarmDismissedTodayFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { pref -> + val flag = pref[Keys.FIRST_ALARM_DISMISSED_TODAY] ?: false + val isToday = pref[Keys.FIRST_ALARM_DISMISSED_DATE_EPOCH] == todayEpoch() + flag && isToday + } + .distinctUntilChanged() + + suspend fun markFortuneCreating( + attemptId: String, + lease: Long, + ) { + val now = nowMillis() + dataStore.edit { pref -> + pref[Keys.CREATING] = true + pref[Keys.FAILED] = false + pref[Keys.ATTEMPT_ID] = attemptId + pref[Keys.STARTED_AT] = now + pref[Keys.EXPIRES_AT] = now + lease + } + } + + suspend fun markFortuneCreatedIfAttemptMatches( + attemptId: String, + fortuneId: Long, + ) { + dataStore.edit { pref -> + val currentAttempt = pref[Keys.ATTEMPT_ID] + val isCreating = pref[Keys.CREATING] ?: false + val expiresAt = pref[Keys.EXPIRES_AT] ?: 0L + + if (isCreating) { + val legacy = (expiresAt <= 0L) || currentAttempt.isNullOrEmpty() + val expired = (!legacy && nowMillis() > expiresAt) + + if (legacy || expired) { + // 만료된 상태라면 성공 처리 거부 + return@edit + } + } + + if (isCreating && currentAttempt == attemptId) { + val today = todayEpoch() + val prevDate = pref[Keys.DATE] + val isNewForToday = (pref[Keys.ID] != fortuneId) || (prevDate != today) + + pref[Keys.ID] = fortuneId + pref[Keys.DATE] = today + pref[Keys.CREATING] = false + pref[Keys.FAILED] = false + pref.remove(Keys.ATTEMPT_ID) + pref.remove(Keys.STARTED_AT) + pref.remove(Keys.EXPIRES_AT) + + if (isNewForToday) { + pref[Keys.SEEN] = false + pref[Keys.TOOLTIP_SHOWN] = false + } + } + } + } + + suspend fun markFortuneFailedIfAttemptMatches(attemptId: String) { + dataStore.edit { pref -> + if (pref[Keys.ATTEMPT_ID] == attemptId) { + pref[Keys.CREATING] = false + pref[Keys.FAILED] = true + pref.remove(Keys.ATTEMPT_ID) + pref.remove(Keys.STARTED_AT) + pref.remove(Keys.EXPIRES_AT) + } + } + } + + suspend fun markFortuneSeen() { + dataStore.edit { it[Keys.SEEN] = true } + } + + suspend fun markFortuneTooltipShown() { + dataStore.edit { it[Keys.TOOLTIP_SHOWN] = true } + } + + suspend fun saveFortuneImageId(imageResId: Int) { + dataStore.edit { it[Keys.IMAGE_ID] = imageResId } + } + + suspend fun saveFortuneScore(score: Int) { + dataStore.edit { it[Keys.SCORE] = score } + } + + suspend fun markFirstAlarmDismissedToday() { + dataStore.edit { pref -> + pref[Keys.FIRST_ALARM_DISMISSED_TODAY] = true + pref[Keys.FIRST_ALARM_DISMISSED_DATE_EPOCH] = todayEpoch() + } + } + + suspend fun clearFortuneData() { + dataStore.edit { pref -> + pref.remove(Keys.ID) + pref.remove(Keys.DATE) + pref.remove(Keys.IMAGE_ID) + pref.remove(Keys.SCORE) + pref.remove(Keys.SEEN) + pref.remove(Keys.TOOLTIP_SHOWN) + pref.remove(Keys.CREATING) + pref.remove(Keys.FAILED) + } + } +} diff --git a/core/datastore/src/main/java/com/yapp/datastore/UserPreferences.kt b/core/datastore/src/main/java/com/yapp/datastore/UserPreferences.kt index c9325870..c7dba64d 100644 --- a/core/datastore/src/main/java/com/yapp/datastore/UserPreferences.kt +++ b/core/datastore/src/main/java/com/yapp/datastore/UserPreferences.kt @@ -5,7 +5,6 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow @@ -25,18 +24,6 @@ class UserPreferences @Inject constructor( val USER_NAME = stringPreferencesKey("user_name") val ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed") - val FORTUNE_ID = longPreferencesKey("fortune_id") - val FORTUNE_DATE_EPOCH = longPreferencesKey("fortune_date_epoch") - val FORTUNE_IMAGE_ID = intPreferencesKey("fortune_image_id") - val FORTUNE_SCORE = intPreferencesKey("fortune_score") - val FORTUNE_SEEN = booleanPreferencesKey("fortune_seen") - val FORTUNE_TOOLTIP_SHOWN = booleanPreferencesKey("fortune_tooltip_shown") - val FORTUNE_CREATING = booleanPreferencesKey("fortune_creating") - val FORTUNE_FAILED = booleanPreferencesKey("fortune_failed") - - val FIRST_ALARM_DISMISSED_TODAY = booleanPreferencesKey("first_alarm_dismissed_today") - val FIRST_ALARM_DISMISSED_DATE_EPOCH = longPreferencesKey("first_alarm_dismissed_date_epoch") - val UPDATE_NOTICE_DONT_SHOW_VERSION = stringPreferencesKey("update_notice_dont_show_version") val UPDATE_NOTICE_LAST_SHOWN_DATE_EPOCH = longPreferencesKey("update_notice_last_shown_date_epoch") } @@ -58,62 +45,6 @@ class UserPreferences @Inject constructor( .map { it[Keys.ONBOARDING_COMPLETED] ?: false } .distinctUntilChanged() - val fortuneIdFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { it[Keys.FORTUNE_ID] } - .distinctUntilChanged() - - val fortuneDateEpochFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { it[Keys.FORTUNE_DATE_EPOCH] } - .distinctUntilChanged() - - val fortuneImageIdFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { it[Keys.FORTUNE_IMAGE_ID] } - .distinctUntilChanged() - - val fortuneScoreFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { it[Keys.FORTUNE_SCORE] } - .distinctUntilChanged() - - val hasUnseenFortuneFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { pref -> - val isToday = pref[Keys.FORTUNE_DATE_EPOCH] == todayEpoch() - isToday && (pref[Keys.FORTUNE_ID] != null) && (pref[Keys.FORTUNE_SEEN] != true) - } - .distinctUntilChanged() - - val shouldShowFortuneToolTipFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { pref -> - val hasTodayFortune = (pref[Keys.FORTUNE_DATE_EPOCH] == todayEpoch()) && (pref[Keys.FORTUNE_ID] != null) - val tooltipShown = pref[Keys.FORTUNE_TOOLTIP_SHOWN] ?: false - hasTodayFortune && !tooltipShown - } - .distinctUntilChanged() - - val isFortuneCreatingFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { it[Keys.FORTUNE_CREATING] ?: false } - .distinctUntilChanged() - - val isFortuneFailedFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { it[Keys.FORTUNE_FAILED] ?: false } - .distinctUntilChanged() - - val isFirstAlarmDismissedTodayFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { pref -> - val flag = pref[Keys.FIRST_ALARM_DISMISSED_TODAY] ?: false - val isToday = pref[Keys.FIRST_ALARM_DISMISSED_DATE_EPOCH] == todayEpoch() - flag && isToday - } - .distinctUntilChanged() - val updateNoticeDontShowVersionFlow: Flow = dataStore.data .catch { emit(emptyPreferences()) } .map { it[Keys.UPDATE_NOTICE_DONT_SHOW_VERSION] } @@ -136,61 +67,6 @@ class UserPreferences @Inject constructor( dataStore.edit { it[Keys.ONBOARDING_COMPLETED] = true } } - suspend fun markFortuneCreating() { - dataStore.edit { pref -> - pref[Keys.FORTUNE_CREATING] = true - pref[Keys.FORTUNE_FAILED] = false - } - } - - suspend fun markFortuneCreated(fortuneId: Long) { - dataStore.edit { pref -> - val today = todayEpoch() - val prevDate = pref[Keys.FORTUNE_DATE_EPOCH] - val isNewForToday = (pref[Keys.FORTUNE_ID] != fortuneId) || (prevDate != today) - - pref[Keys.FORTUNE_ID] = fortuneId - pref[Keys.FORTUNE_DATE_EPOCH] = today - pref[Keys.FORTUNE_CREATING] = false - pref[Keys.FORTUNE_FAILED] = false - - if (isNewForToday) { - pref[Keys.FORTUNE_SEEN] = false - pref[Keys.FORTUNE_TOOLTIP_SHOWN] = false - } - } - } - - suspend fun markFortuneFailed() { - dataStore.edit { pref -> - pref[Keys.FORTUNE_CREATING] = false - pref[Keys.FORTUNE_FAILED] = true - } - } - - suspend fun markFortuneSeen() { - dataStore.edit { it[Keys.FORTUNE_SEEN] = true } - } - - suspend fun markFortuneTooltipShown() { - dataStore.edit { it[Keys.FORTUNE_TOOLTIP_SHOWN] = true } - } - - suspend fun saveFortuneImageId(imageResId: Int) { - dataStore.edit { it[Keys.FORTUNE_IMAGE_ID] = imageResId } - } - - suspend fun saveFortuneScore(score: Int) { - dataStore.edit { it[Keys.FORTUNE_SCORE] = score } - } - - suspend fun markFirstAlarmDismissedToday() { - dataStore.edit { pref -> - pref[Keys.FIRST_ALARM_DISMISSED_TODAY] = true - pref[Keys.FIRST_ALARM_DISMISSED_DATE_EPOCH] = todayEpoch() - } - } - suspend fun markUpdateNoticeDontShow(version: String) { dataStore.edit { it[Keys.UPDATE_NOTICE_DONT_SHOW_VERSION] = version } } @@ -204,17 +80,4 @@ class UserPreferences @Inject constructor( suspend fun clearUserData() { dataStore.edit { it.clear() } } - - suspend fun clearFortuneData() { - dataStore.edit { pref -> - pref.remove(Keys.FORTUNE_ID) - pref.remove(Keys.FORTUNE_DATE_EPOCH) - pref.remove(Keys.FORTUNE_IMAGE_ID) - pref.remove(Keys.FORTUNE_SCORE) - pref.remove(Keys.FORTUNE_SEEN) - pref.remove(Keys.FORTUNE_TOOLTIP_SHOWN) - pref.remove(Keys.FORTUNE_CREATING) - pref.remove(Keys.FORTUNE_FAILED) - } - } } diff --git a/core/datastore/src/test/kotlin/com/yapp/datastore/FortunePreferencesTest.kt b/core/datastore/src/test/kotlin/com/yapp/datastore/FortunePreferencesTest.kt new file mode 100644 index 00000000..b9410965 --- /dev/null +++ b/core/datastore/src/test/kotlin/com/yapp/datastore/FortunePreferencesTest.kt @@ -0,0 +1,251 @@ +package com.yapp.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.time.Clock +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.UUID + +class FortunePreferencesTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val fixedZoneOffsetUtc = ZoneOffset.UTC + + private val baseInstantAtT0: Instant = Instant.parse("2025-09-17T00:00:00Z") + private val baseInstantAtT0Plus2Seconds: Instant = Instant.parse("2025-09-17T00:00:02Z") + + private val fixedClockAtT0: Clock = Clock.fixed(baseInstantAtT0, fixedZoneOffsetUtc) + private val fixedClockAtT0Plus2Seconds: Clock = + Clock.fixed(baseInstantAtT0Plus2Seconds, fixedZoneOffsetUtc) + + private val referenceInstantForAnyDay: Instant = Instant.parse("2025-09-17T00:00:00Z") + private val fixedClockForReferenceDay: Clock = + Clock.fixed(referenceInstantForAnyDay, fixedZoneOffsetUtc) + + private fun createNewDataStoreWithFile(fileName: String): DataStore = + PreferenceDataStoreFactory.create { + temporaryFolder.newFile(fileName) + } + + private fun createFortunePreferencesWithClock( + dataStore: DataStore, + fixedClock: Clock, + ): FortunePreferences = FortunePreferences(dataStore, fixedClock) + + @Test + fun `운세_생성_상태_Creating_만료_시_Failure로_교정된다`() = runTest { + // given: t0 시점에서 Creating(lease 1초) 설정 + val dataStoreAtT0 = createNewDataStoreWithFile("prefs_expire.preferences_pb") + val preferencesAtT0 = + createFortunePreferencesWithClock(dataStoreAtT0, fixedClockAtT0) + + val generatedAttemptId = UUID.randomUUID().toString() + preferencesAtT0.markFortuneCreating(attemptId = generatedAttemptId, lease = 1_000L) + + // when: t0 + 2초 경과 후 같은 DataStore를 새로운 Clock으로 읽음 + val preferencesAtT0Plus2Seconds = + createFortunePreferencesWithClock(dataStoreAtT0, fixedClockAtT0Plus2Seconds) + + // then: Creating → false, Failed → true + val creating = preferencesAtT0Plus2Seconds.isFortuneCreatingFlow.first() + assertEquals(false, creating) + val failed = preferencesAtT0Plus2Seconds.isFortuneFailedFlow.first() + assertEquals(true, failed) + } + + @Test + fun `만료_정보_없는_운세_생성_상태_Creating은_즉시_Failure로_교정된다`() = runTest { + // given: 과거 버전 데이터 (Creating=true만 존재) + val dataStoreWithLegacyCreating = createNewDataStoreWithFile("prefs_legacy.preferences_pb") + val keyCreatingOnly = booleanPreferencesKey("fortune_creating") + dataStoreWithLegacyCreating.edit { it[keyCreatingOnly] = true } + + val preferencesFromLegacyData = + createFortunePreferencesWithClock(dataStoreWithLegacyCreating, fixedClockForReferenceDay) + + // when: Failure로 교정 로직이 표시된 Flow 구독 시작 + + // then: 즉시 Creating → false, Failed → true + val creating = preferencesFromLegacyData.isFortuneCreatingFlow.first() + assertEquals(false, creating) + val failed = preferencesFromLegacyData.isFortuneFailedFlow.first() + assertEquals(true, failed) + } + + @Test + fun `생성_성공_시_attemptId가_일치할_때만_운세_생성_상태가_Creating에서_Success로_전환된다`() = runTest { + // given: 운세 Creating 상태 + val dataStore = createNewDataStoreWithFile("prefs_success.preferences_pb") + val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) + val validAttemptId = "ATTEMPT_VALID" + val invalidAttemptId = "ATTEMPT_INVALID" + val createdFortuneId = 99L + preferences.markFortuneCreating(attemptId = validAttemptId, lease = 60_000L) + + // when: 잘못된 attemptId로 생성 성공 처리 시도 + preferences.markFortuneCreatedIfAttemptMatches( + attemptId = invalidAttemptId, + fortuneId = createdFortuneId + ) + + // then: 여전히 Creating 상태 (무시됨) + val stillCreating = preferences.isFortuneCreatingFlow.first() + assertEquals(true, stillCreating) + + // when: 올바른 attemptId로 생성 성공 처리 시도 + preferences.markFortuneCreatedIfAttemptMatches( + attemptId = validAttemptId, + fortuneId = createdFortuneId + ) + + // then: Creating → false, Failed → false, fortuneId 및 날짜 설정 + val creatingAfterSuccess = preferences.isFortuneCreatingFlow.first() + assertEquals(false, creatingAfterSuccess) + val failedAfterSuccess = preferences.isFortuneFailedFlow.first() + assertEquals(false, failedAfterSuccess) + val savedId = preferences.fortuneIdFlow.first() + assertEquals(createdFortuneId, savedId) + val savedEpoch = preferences.fortuneDateEpochFlow.first() + assertEquals(LocalDate.now(fixedClockForReferenceDay).toEpochDay(), savedEpoch) + } + + @Test + fun `운세_생성_실패_시_attemptId가_일치할_때만_Failure로_전환된다`() = runTest { + // given: 운세 Creating 상태 + val dataStore = createNewDataStoreWithFile("prefs_fail.preferences_pb") + val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) + val validAttemptId = "ATTEMPT_VALID" + val invalidAttemptId = "ATTEMPT_INVALID" + preferences.markFortuneCreating(attemptId = validAttemptId, lease = 60_000L) + + // when: 잘못된 attemptId로 실패 처리 시도 + preferences.markFortuneFailedIfAttemptMatches(invalidAttemptId) + + // then: 아직 Creating 상태 (무시됨) + val stillCreating = preferences.isFortuneCreatingFlow.first() + assertEquals(true, stillCreating) + + // when: 올바른 attemptId로 실패 처리 시도 + preferences.markFortuneFailedIfAttemptMatches(validAttemptId) + + // then: Creating → false, Failed → true + val creatingAfterFail = preferences.isFortuneCreatingFlow.first() + assertEquals(false, creatingAfterFail) + val failed = preferences.isFortuneFailedFlow.first() + assertEquals(true, failed) + } + + @Test + fun `운세_생성_상태_Creating_만료_시_Success_처리는_거부되고_Failure로_교정된다`() = runTest { + // given: t0에서 Creating(lease 1초) 설정 + val dataStore = createNewDataStoreWithFile("prefs_expired_success_guard.preferences_pb") + val prefsAtT0 = createFortunePreferencesWithClock(dataStore, fixedClockAtT0) + + val attemptId = UUID.randomUUID().toString() + val fortuneId = 999L + prefsAtT0.markFortuneCreating(attemptId = attemptId, lease = 1_000L) + + // when: t0+2초(만료 이후)로 시계를 바꾸고, 같은 DataStore로 성공 처리 시도 + val prefsAtT0Plus2 = createFortunePreferencesWithClock(dataStore, fixedClockAtT0Plus2Seconds) + // 만료된 상태이므로, 아래 호출은 내부에서 return@edit 되어 성공 반영이 되면 안 된다. + prefsAtT0Plus2.markFortuneCreatedIfAttemptMatches( + attemptId = attemptId, + fortuneId = fortuneId + ) + + // then: 성공 반영이 거부되었으므로 fortuneId는 여전히 null이어야 한다 + val savedId = prefsAtT0Plus2.fortuneIdFlow.first() + assertEquals(null, savedId) + + // 그리고 isFortuneCreatingFlow 구독 시 만료 교정 로직이 작동하여 + // CREATING → false, FAILED → true 로 자동 교정되어야 한다. + val creatingAfter = prefsAtT0Plus2.isFortuneCreatingFlow.first() + assertEquals(false, creatingAfter) + + val failedAfter = prefsAtT0Plus2.isFortuneFailedFlow.first() + assertEquals(true, failedAfter) + } + + @Test + fun `오늘_운세가_있고_확인한_경우_hasUnseenFortune가_false`() = runTest { + // given: 오늘 운세가 생성되어 있고(미확인) + val dataStore = createNewDataStoreWithFile("prefs_seen.preferences_pb") + val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) + val attemptId = "ATTEMPT_FOR_SEEN" + val fortuneId = 777L + preferences.markFortuneCreating(attemptId = attemptId, lease = 60_000L) + preferences.markFortuneCreatedIfAttemptMatches(attemptId = attemptId, fortuneId = fortuneId) + + // when: 사용자가 오늘 운세를 확인 + preferences.markFortuneSeen() + + // then: hasUnseenFortune → false + val unseen = preferences.hasUnseenFortuneFlow.first() + assertEquals(false, unseen) + } + + @Test + fun `오늘_운세가_있고_아직_확인하지_않은_경우_hasUnseenFortune가_true`() = runTest { + // given: 오늘 운세가 생성되어 있는 상태(미확인) + val dataStore = createNewDataStoreWithFile("prefs_unseen.preferences_pb") + val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) + val attemptId = "ATTEMPT_FOR_UNSEEN" + val fortuneId = 123L + preferences.markFortuneCreating(attemptId = attemptId, lease = 60_000L) + preferences.markFortuneCreatedIfAttemptMatches(attemptId = attemptId, fortuneId = fortuneId) + + // when: hasUnseenFortuneFlow 구독 + + // then: 오늘 운세 존재 + 아직 읽지 않음 = hasUnseenFortune → true + val unseen = preferences.hasUnseenFortuneFlow.first() + assertEquals(true, unseen) + } + + @Test + fun `오늘_운세가_있고_Tooltip을_보여주었다면_shouldShowFortuneToolTip이_false`() = runTest { + // given: 오늘 운세가 생성되어 있는 상태(툴팁 미표시) + val dataStore = createNewDataStoreWithFile("prefs_tooltip_true.preferences_pb") + val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) + val attemptId = "ATTEMPT_FOR_TOOLTIP_TRUE" + val fortuneId = 888L + preferences.markFortuneCreating(attemptId = attemptId, lease = 60_000L) + preferences.markFortuneCreatedIfAttemptMatches(attemptId = attemptId, fortuneId = fortuneId) + + // when: ToolTip을 보여줌 + preferences.markFortuneTooltipShown() + + // then: shouldShowFortuneToolTip → false + val showTooltip = preferences.shouldShowFortuneToolTipFlow.first() + assertEquals(false, showTooltip) + } + + @Test + fun `오늘_운세가_있고_Tooltip을_아직_보여주지_않았다면_shouldShowFortuneToolTip이_true`() = runTest { + // given: 오늘 운세가 생성되어 있는 상태(툴팁 미표시) + val dataStore = createNewDataStoreWithFile("prefs_tooltip.preferences_pb") + val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) + val attemptId = "ATTEMPT_FOR_TOOLTIP" + val fortuneId = 456L + preferences.markFortuneCreating(attemptId = attemptId, lease = 60_000L) + preferences.markFortuneCreatedIfAttemptMatches(attemptId = attemptId, fortuneId = fortuneId) + + // when: shouldShowFortuneToolTipFlow 구독 + + // then: 오늘 운세 존재 + 툴팁 미표시 = shouldShowFortuneToolTip → true + val showTooltip = preferences.shouldShowFortuneToolTipFlow.first() + assertEquals(true, showTooltip) + } +} diff --git a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt index 4e519ddb..2cd0f60d 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt @@ -14,9 +14,9 @@ interface FortuneLocalDataSource { val fortuneCreateStatusFlow: Flow - suspend fun markFortuneCreating() - suspend fun markFortuneCreated(fortuneId: Long) - suspend fun markFortuneFailed() + suspend fun markFortuneCreating(attemptId: String, leaseMillis: Long) + suspend fun markFortuneCreated(attemptId: String, fortuneId: Long) + suspend fun markFortuneFailed(attemptId: String) suspend fun markFortuneSeen() suspend fun markFortuneTooltipShown() suspend fun saveFortuneImageId(imageResId: Int) diff --git a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt index b8ab799f..a3178e11 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt @@ -1,6 +1,6 @@ package com.yapp.data.local.datasource -import com.yapp.datastore.UserPreferences +import com.yapp.datastore.FortunePreferences import com.yapp.domain.model.FortuneCreateStatus import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -8,22 +8,22 @@ import java.time.LocalDate import javax.inject.Inject class FortuneLocalDataSourceImpl @Inject constructor( - private val userPreferences: UserPreferences, + private val fortunePreferences: FortunePreferences, ) : FortuneLocalDataSource { - override val fortuneIdFlow = userPreferences.fortuneIdFlow - override val fortuneDateEpochFlow = userPreferences.fortuneDateEpochFlow - override val fortuneImageIdFlow = userPreferences.fortuneImageIdFlow - override val fortuneScoreFlow = userPreferences.fortuneScoreFlow - override val hasUnseenFortuneFlow = userPreferences.hasUnseenFortuneFlow - override val shouldShowFortuneToolTipFlow = userPreferences.shouldShowFortuneToolTipFlow - override val isFirstAlarmDismissedTodayFlow = userPreferences.isFirstAlarmDismissedTodayFlow + override val fortuneIdFlow = fortunePreferences.fortuneIdFlow + override val fortuneDateEpochFlow = fortunePreferences.fortuneDateEpochFlow + override val fortuneImageIdFlow = fortunePreferences.fortuneImageIdFlow + override val fortuneScoreFlow = fortunePreferences.fortuneScoreFlow + override val hasUnseenFortuneFlow = fortunePreferences.hasUnseenFortuneFlow + override val shouldShowFortuneToolTipFlow = fortunePreferences.shouldShowFortuneToolTipFlow + override val isFirstAlarmDismissedTodayFlow = fortunePreferences.isFirstAlarmDismissedTodayFlow override val fortuneCreateStatusFlow = combine( - userPreferences.fortuneIdFlow, - userPreferences.fortuneDateEpochFlow, - userPreferences.isFortuneCreatingFlow, - userPreferences.isFortuneFailedFlow, + fortunePreferences.fortuneIdFlow, + fortunePreferences.fortuneDateEpochFlow, + fortunePreferences.isFortuneCreatingFlow, + fortunePreferences.isFortuneFailedFlow, ) { fortuneId, fortuneDate, isCreating, isFailed -> when { isFailed -> FortuneCreateStatus.Failure @@ -35,39 +35,39 @@ class FortuneLocalDataSourceImpl @Inject constructor( private fun todayEpoch(): Long = LocalDate.now().toEpochDay() - override suspend fun markFortuneCreating() { - userPreferences.markFortuneCreating() + override suspend fun markFortuneCreating(attemptId: String, leaseMillis: Long) { + fortunePreferences.markFortuneCreating(attemptId, leaseMillis) } - override suspend fun markFortuneCreated(fortuneId: Long) { - userPreferences.markFortuneCreated(fortuneId) + override suspend fun markFortuneCreated(attemptId: String, fortuneId: Long) { + fortunePreferences.markFortuneCreatedIfAttemptMatches(attemptId, fortuneId) } - override suspend fun markFortuneFailed() { - userPreferences.markFortuneFailed() + override suspend fun markFortuneFailed(attemptId: String) { + fortunePreferences.markFortuneFailedIfAttemptMatches(attemptId) } override suspend fun markFortuneSeen() { - userPreferences.markFortuneSeen() + fortunePreferences.markFortuneSeen() } override suspend fun markFortuneTooltipShown() { - userPreferences.markFortuneTooltipShown() + fortunePreferences.markFortuneTooltipShown() } override suspend fun saveFortuneImageId(imageResId: Int) { - userPreferences.saveFortuneImageId(imageResId) + fortunePreferences.saveFortuneImageId(imageResId) } override suspend fun saveFortuneScore(score: Int) { - userPreferences.saveFortuneScore(score) + fortunePreferences.saveFortuneScore(score) } override suspend fun markFirstAlarmDismissedToday() { - userPreferences.markFirstAlarmDismissedToday() + fortunePreferences.markFirstAlarmDismissedToday() } override suspend fun clearFortuneData() { - userPreferences.clearFortuneData() + fortunePreferences.clearFortuneData() } } diff --git a/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt b/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt index 1c761ba6..8849da81 100644 --- a/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt +++ b/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt @@ -24,9 +24,9 @@ class FortuneRepositoryImpl @Inject constructor( override val fortuneCreateStatusFlow: Flow = fortuneLocalDataSource.fortuneCreateStatusFlow - override suspend fun markFortuneAsCreating() = fortuneLocalDataSource.markFortuneCreating() - override suspend fun markFortuneAsCreated(fortuneId: Long) = fortuneLocalDataSource.markFortuneCreated(fortuneId) - override suspend fun markFortuneAsFailed() = fortuneLocalDataSource.markFortuneFailed() + override suspend fun markFortuneAsCreating(attemptId: String, leaseMillis: Long) = fortuneLocalDataSource.markFortuneCreating(attemptId, leaseMillis) + override suspend fun markFortuneAsCreated(attemptId: String, fortuneId: Long) = fortuneLocalDataSource.markFortuneCreated(attemptId, fortuneId) + override suspend fun markFortuneAsFailed(attemptId: String) = fortuneLocalDataSource.markFortuneFailed(attemptId) override suspend fun markFortuneSeen() = fortuneLocalDataSource.markFortuneSeen() override suspend fun markFortuneTooltipShown() = fortuneLocalDataSource.markFortuneTooltipShown() override suspend fun saveFortuneImageId(imageResId: Int) = fortuneLocalDataSource.saveFortuneImageId(imageResId) diff --git a/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt b/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt index 372fd5fe..7edc5396 100644 --- a/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt +++ b/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt @@ -15,9 +15,9 @@ interface FortuneRepository { val fortuneCreateStatusFlow: Flow - suspend fun markFortuneAsCreating() - suspend fun markFortuneAsCreated(fortuneId: Long) - suspend fun markFortuneAsFailed() + suspend fun markFortuneAsCreating(attemptId: String, leaseMillis: Long = 2 * 60_000L) + suspend fun markFortuneAsCreated(attemptId: String, fortuneId: Long) + suspend fun markFortuneAsFailed(attemptId: String) suspend fun markFortuneSeen() suspend fun markFortuneTooltipShown() suspend fun saveFortuneImageId(imageResId: Int) diff --git a/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt b/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt index 2dd14f72..d5feb56c 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt @@ -9,8 +9,10 @@ import com.yapp.domain.repository.FortuneRepository import com.yapp.domain.repository.UserInfoRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import java.util.UUID @HiltWorker class PostFortuneWorker @AssistedInject constructor( @@ -21,43 +23,44 @@ class PostFortuneWorker @AssistedInject constructor( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { + // 이미 진행 중이거나(다른 워커) 오늘 운세가 성공 상태면 중복 실행 방지 when (fortuneRepository.fortuneCreateStatusFlow.first()) { is FortuneCreateStatus.Creating, is FortuneCreateStatus.Success, - -> { - return Result.success() - } + -> return Result.success() FortuneCreateStatus.Failure, FortuneCreateStatus.Idle, - -> { - val userId = userInfoRepository.userIdFlow.firstOrNull() - ?: run { - // 사용자 없으면 실패 상태 표시 후 실패 반환 - fortuneRepository.markFortuneAsFailed() - return Result.failure() - } - - return try { - fortuneRepository.markFortuneAsCreating() - - val result = fortuneRepository.postFortune(userId) - result.fold( - onSuccess = { fortune -> - fortuneRepository.markFortuneAsCreated(fortune.id) - fortuneRepository.saveFortuneScore(fortune.avgFortuneScore) - Result.success() - }, - onFailure = { - fortuneRepository.markFortuneAsFailed() - // WM 백오프 규칙에 따라 재시도 - Result.retry() - }, - ) - } catch (_: Throwable) { - fortuneRepository.markFortuneAsFailed() - Result.retry() - } + -> { /* 계속 진행 */ } + } + + val userId = userInfoRepository.userIdFlow.firstOrNull() + ?: run { + return Result.failure() } + + val attemptId = UUID.randomUUID().toString() + + return try { + fortuneRepository.markFortuneAsCreating(attemptId) + + val result = fortuneRepository.postFortune(userId) + + result.fold( + onSuccess = { fortune -> + fortuneRepository.markFortuneAsCreated(attemptId, fortune.id) + fortuneRepository.saveFortuneScore(fortune.avgFortuneScore) + Result.success() + }, + onFailure = { + fortuneRepository.markFortuneAsFailed(attemptId) + Result.retry() + }, + ) + } catch (ce: CancellationException) { + throw ce + } catch (_: Throwable) { + fortuneRepository.markFortuneAsFailed(attemptId) + Result.retry() } } }