diff --git a/README.md b/README.md index 102650eb..7876386f 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ - `offline_cache_enabled`가 켜지면 [운동 기록 Repository](data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt)는 캘린더·목록·상세를 서버에서 갱신한 뒤 Room 스냅샷으로 반환합니다. 꺼지면 기존 서버 조회를 유지합니다. 최초 진입·새 날짜/월 선택·화면의 **새로고침**은 서버를 조회합니다. 복귀·앱 전경 전환은 첫 시작을 제외하고 Room만 한 번 다시 읽으며 선택 날짜·월·스크롤을 보존합니다. 재연결은 화면이나 작성 중인 폼을 교체하지 않습니다. - 오프라인에서는 저장된 결과(성공한 빈 목록 포함)를 읽고, 미조회 날짜는 네트워크 오류로 구분합니다. 온라인 갱신 실패는 저장된 화면을 유지하면서 한 번 알립니다. 계정별 로컬 ID와 동기화 대기 표시를 사용하며, 기존 생성·수정·삭제·이미지 작업은 여전히 온라인 전용입니다. 자신의 쓰기는 Room 목록·상세·날짜별 횟수를 함께 맞춥니다. 쓰기 후 캐시 우선 조회는 유효한 스냅샷이 없고 온라인일 때만 서버에서 다시 읽습니다. 서버 쓰기 성공 뒤 캐시 반영 실패는 성공 결과와 경고로 전달하며 쓰기를 재시도하지 않습니다. - Room 2→3은 빈 조회의 계정별 메타데이터 테이블만 추가하는 마이그레이션입니다. 플래그 OFF 쓰기 성공이나 캐시 반영 실패는 계정별 무효 표시를 동기 저장합니다. 표시를 저장한 다음 ON 조회는 동기화 완료 목록·상세·횟수·빈 조회 메타데이터를 한 트랜잭션으로 버린 뒤 표시를 지웁니다. 정리 실패 시 오래된 스냅샷을 반환하지 않으며 대기 행과 타이머는 보존합니다. 무효 표시 저장 자체가 실패하면 플래그와 무관하게 서버 쓰기 성공에 `CACHE_ERROR` 경고를 붙이고 서버 쓰기를 재시도하지 않습니다. 이 저장 실패 때에는 오래된 캐시가 남을 수 있습니다. 이미지 업로드 후 상세 조회 실패도 캐시 경고를 전달하고 무효 표시 저장을 시도합니다. +- 앱 시작 시 IO 코루틴이 `ClockProvider.now().minusMonths(6)`보다 이른 운동 횟수·목록·상세와 빈 응답 조회 마커를 한 트랜잭션으로 정리합니다. 기준일은 포함해 보관하며, 목록 없는 상세는 운동 시작 날짜로 판정합니다. 서버 ID가 있는 `SYNCED` 기록만 삭제하고, 같은 회원의 로컬/서버 ID로 연결된 대기·로컬 전용·보관 기간 안의 기록과 타이머는 보존합니다. 정리는 앱 시작을 기다리게 하지 않으며 실패해도 앱 초기화를 중단하지 않습니다. - CI 단위 테스트 명령에는 Kotlin/JVM 모듈인 `:domain:test`가 포함되어 있지 않습니다. 로컬 전체 단위 테스트 안내에는 이를 별도로 포함했습니다. Maestro도 현재 CI에서 실행하지 않습니다. - `dev` 워크플로우에는 배포를 push로 제한하는 조건이 없어 PR에도 배포 단계가 있습니다. 배포 구성은 공개 출시 여부나 실제 배포 성공을 뜻하지 않습니다. diff --git a/app/src/main/java/com/project200/undabang/ApplicationClass.kt b/app/src/main/java/com/project200/undabang/ApplicationClass.kt index 05c85be6..d732708e 100644 --- a/app/src/main/java/com/project200/undabang/ApplicationClass.kt +++ b/app/src/main/java/com/project200/undabang/ApplicationClass.kt @@ -9,7 +9,15 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import com.google.firebase.crashlytics.FirebaseCrashlytics import com.kakao.vectormap.KakaoMapSdk +import com.project200.common.di.IoDispatcher +import com.project200.domain.manager.ExerciseCacheCleaner import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -18,6 +26,15 @@ class ApplicationClass : Application(), Configuration.Provider { @Inject lateinit var workerFactory: HiltWorkerFactory + @Inject + lateinit var exerciseCacheCleaner: ExerciseCacheCleaner + + @Inject + @IoDispatcher + lateinit var ioDispatcher: CoroutineDispatcher + + private val applicationScope by lazy { CoroutineScope(SupervisorJob() + ioDispatcher) } + /** WorkManager가 Hilt로 Worker를 생성하도록 팩토리를 넘깁니다 */ override val workManagerConfiguration: Configuration get() = @@ -40,6 +57,8 @@ class ApplicationClass : Application(), Configuration.Provider { // Crashlytics 디버그 모드에서는 비활성화 FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG) + + applicationScope.launchExerciseCacheCleanup(exerciseCacheCleaner) } /** @@ -58,3 +77,15 @@ class ApplicationClass : Application(), Configuration.Provider { notificationManager.createNotificationChannel(channel) } } + +/** 앱 초기화는 정리를 기다리지 않습니다. 일반 실패는 격리하고 취소는 작업의 취소 상태로 남깁니다 */ +internal fun CoroutineScope.launchExerciseCacheCleanup(cleaner: ExerciseCacheCleaner): Job = + launch { + try { + cleaner.cleanExpiredCache() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "오래된 운동 캐시 정리 실패") + } + } diff --git a/app/src/test/java/com/project200/undabang/ExerciseCacheCleanupTest.kt b/app/src/test/java/com/project200/undabang/ExerciseCacheCleanupTest.kt new file mode 100644 index 00000000..addf62b6 --- /dev/null +++ b/app/src/test/java/com/project200/undabang/ExerciseCacheCleanupTest.kt @@ -0,0 +1,108 @@ +package com.project200.undabang + +import com.google.common.truth.Truth.assertThat +import com.project200.domain.manager.ExerciseCacheCleaner +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@ExperimentalCoroutinesApi +class ExerciseCacheCleanupTest { + @Test + fun `시작 예약은 정리를 기다리지 않고 백그라운드에서 완료한다`() = + runTest { + val release = CompletableDeferred() + var started = false + var completed = false + val cleaner = + object : ExerciseCacheCleaner { + override suspend fun cleanExpiredCache() { + started = true + release.await() + completed = true + } + } + + val job = launchExerciseCacheCleanup(cleaner) + + assertThat(started).isFalse() + assertThat(completed).isFalse() + runCurrent() + assertThat(started).isTrue() + assertThat(completed).isFalse() + assertThat(job.isActive).isTrue() + release.complete(Unit) + job.join() + assertThat(completed).isTrue() + assertThat(job.isCancelled).isFalse() + } + + @Test + fun `정리 예외는 시작 호출자에게 실패나 취소로 전파하지 않는다`() = + runTest { + val cleaner = + object : ExerciseCacheCleaner { + override suspend fun cleanExpiredCache() { + throw IllegalStateException("정리 실패") + } + } + + val job = launchExerciseCacheCleanup(cleaner) + job.join() + + assertThat(job.isCompleted).isTrue() + assertThat(job.isCancelled).isFalse() + assertThat(coroutineContext[Job]?.isActive).isTrue() + } + + @Test + fun `정리의 취소 예외를 성공으로 삼키지 않는다`() = + runTest { + val expected = CancellationException("정리 취소") + var completionCause: Throwable? = null + val cleaner = + object : ExerciseCacheCleaner { + override suspend fun cleanExpiredCache() { + throw expected + } + } + + val job = launchExerciseCacheCleanup(cleaner) + job.invokeOnCompletion { completionCause = it } + job.join() + + assertThat(job.isCancelled).isTrue() + assertThat(completionCause).isSameInstanceAs(expected) + assertThat(coroutineContext[Job]?.isActive).isTrue() + } + + @Test + fun `정리 작업 취소는 실행 중인 정리에도 전달한다`() = + runTest { + var cancelled = false + val cleaner = + object : ExerciseCacheCleaner { + override suspend fun cleanExpiredCache() { + try { + awaitCancellation() + } finally { + cancelled = true + } + } + } + + val job = launchExerciseCacheCleanup(cleaner) + runCurrent() + job.cancel() + job.join() + + assertThat(cancelled).isTrue() + assertThat(job.isCancelled).isTrue() + assertThat(coroutineContext[Job]?.isActive).isTrue() + } +} diff --git a/data/src/main/java/com/project200/data/di/DatabaseModule.kt b/data/src/main/java/com/project200/data/di/DatabaseModule.kt index efaf6aa7..4edb27f3 100644 --- a/data/src/main/java/com/project200/data/di/DatabaseModule.kt +++ b/data/src/main/java/com/project200/data/di/DatabaseModule.kt @@ -4,12 +4,14 @@ import android.content.Context import androidx.room.Room import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.project200.data.impl.RoomExerciseCacheCleaner import com.project200.data.impl.RoomSessionDataCleaner import com.project200.data.local.UndabangDatabase import com.project200.data.local.UndabangTypeConverters import com.project200.data.local.dao.ExerciseCountDao import com.project200.data.local.dao.ExerciseRecordDao import com.project200.data.local.dao.TimerDao +import com.project200.domain.manager.ExerciseCacheCleaner import com.project200.domain.manager.SessionDataCleaner import com.squareup.moshi.Moshi import dagger.Binds @@ -27,6 +29,10 @@ abstract class DatabaseModule { @Singleton abstract fun bindSessionDataCleaner(roomSessionDataCleaner: RoomSessionDataCleaner): SessionDataCleaner + @Binds + @Singleton + abstract fun bindExerciseCacheCleaner(roomExerciseCacheCleaner: RoomExerciseCacheCleaner): ExerciseCacheCleaner + companion object { /** 메타데이터만 추가합니다. 기존 계정·대기 행·타이머는 재생성하지 않습니다. */ val MIGRATION_2_3 = diff --git a/data/src/main/java/com/project200/data/impl/RoomExerciseCacheCleaner.kt b/data/src/main/java/com/project200/data/impl/RoomExerciseCacheCleaner.kt new file mode 100644 index 00000000..bea513ed --- /dev/null +++ b/data/src/main/java/com/project200/data/impl/RoomExerciseCacheCleaner.kt @@ -0,0 +1,37 @@ +package com.project200.data.impl + +import androidx.room.withTransaction +import com.project200.common.di.IoDispatcher +import com.project200.common.utils.ClockProvider +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.dao.ExerciseCountDao +import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.domain.manager.ExerciseCacheCleaner +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RoomExerciseCacheCleaner + @Inject + constructor( + private val database: UndabangDatabase, + private val exerciseCountDao: ExerciseCountDao, + private val exerciseRecordDao: ExerciseRecordDao, + private val clockProvider: ClockProvider, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + ) : ExerciseCacheCleaner { + override suspend fun cleanExpiredCache() = + withContext(ioDispatcher) { + val cutoff = clockProvider.now().minusMonths(6) + val cutoffStart = cutoff.atStartOfDay() + // 모든 회원의 캐시를 정리하되, 짝을 찾는 SQL은 같은 회원 안에서만 비교합니다 + database.withTransaction { + exerciseRecordDao.deleteExpiredSyncedDetails(cutoff, cutoffStart) + exerciseRecordDao.deleteExpiredSyncedListItems(cutoff, cutoffStart) + exerciseRecordDao.deleteExpiredQueryMarkers(cutoff) + exerciseCountDao.deleteBefore(cutoff) + } + } + } diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt index 7588d9cf..de293ec7 100644 --- a/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt @@ -36,4 +36,8 @@ interface ExerciseCountDao { startDate: LocalDate, endDate: LocalDate, ) + + /** 횟수는 서버 응답 캐시이므로 기준일 이전의 값만 정리합니다 */ + @Query("DELETE FROM exercise_count WHERE date < :cutoff") + suspend fun deleteBefore(cutoff: LocalDate) } diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt index 4138e31d..662ffa5d 100644 --- a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt @@ -8,6 +8,7 @@ import com.project200.data.local.entity.ExerciseListItemEntity import com.project200.data.local.entity.ExerciseRecordDetailEntity import com.project200.data.local.entity.SyncState import java.time.LocalDate +import java.time.LocalDateTime @Dao interface ExerciseRecordDao { @@ -207,6 +208,56 @@ interface ExerciseRecordDao { memberId: String, localId: String, ) + + /** + * 오래된 서버 캐시만 지웁니다. 같은 회원의 같은 로컬/서버 ID에 대기·로컬 전용 행이나 + * 보관 기간 안의 행이 있으면 목록과 상세를 모두 보존합니다. + * 상세 정리와 같은 트랜잭션에서 호출해야 합니다 + */ + @Query( + "DELETE FROM exercise_list_item " + + "WHERE date < :cutoff AND serverId IS NOT NULL AND syncState = :synced " + + "AND NOT EXISTS (" + + "SELECT 1 FROM exercise_list_item AS other " + + "WHERE other.memberId = exercise_list_item.memberId " + + "AND (other.localId = exercise_list_item.localId OR other.serverId = exercise_list_item.serverId) " + + "AND (other.syncState != :synced OR other.serverId IS NULL OR other.date >= :cutoff)) " + + "AND NOT EXISTS (" + + "SELECT 1 FROM exercise_record_detail AS detail " + + "WHERE detail.memberId = exercise_list_item.memberId " + + "AND (detail.localId = exercise_list_item.localId OR detail.serverId = exercise_list_item.serverId) " + + "AND (detail.syncState != :synced OR detail.serverId IS NULL OR detail.startedAt >= :cutoffStart))", + ) + suspend fun deleteExpiredSyncedListItems( + cutoff: LocalDate, + cutoffStart: LocalDateTime, + synced: SyncState = SyncState.SYNCED, + ) + + /** 목록 없이 상세만 캐시한 기록도 시작 날짜로 정리합니다. 대기 중인 짝은 보존합니다 */ + @Query( + "DELETE FROM exercise_record_detail " + + "WHERE startedAt < :cutoffStart AND serverId IS NOT NULL AND syncState = :synced " + + "AND NOT EXISTS (" + + "SELECT 1 FROM exercise_record_detail AS other " + + "WHERE other.memberId = exercise_record_detail.memberId " + + "AND (other.localId = exercise_record_detail.localId OR other.serverId = exercise_record_detail.serverId) " + + "AND (other.syncState != :synced OR other.serverId IS NULL OR other.startedAt >= :cutoffStart)) " + + "AND NOT EXISTS (" + + "SELECT 1 FROM exercise_list_item AS item " + + "WHERE item.memberId = exercise_record_detail.memberId " + + "AND (item.localId = exercise_record_detail.localId OR item.serverId = exercise_record_detail.serverId) " + + "AND (item.syncState != :synced OR item.serverId IS NULL OR item.date >= :cutoff))", + ) + suspend fun deleteExpiredSyncedDetails( + cutoff: LocalDate, + cutoffStart: LocalDateTime, + synced: SyncState = SyncState.SYNCED, + ) + + /** 오래된 빈 응답이 캐시 적중으로 남지 않도록 목록·횟수 조회 마커도 함께 지웁니다 */ + @Query("DELETE FROM exercise_cache_query WHERE date < :cutoff") + suspend fun deleteExpiredQueryMarkers(cutoff: LocalDate) } /** 서버 ID와 로컬 ID의 짝입니다 */ diff --git a/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt b/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt index d460614e..f54f7093 100644 --- a/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt +++ b/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt @@ -5,6 +5,9 @@ import com.google.common.truth.Truth.assertThat import com.project200.data.local.UndabangDatabase import com.project200.data.local.UndabangTypeConverters import com.project200.data.local.entity.ExerciseCountEntity +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.SimpleTimerEntity +import com.project200.data.local.entity.SyncState import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import kotlinx.coroutines.test.runTest @@ -17,8 +20,8 @@ import java.time.LocalDate /** * 출시 전 스키마 정책을 검증합니다. * - * 파일의 version을 낮춰 구버전 DB를 만든 뒤 다시 열어, 마이그레이션 없이도 - * 크래시하지 않고 재생성되는지 봅니다 + * 2→3은 조회 마커만 추가하고 기존 데이터를 보존합니다. + * 미지원 파일 버전은 재생성하고, 같은 버전은 그대로 다시 엽니다 */ @RunWith(RobolectricTestRunner::class) class SchemaRecreatePolicyTest { @@ -74,4 +77,62 @@ class SchemaRecreatePolicyTest { assertThat(counts.single().count).isEqualTo(3) database.close() } + + @Test + fun `2에서 3 마이그레이션은 대기 운동과 타이머를 보존하고 조회 마커만 추가한다`() = + runTest { + var database = openDatabase() + val pendingRecord = + ExerciseListItemEntity( + memberId = "member-a", + localId = "pending-exercise", + serverId = null, + syncState = SyncState.CREATE_PENDING, + date = date, + sortOrder = 0, + title = "대기 운동", + personalType = "WEIGHT", + startedAt = date.atTime(10, 0), + endedAt = date.atTime(11, 0), + thumbnailUrls = null, + ) + val pendingTimer = + SimpleTimerEntity( + memberId = "member-a", + localId = "pending-timer", + serverId = null, + syncState = SyncState.CREATE_PENDING, + time = 60, + createRequestId = "timer-create", + pendingEditId = null, + editedAt = 1L, + ) + database.exerciseRecordDao().upsertListItems(listOf(pendingRecord)) + database.timerDao().upsertSimpleTimer(pendingTimer) + database.exerciseCountDao().upsertAll(listOf(ExerciseCountEntity("member-a", date, 2))) + database.close() + + // 2→3의 유일한 스키마 차이인 조회 마커 테이블을 제거해 실제 이전 파일을 재현합니다 + val path = context.getDatabasePath(UndabangDatabase.DATABASE_NAME).absolutePath + SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { raw -> + raw.execSQL("DROP TABLE exercise_cache_query") + raw.version = 2 + } + database = openDatabase() + + assertThat(database.openHelper.readableDatabase.version).isEqualTo(3) + assertThat(database.exerciseRecordDao().getListByDate("member-a", date)).containsExactly(pendingRecord) + assertThat(database.timerDao().getSimpleTimer("member-a", "pending-timer")).isEqualTo(pendingTimer) + assertThat(database.exerciseCountDao().getRange("member-a", date, date)) + .containsExactly(ExerciseCountEntity("member-a", date, 2)) + database.openHelper.writableDatabase.execSQL( + "INSERT INTO exercise_cache_query(memberId, queryType, date) VALUES (?, ?, ?)", + arrayOf("member-a", "list", date.toString()), + ) + database.openHelper.readableDatabase.query("SELECT queryType FROM exercise_cache_query").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(0)).isEqualTo("list") + } + database.close() + } } diff --git a/data/src/test/java/com/project200/data/impl/RoomExerciseCacheCleanerTest.kt b/data/src/test/java/com/project200/data/impl/RoomExerciseCacheCleanerTest.kt new file mode 100644 index 00000000..bcb5b3ba --- /dev/null +++ b/data/src/test/java/com/project200/data/impl/RoomExerciseCacheCleanerTest.kt @@ -0,0 +1,458 @@ +package com.project200.data.impl + +import com.google.common.truth.Truth.assertThat +import com.project200.common.utils.ClockProvider +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.createInMemoryDatabase +import com.project200.data.local.dao.ExerciseCountDao +import com.project200.data.local.entity.CachedTimerStep +import com.project200.data.local.entity.CustomTimerEntity +import com.project200.data.local.entity.ExerciseCacheQueryEntity +import com.project200.data.local.entity.ExerciseCountEntity +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.ExerciseRecordDetailEntity +import com.project200.data.local.entity.SimpleTimerEntity +import com.project200.data.local.entity.SyncState +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class RoomExerciseCacheCleanerTest { + private lateinit var database: UndabangDatabase + private val clockProvider = mockk() + private val testDispatcher = StandardTestDispatcher() + private val today = LocalDate.parse("2026-08-31") + private val cutoff = LocalDate.parse("2026-02-28") + private val oldDate = cutoff.minusDays(1) + + @Before + fun setUp() { + database = createInMemoryDatabase() + every { clockProvider.now() } returns today + } + + @After + fun tearDown() { + database.close() + } + + private fun cleaner(countDao: ExerciseCountDao = database.exerciseCountDao()) = + RoomExerciseCacheCleaner( + database = database, + exerciseCountDao = countDao, + exerciseRecordDao = database.exerciseRecordDao(), + clockProvider = clockProvider, + ioDispatcher = testDispatcher, + ) + + private fun listItem( + localId: String, + serverId: Long?, + syncState: SyncState = SyncState.SYNCED, + date: LocalDate = oldDate, + memberId: String = "member-a", + ) = ExerciseListItemEntity( + memberId = memberId, + localId = localId, + serverId = serverId, + syncState = syncState, + date = date, + sortOrder = 0, + title = "제목 $localId", + personalType = "WEIGHT", + startedAt = date.atTime(10, 0), + endedAt = date.atTime(11, 0), + thumbnailUrls = null, + ) + + private fun detail( + localId: String, + serverId: Long?, + syncState: SyncState = SyncState.SYNCED, + date: LocalDate = oldDate, + memberId: String = "member-a", + ) = ExerciseRecordDetailEntity( + memberId = memberId, + localId = localId, + serverId = serverId, + syncState = syncState, + title = "상세 $localId", + detail = "본문", + personalType = "WEIGHT", + startedAt = date.atStartOfDay(), + endedAt = date.atTime(1, 0), + location = "헬스장", + pictures = null, + ) + + private suspend fun insertCachedRecord( + localId: String = "old", + serverId: Long = 1L, + date: LocalDate = oldDate, + ) { + database.exerciseRecordDao().upsertListItems(listOf(listItem(localId, serverId, date = date))) + database.exerciseRecordDao().upsertDetail(detail(localId, serverId, date = date)) + database.exerciseCountDao().upsertAll(listOf(ExerciseCountEntity("member-a", date, 1))) + saveCacheQuery(ExerciseCacheQueryEntity("member-a", "list", date)) + saveCacheQuery(ExerciseCacheQueryEntity("member-a", "count", date)) + } + + // #584의 조회/저장 API를 복사하지 않고 공유 스키마의 실제 빈 응답 마커를 준비합니다 + private fun saveCacheQuery(query: ExerciseCacheQueryEntity) { + database.openHelper.writableDatabase.execSQL( + "INSERT OR REPLACE INTO exercise_cache_query(memberId, queryType, date) VALUES (?, ?, ?)", + arrayOf(query.memberId, query.queryType, query.date.toString()), + ) + } + + private fun hasCacheQuery(query: ExerciseCacheQueryEntity): Boolean = + database.openHelper.readableDatabase.query( + "SELECT COUNT(*) FROM exercise_cache_query WHERE memberId = ? AND queryType = ? AND date = ?", + arrayOf(query.memberId, query.queryType, query.date.toString()), + ).use { cursor -> + cursor.moveToFirst() && cursor.getInt(0) > 0 + } + + @Test + fun `기록 없는 성공 조회 마커도 모든 회원의 기준일 이전 값만 만료한다`() = + runTest(testDispatcher) { + val markers = + listOf("member-a", "member-b").flatMap { memberId -> + listOf("list", "count").flatMap { type -> + listOf(oldDate, cutoff, cutoff.plusDays(1)).map { date -> + ExerciseCacheQueryEntity(memberId, type, date) + } + } + } + markers.forEach { marker -> + saveCacheQuery(marker) + assertThat(hasCacheQuery(marker)).isTrue() + assertThat(database.exerciseRecordDao().getListByDate(marker.memberId, marker.date)).isEmpty() + assertThat(database.exerciseCountDao().getRange(marker.memberId, marker.date, marker.date)).isEmpty() + } + + cleaner().cleanExpiredCache() + + markers.forEach { marker -> + assertThat(hasCacheQuery(marker)).isEqualTo(!marker.date.isBefore(cutoff)) + } + } + + @Test + fun `월말에서 6개월을 뺀 기준일은 남기고 그 이전 횟수 목록 상세만 삭제한다`() = + runTest(testDispatcher) { + // 180일 전이 아니라 8월 31일에서 달력상 6개월 전인 2월 28일이 기준이다 + val dates = listOf(oldDate, cutoff, cutoff.plusDays(1), today, today.plusDays(1)) + dates.forEachIndexed { index, date -> + insertCachedRecord(localId = date.toString(), serverId = index.toLong(), date = date) + } + + cleaner().cleanExpiredCache() + + val expectedDates = dates.drop(1) + assertThat(database.exerciseCountDao().getRange("member-a", oldDate, today.plusDays(1)).map { it.date }) + .containsExactlyElementsIn(expectedDates).inOrder() + dates.forEach { date -> + val rows = database.exerciseRecordDao().getListByDate("member-a", date) + val savedDetail = database.exerciseRecordDao().getDetailByLocalId("member-a", date.toString()) + if (date == oldDate) { + assertThat(rows).isEmpty() + assertThat(savedDetail).isNull() + } else { + assertThat(rows.map { it.localId }).containsExactly(date.toString()) + assertThat(savedDetail).isEqualTo(detail(date.toString(), dates.indexOf(date).toLong(), date = date)) + } + } + } + + @Test + fun `윤년 월말은 2월 29일을 포함해 보관한다`() = + runTest(testDispatcher) { + every { clockProvider.now() } returns LocalDate.parse("2024-08-31") + val leapCutoff = LocalDate.parse("2024-02-29") + insertCachedRecord("expired", 1L, leapCutoff.minusDays(1)) + insertCachedRecord("boundary", 2L, leapCutoff) + + cleaner().cleanExpiredCache() + + assertThat(database.exerciseRecordDao().getDetailByLocalId("member-a", "expired")).isNull() + assertThat(database.exerciseRecordDao().getDetailByLocalId("member-a", "boundary")).isNotNull() + assertThat(database.exerciseCountDao().getRange("member-a", leapCutoff.minusDays(1), leapCutoff).map { it.date }) + .containsExactly(leapCutoff) + } + + @Test + fun `목록 없는 상세도 시작 시각이 기준일 자정보다 이르면 삭제한다`() = + runTest(testDispatcher) { + val dao = database.exerciseRecordDao() + dao.upsertDetail(detail("expired-detail", 1L).copy(startedAt = cutoff.atStartOfDay().minusNanos(1))) + val boundary = detail("boundary-detail", 2L, date = cutoff) + val future = detail("future-detail", 3L, date = today.plusDays(1)) + dao.upsertDetail(boundary) + dao.upsertDetail(future) + + cleaner().cleanExpiredCache() + + assertThat(dao.getDetailByLocalId("member-a", "expired-detail")).isNull() + assertThat(dao.getDetailByLocalId("member-a", "boundary-detail")).isEqualTo(boundary) + assertThat(dao.getDetailByLocalId("member-a", "future-detail")).isEqualTo(future) + } + + @Test + fun `모든 대기 상태와 서버 ID 없는 목록 상세는 값 그대로 남는다`() = + runTest(testDispatcher) { + val states = SyncState.entries.filterNot { it == SyncState.SYNCED } + val lists = + states.mapIndexed { index, state -> listItem(state.name, index.toLong(), state) } + + listItem("local-only", null) + listItem("creating-local", null, SyncState.CREATE_PENDING) + val details = + states.mapIndexed { index, state -> detail(state.name, index.toLong(), state) } + + detail("local-only", null) + detail("creating-local", null, SyncState.CREATE_PENDING) + val dao = database.exerciseRecordDao() + dao.upsertListItems(lists) + details.forEach { dao.upsertDetail(it) } + + cleaner().cleanExpiredCache() + + // excluded를 바꿔 DELETE_PENDING 행도 실제 DB에 남았는지 확인한다 + assertThat(dao.getListByDate("member-a", oldDate, excluded = SyncState.SYNCED)) + .containsExactlyElementsIn(lists.filterNot { it.syncState == SyncState.SYNCED }) + assertThat(dao.getListByDate("member-a", oldDate).single { it.localId == "local-only" }) + .isEqualTo(lists.single { it.localId == "local-only" }) + details.forEach { assertThat(dao.getDetailByLocalId("member-a", it.localId)).isEqualTo(it) } + } + + @Test + fun `대기 상세의 같은 로컬 ID나 서버 ID로 연결된 동기화 목록을 보존한다`() = + runTest(testDispatcher) { + val dao = database.exerciseRecordDao() + val lists = listOf(listItem("same-local", 1L), listItem("list-server", 2L)) + val details = + listOf( + detail("same-local", null, SyncState.CREATE_PENDING), + detail("detail-server", 2L, SyncState.DELETE_PENDING), + ) + dao.upsertListItems(lists) + details.forEach { dao.upsertDetail(it) } + + cleaner().cleanExpiredCache() + + assertThat(dao.getListByDate("member-a", oldDate)).containsExactlyElementsIn(lists) + details.forEach { assertThat(dao.getDetailByLocalId("member-a", it.localId)).isEqualTo(it) } + } + + @Test + fun `대기 목록의 같은 로컬 ID나 서버 ID로 연결된 동기화 상세를 보존한다`() = + runTest(testDispatcher) { + val dao = database.exerciseRecordDao() + val lists = + listOf( + listItem("same-local", null, SyncState.CREATE_PENDING), + listItem("list-server", 2L, SyncState.UPDATE_PENDING), + ) + val details = listOf(detail("same-local", 1L), detail("detail-server", 2L)) + dao.upsertListItems(lists) + details.forEach { dao.upsertDetail(it) } + + cleaner().cleanExpiredCache() + + assertThat(dao.getListByDate("member-a", oldDate)).containsExactlyElementsIn(lists) + details.forEach { assertThat(dao.getDetailByLocalId("member-a", it.localId)).isEqualTo(it) } + } + + @Test + fun `다른 로컬 ID로 남은 같은 서버 기록의 대기 행도 캐시 삭제를 막는다`() = + runTest(testDispatcher) { + val dao = database.exerciseRecordDao() + val lists = listOf(listItem("cached-list", 1L), listItem("pending-list", 1L, SyncState.DELETE_PENDING)) + val details = listOf(detail("cached-detail", 2L), detail("pending-detail", 2L, SyncState.UPDATE_PENDING)) + dao.upsertListItems(lists) + details.forEach { dao.upsertDetail(it) } + + cleaner().cleanExpiredCache() + + assertThat(dao.getListByDate("member-a", oldDate)).containsExactly(lists.first()) + assertThat(dao.getListByDate("member-a", oldDate, excluded = SyncState.SYNCED)).containsExactly(lists.last()) + details.forEach { assertThat(dao.getDetailByLocalId("member-a", it.localId)).isEqualTo(it) } + } + + @Test + fun `기준일 안쪽의 짝이 있으면 오래된 목록이나 상세도 보존한다`() = + runTest(testDispatcher) { + val dao = database.exerciseRecordDao() + val oldList = listItem("old-list", 1L) + val currentList = listItem("current-list", 2L, date = cutoff) + val currentDetail = detail("old-list", 1L, date = cutoff) + val oldDetail = detail("current-list", 2L) + dao.upsertListItems(listOf(oldList, currentList)) + dao.upsertDetail(currentDetail) + dao.upsertDetail(oldDetail) + + cleaner().cleanExpiredCache() + + assertThat(dao.getListByDate("member-a", oldDate)).containsExactly(oldList) + assertThat(dao.getListByDate("member-a", cutoff)).containsExactly(currentList) + assertThat(dao.getDetailByLocalId("member-a", "old-list")).isEqualTo(currentDetail) + assertThat(dao.getDetailByLocalId("member-a", "current-list")).isEqualTo(oldDetail) + } + + @Test + fun `짝 보호는 회원 안에서만 적용하고 다른 회원의 오래된 캐시는 독립적으로 정리한다`() = + runTest(testDispatcher) { + val dao = database.exerciseRecordDao() + val pendingList = listItem("shared-list", 1L, SyncState.UPDATE_PENDING) + val pendingDetail = detail("shared-detail", 2L, SyncState.DELETE_PENDING) + dao.upsertListItems(listOf(pendingList, pendingList.copy(memberId = "member-b", syncState = SyncState.SYNCED))) + dao.upsertDetail(pendingDetail) + dao.upsertDetail(pendingDetail.copy(memberId = "member-b", syncState = SyncState.SYNCED)) + // 다른 회원의 대기 상세/목록도 삭제 대상의 짝으로 취급하면 안 된다 + dao.upsertDetail(detail("shared-list", 1L, memberId = "member-b")) + dao.upsertListItems(listOf(listItem("shared-detail", 2L, memberId = "member-b"))) + + cleaner().cleanExpiredCache() + + assertThat(dao.getListByDate("member-a", oldDate)).containsExactly(pendingList) + assertThat(dao.getDetailByLocalId("member-a", "shared-detail")).isEqualTo(pendingDetail) + assertThat(dao.getListByDate("member-b", oldDate)).isEmpty() + assertThat(dao.getDetailByLocalId("member-b", "shared-list")).isNull() + assertThat(dao.getDetailByLocalId("member-b", "shared-detail")).isNull() + } + + @Test + fun `동기화 표시라도 서버 ID 없는 짝이 있으면 양쪽 캐시를 보존한다`() = + runTest(testDispatcher) { + val dao = database.exerciseRecordDao() + val lists = listOf(listItem("local-detail", 1L), listItem("local-list", null)) + val details = listOf(detail("local-detail", null), detail("local-list", 2L)) + dao.upsertListItems(lists) + details.forEach { dao.upsertDetail(it) } + + cleaner().cleanExpiredCache() + + assertThat(dao.getListByDate("member-a", oldDate)).containsExactlyElementsIn(lists) + details.forEach { assertThat(dao.getDetailByLocalId("member-a", it.localId)).isEqualTo(it) } + } + + @Test + fun `같은 서버 ID의 보관 기간 안쪽 행은 다른 로컬 ID의 오래된 캐시도 보존한다`() = + runTest(testDispatcher) { + val dao = database.exerciseRecordDao() + val oldList = listItem("old-list", 1L) + val currentList = listItem("new-list", 1L, date = cutoff) + val oldDetail = detail("old-detail", 2L) + val currentDetail = detail("new-detail", 2L, date = cutoff) + dao.upsertListItems(listOf(oldList, currentList)) + dao.upsertDetail(oldDetail) + dao.upsertDetail(currentDetail) + + cleaner().cleanExpiredCache() + + assertThat(dao.getListByDate("member-a", oldDate)).containsExactly(oldList) + assertThat(dao.getListByDate("member-a", cutoff)).containsExactly(currentList) + assertThat(dao.getDetailByLocalId("member-a", "old-detail")).isEqualTo(oldDetail) + assertThat(dao.getDetailByLocalId("member-a", "new-detail")).isEqualTo(currentDetail) + } + + @Test + fun `오래된 단순 커스텀 타이머는 동기화 상태와 관계없이 보존한다`() = + runTest(testDispatcher) { + insertCachedRecord() + val timerDao = database.timerDao() + val simpleTimers = + SyncState.entries.map { state -> + SimpleTimerEntity( + memberId = "member-a", + localId = state.name, + serverId = 1L, + syncState = state, + time = 60, + createRequestId = "create-${state.name}", + pendingEditId = "edit-${state.name}", + editedAt = 0L, + ) + } + val customTimers = + SyncState.entries.map { state -> + CustomTimerEntity( + memberId = "member-a", + localId = state.name, + serverId = 2L, + syncState = state, + name = "타이머 ${state.name}", + steps = listOf(CachedTimerStep(order = 0, name = "운동", time = 30)), + createRequestId = "create-${state.name}", + pendingEditId = "edit-${state.name}", + editedAt = 0L, + ) + } + simpleTimers.forEach { timerDao.upsertSimpleTimer(it) } + customTimers.forEach { timerDao.upsertCustomTimer(it) } + + cleaner().cleanExpiredCache() + + assertThat(database.exerciseRecordDao().getDetailByLocalId("member-a", "old")).isNull() + simpleTimers.forEach { assertThat(timerDao.getSimpleTimer("member-a", it.localId)).isEqualTo(it) } + customTimers.forEach { assertThat(timerDao.getCustomTimer("member-a", it.localId)).isEqualTo(it) } + } + + @Test + fun `마지막 삭제가 실패하면 횟수 목록 상세와 조회 마커 삭제가 모두 롤백된다`() = + runTest(testDispatcher) { + insertCachedRecord() + val countDao = database.exerciseCountDao() + val expected = IllegalStateException("정리 실패") + val failingDao = + object : ExerciseCountDao by countDao { + override suspend fun deleteBefore(cutoff: LocalDate) { + countDao.deleteBefore(cutoff) + throw expected + } + } + + val failure = runCatching { cleaner(failingDao).cleanExpiredCache() }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(database.exerciseCountDao().getRange("member-a", oldDate, oldDate)) + .containsExactly(ExerciseCountEntity("member-a", oldDate, 1)) + assertThat(database.exerciseRecordDao().getListByDate("member-a", oldDate)).containsExactly(listItem("old", 1L)) + assertThat(database.exerciseRecordDao().getDetailByLocalId("member-a", "old")).isEqualTo(detail("old", 1L)) + assertThat(hasCacheQuery(ExerciseCacheQueryEntity("member-a", "list", oldDate))).isTrue() + assertThat(hasCacheQuery(ExerciseCacheQueryEntity("member-a", "count", oldDate))).isTrue() + } + + @Test + fun `정리 도중 취소는 전파하고 이미 실행한 삭제도 롤백한다`() = + runTest(testDispatcher) { + insertCachedRecord() + val countDao = database.exerciseCountDao() + val expected = CancellationException("정리 취소") + val cancellingDao = + object : ExerciseCountDao by countDao { + override suspend fun deleteBefore(cutoff: LocalDate) { + countDao.deleteBefore(cutoff) + throw expected + } + } + + val failure = runCatching { cleaner(cancellingDao).cleanExpiredCache() }.exceptionOrNull() + + assertThat(failure).isInstanceOf(CancellationException::class.java) + assertThat(database.exerciseCountDao().getRange("member-a", oldDate, oldDate)) + .containsExactly(ExerciseCountEntity("member-a", oldDate, 1)) + assertThat(database.exerciseRecordDao().getListByDate("member-a", oldDate)).containsExactly(listItem("old", 1L)) + assertThat(database.exerciseRecordDao().getDetailByLocalId("member-a", "old")).isEqualTo(detail("old", 1L)) + assertThat(hasCacheQuery(ExerciseCacheQueryEntity("member-a", "list", oldDate))).isTrue() + assertThat(hasCacheQuery(ExerciseCacheQueryEntity("member-a", "count", oldDate))).isTrue() + } +} diff --git a/data/src/test/java/com/project200/data/local/dao/ExerciseCountDaoTest.kt b/data/src/test/java/com/project200/data/local/dao/ExerciseCountDaoTest.kt index c7c40776..35ea6516 100644 --- a/data/src/test/java/com/project200/data/local/dao/ExerciseCountDaoTest.kt +++ b/data/src/test/java/com/project200/data/local/dao/ExerciseCountDaoTest.kt @@ -117,4 +117,28 @@ class ExerciseCountDaoTest { assertThat(result).hasSize(1) assertThat(result.single().count).isEqualTo(4) } + + @Test + fun `보관 기간 삭제 - 모든 회원의 기준일 이전 캐시만 삭제한다`() = + runTest { + val cutoff = LocalDate.parse("2026-02-28") + dao.upsertAll( + listOf( + count("member-a", "2026-02-27"), + count("member-a", "2026-02-28"), + count("member-a", "2026-03-01"), + count("member-b", "2026-02-27"), + count("member-b", "2026-02-28"), + ), + ) + + dao.deleteBefore(cutoff) + + val start = cutoff.minusDays(1) + val end = cutoff.plusDays(1) + assertThat(dao.getRange("member-a", start, end).map { it.date }) + .containsExactly(cutoff, end).inOrder() + assertThat(dao.getRange("member-b", start, end).map { it.date }) + .containsExactly(cutoff) + } } diff --git a/domain/src/main/java/com/project200/domain/manager/ExerciseCacheCleaner.kt b/domain/src/main/java/com/project200/domain/manager/ExerciseCacheCleaner.kt new file mode 100644 index 00000000..fcfc16ea --- /dev/null +++ b/domain/src/main/java/com/project200/domain/manager/ExerciseCacheCleaner.kt @@ -0,0 +1,6 @@ +package com.project200.domain.manager + +/** 서버에서 다시 받을 수 있는 6개월 이전 운동 캐시만 정리합니다. 로컬 변경과 타이머는 보존합니다 */ +interface ExerciseCacheCleaner { + suspend fun cleanExpiredCache() +}