From 236b450cd478566c8b1074314fd0bfa0dfee5561 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Wed, 14 Oct 2026 13:52:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20DataStore=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EB=B2=84=EC=A0=84=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20VersionedPreferencesMigration=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/common/util/ExtentionFuction.kt | 11 ++++- .../into/websoso/data/di/DataStoreModule.kt | 7 ++- .../core/datastore/di/DataStoreModule.kt | 11 ++++- .../VersionedPreferencesMigration.kt | 46 +++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 core/datastore/src/main/java/com/into/websoso/core/datastore/migration/VersionedPreferencesMigration.kt diff --git a/app/src/main/java/com/into/websoso/core/common/util/ExtentionFuction.kt b/app/src/main/java/com/into/websoso/core/common/util/ExtentionFuction.kt index d73d2648c..db3d80f47 100644 --- a/app/src/main/java/com/into/websoso/core/common/util/ExtentionFuction.kt +++ b/app/src/main/java/com/into/websoso/core/common/util/ExtentionFuction.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.repeatOnLifecycle import com.into.websoso.BuildConfig import com.into.websoso.core.common.ui.custom.WebsosoCustomSnackBar import com.into.websoso.core.common.ui.custom.WebsosoCustomToast +import com.into.websoso.core.datastore.migration.VersionedPreferencesMigration import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import java.io.Serializable @@ -142,12 +143,18 @@ inline fun Bundle.getAdaptedParcelable(key: String): T? as? T } -fun Context.createDataStore(preferencesName: String): DataStore = +fun Context.createDataStore( + preferencesName: String, + schemaVersion: Int, +): DataStore = PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler( produceNewData = { emptyPreferences() }, ), - migrations = listOf(SharedPreferencesMigration(this, preferencesName)), + migrations = listOf( + SharedPreferencesMigration(this, preferencesName), + VersionedPreferencesMigration(currentVersion = schemaVersion), + ), produceFile = { this.preferencesDataStoreFile(preferencesName) }, ) diff --git a/app/src/main/java/com/into/websoso/data/di/DataStoreModule.kt b/app/src/main/java/com/into/websoso/data/di/DataStoreModule.kt index 90b84cf8a..d2b21e552 100644 --- a/app/src/main/java/com/into/websoso/data/di/DataStoreModule.kt +++ b/app/src/main/java/com/into/websoso/data/di/DataStoreModule.kt @@ -18,7 +18,12 @@ object DataStoreModule { @Provides fun provideUserPreferencesDataStore( @ApplicationContext context: Context, - ): DataStore = context.createDataStore(USER_PREFERENCES) + ): DataStore = + context.createDataStore( + preferencesName = USER_PREFERENCES, + schemaVersion = USER_PREFERENCES_SCHEMA_VERSION, + ) private const val USER_PREFERENCES = "com.teamwss.websoso.user_preferences" + private const val USER_PREFERENCES_SCHEMA_VERSION = 1 } 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 d173fdd45..3b1ef245a 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 @@ -5,6 +5,7 @@ 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 com.into.websoso.core.datastore.migration.VersionedPreferencesMigration import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,7 +17,11 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) internal object DataStoreModule { private const val ACCOUNT_DATASTORE = "ACCOUNT_DATASTORE" - private val Context.accountDataStore: DataStore by preferencesDataStore(name = ACCOUNT_DATASTORE) + private const val ACCOUNT_SCHEMA_VERSION = 1 + private val Context.accountDataStore: DataStore by preferencesDataStore( + name = ACCOUNT_DATASTORE, + produceMigrations = { listOf(VersionedPreferencesMigration(currentVersion = ACCOUNT_SCHEMA_VERSION)) }, + ) private const val LIBRARY_FILTER_DATASTORE = "LIBRARY_FILTER_DATASTORE" private val Context.libraryFilterDataStore: DataStore by preferencesDataStore( @@ -24,14 +29,18 @@ internal object DataStoreModule { ) private const val USER_DATASTORE = "USER_DATASTORE" + private const val USER_SCHEMA_VERSION = 1 private val Context.userDataStore: DataStore by preferencesDataStore( name = USER_DATASTORE, + produceMigrations = { listOf(VersionedPreferencesMigration(currentVersion = USER_SCHEMA_VERSION)) }, ) private const val PENDING_FEED_LIKE_DATASTORE = "PENDING_FEED_LIKE_DATASTORE" + private const val PENDING_FEED_LIKE_SCHEMA_VERSION = 1 private val Context.pendingFeedLikeDataStore: DataStore by preferencesDataStore( name = PENDING_FEED_LIKE_DATASTORE, corruptionHandler = DefaultPendingFeedLikeStore.corruptionHandler, + produceMigrations = { listOf(VersionedPreferencesMigration(currentVersion = PENDING_FEED_LIKE_SCHEMA_VERSION)) }, ) @Provides diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/migration/VersionedPreferencesMigration.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/migration/VersionedPreferencesMigration.kt new file mode 100644 index 000000000..218941146 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/migration/VersionedPreferencesMigration.kt @@ -0,0 +1,46 @@ +package com.into.websoso.core.datastore.migration + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.intPreferencesKey + +/** + * 저장소에 스키마 버전을 함께 기록하고, 저장된 버전이 [currentVersion]보다 낮으면 단계별로 변환한다. + * + * 앱은 배포 시 전부 교체되지만 저장된 값은 이전 버전 앱이 남긴 그대로 남는다. + * 저장 명세(키 이름, 값 형식)를 바꿀 때는 [currentVersion]을 올리고 이전 버전에서 넘어오는 단계를 [steps]에 추가한다. + * + * - 버전 키가 없으면 버전 관리 도입 이전([LEGACY_SCHEMA_VERSION])으로 본다. + * - [steps]의 키는 변환 전 버전이며, 해당 단계는 `버전 → 버전 + 1` 변환을 수행한다. + * - 신규 설치에서도 모든 단계가 빈 저장소를 대상으로 실행되므로, 각 단계는 값이 없는 경우를 처리해야 한다. + */ +class VersionedPreferencesMigration( + private val currentVersion: Int, + private val steps: Map Unit> = emptyMap(), +) : DataMigration { + init { + require(currentVersion > LEGACY_SCHEMA_VERSION) + require(steps.keys.all { it in LEGACY_SCHEMA_VERSION until currentVersion }) + } + + override suspend fun shouldMigrate(currentData: Preferences): Boolean = currentData.schemaVersion < currentVersion + + override suspend fun migrate(currentData: Preferences): Preferences = + currentData.toMutablePreferences().apply { + (currentData.schemaVersion until currentVersion).forEach { version -> + steps[version]?.invoke(this) + } + this[SCHEMA_VERSION] = currentVersion + } + + override suspend fun cleanUp() = Unit + + private val Preferences.schemaVersion: Int + get() = this[SCHEMA_VERSION] ?: LEGACY_SCHEMA_VERSION + + companion object { + internal val SCHEMA_VERSION = intPreferencesKey("SCHEMA_VERSION") + internal const val LEGACY_SCHEMA_VERSION = 0 + } +} From 93c356fd01c6d88a264489cb596f59b7d7880066 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Wed, 14 Oct 2026 13:52:53 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=EC=84=9C=EC=9E=AC=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=EC=9D=84=20=EC=9D=BD=EA=B8=B0=20=EA=B2=BD=EB=A1=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20DataMigration=EC=9C=BC=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../library/DefaultLibraryFilterDataSource.kt | 36 +-------- .../library/mapper/LibraryFilterMapper.kt | 16 +--- .../migration/LibraryFilterMigration.kt | 79 +++++++++++++++++++ .../core/datastore/di/DataStoreModule.kt | 2 + 4 files changed, 89 insertions(+), 44 deletions(-) create mode 100644 core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/migration/LibraryFilterMigration.kt diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/DefaultLibraryFilterDataSource.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/DefaultLibraryFilterDataSource.kt index 47878354f..414be9d54 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/DefaultLibraryFilterDataSource.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/DefaultLibraryFilterDataSource.kt @@ -3,17 +3,14 @@ package com.into.websoso.core.datastore.datasource.library 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.library.mapper.toData import com.into.websoso.core.datastore.datasource.library.mapper.toPreferences +import com.into.websoso.core.datastore.datasource.library.migration.LIBRARY_FILTER_PARAMS_KEY import com.into.websoso.core.datastore.datasource.library.model.LibraryFilterPreferences import com.into.websoso.core.datastore.di.LibraryFilterDataStore import com.into.websoso.data.filter.datasource.LibraryFilterLocalDataSource -import com.into.websoso.data.filter.model.DEFAULT_IS_RATINGLESS -import com.into.websoso.data.filter.model.DEFAULT_RATING_MAX -import com.into.websoso.data.filter.model.DEFAULT_RATING_MIN import com.into.websoso.data.filter.model.LibraryFilter import dagger.Binds import dagger.Module @@ -25,9 +22,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import kotlinx.serialization.json.floatOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import javax.inject.Inject import javax.inject.Singleton @@ -44,28 +38,13 @@ internal class DefaultLibraryFilterDataSource .map { prefs -> prefs[LIBRARY_FILTER_PARAMS_KEY]?.let { jsonString -> withContext(dispatcher) { - json - .decodeFromString(jsonString) - .migrateLegacyRating(jsonString) - .toData() + runCatching { json.decodeFromString(jsonString) } + .getOrNull() + ?.toData() } } }.distinctUntilChanged() - private fun LibraryFilterPreferences.migrateLegacyRating(jsonString: String): LibraryFilterPreferences { - val root = json.parseToJsonElement(jsonString).jsonObject - - val hasNewRating = root.containsKey(RATING_MIN_KEY) || root.containsKey(RATING_MAX_KEY) - val legacyRating = root[LEGACY_NOVEL_RATING_KEY]?.jsonPrimitive?.floatOrNull - if (hasNewRating || legacyRating == null) return this - - return copy( - ratingMin = legacyRating.coerceIn(DEFAULT_RATING_MIN, DEFAULT_RATING_MAX), - ratingMax = DEFAULT_RATING_MAX, - isRatingless = DEFAULT_IS_RATINGLESS, - ) - } - override suspend fun updateLibraryFilter(libraryFilter: LibraryFilter) { val encodedJsonString = withContext(dispatcher) { json.encodeToString( @@ -83,13 +62,6 @@ internal class DefaultLibraryFilterDataSource prefs.remove(LIBRARY_FILTER_PARAMS_KEY) } } - - companion object { - private val LIBRARY_FILTER_PARAMS_KEY = stringPreferencesKey("LIBRARY_FILTER_PARAMS_KEY") - private const val LEGACY_NOVEL_RATING_KEY = "novelRating" - private const val RATING_MIN_KEY = "ratingMin" - private const val RATING_MAX_KEY = "ratingMax" - } } @Module diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/mapper/LibraryFilterMapper.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/mapper/LibraryFilterMapper.kt index 54b0694e4..56c2723b4 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/mapper/LibraryFilterMapper.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/mapper/LibraryFilterMapper.kt @@ -18,13 +18,8 @@ internal fun LibraryFilter.toPreferences(): LibraryFilterPreferences = keywords = keywords, ) -// 1.8.x 이하는 정렬 기준을 enum 이름("RECENT", "OLD")으로 저장했다. -// 서버 v2 명세 key로 마이그레이션하고, 알 수 없는 값은 기본값으로 대체한다. -private val LEGACY_SORT_CRITERIA_MIGRATION = mapOf( - "RECENT" to "created_desc", - "OLD" to "created_asc", -) - +// 저장값은 여러 버전의 앱이 남긴 값이므로 서버 명세에 없는 정렬 기준은 기본값으로 대체한다. +// 구버전 값 변환은 LibraryFilterMigration에서 처리한다. private val VALID_SORT_CRITERIA_KEYS = setOf( "created_desc", "created_asc", @@ -34,14 +29,11 @@ private val VALID_SORT_CRITERIA_KEYS = setOf( "rating_asc", ) -private fun String.toMigratedSortCriteria(): String = - LEGACY_SORT_CRITERIA_MIGRATION[this] - ?: takeIf { it in VALID_SORT_CRITERIA_KEYS } - ?: DEFAULT_SORT_CRITERIA +private fun String.toValidSortCriteria(): String = takeIf { it in VALID_SORT_CRITERIA_KEYS } ?: DEFAULT_SORT_CRITERIA internal fun LibraryFilterPreferences.toData(): LibraryFilter = LibraryFilter( - sortCriteria = sortCriteria.toMigratedSortCriteria(), + sortCriteria = sortCriteria.toValidSortCriteria(), isInterested = isInterested, readStatuses = readStatuses, attractivePoints = attractivePoints, diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/migration/LibraryFilterMigration.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/migration/LibraryFilterMigration.kt new file mode 100644 index 000000000..7acb0ac75 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/library/migration/LibraryFilterMigration.kt @@ -0,0 +1,79 @@ +package com.into.websoso.core.datastore.datasource.library.migration + +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.into.websoso.core.datastore.migration.VersionedPreferencesMigration +import com.into.websoso.data.filter.model.DEFAULT_IS_RATINGLESS +import com.into.websoso.data.filter.model.DEFAULT_RATING_MAX +import com.into.websoso.data.filter.model.DEFAULT_RATING_MIN +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.jsonObject + +internal val LIBRARY_FILTER_PARAMS_KEY = stringPreferencesKey("LIBRARY_FILTER_PARAMS_KEY") + +private const val LIBRARY_FILTER_SCHEMA_VERSION = 1 + +internal val libraryFilterMigration = VersionedPreferencesMigration( + currentVersion = LIBRARY_FILTER_SCHEMA_VERSION, + steps = mapOf( + 0 to MutablePreferences::migrateLibraryFilterFromLegacy, + ), +) + +private const val SORT_CRITERIA_KEY = "sortCriteria" +private const val LEGACY_NOVEL_RATING_KEY = "novelRating" +private const val RATING_MIN_KEY = "ratingMin" +private const val RATING_MAX_KEY = "ratingMax" +private const val IS_RATINGLESS_KEY = "isRatingless" + +// 1.8.x 이하는 정렬 기준을 enum 이름("RECENT", "OLD")으로 저장했다. +private val LEGACY_SORT_CRITERIA = mapOf( + "RECENT" to "created_desc", + "OLD" to "created_asc", +) + +/** + * 0 → 1: 버전 관리 도입 이전 서재 필터를 서버 v2 명세로 변환한다. + * - 정렬 기준 enum 이름을 서버 key로 변환한다. + * - 단일 별점(novelRating)을 별점 범위(ratingMin, ratingMax)로 변환한다. + * - JSON으로 읽을 수 없는 값은 폐기한다. + */ +private fun MutablePreferences.migrateLibraryFilterFromLegacy() { + val jsonString = this[LIBRARY_FILTER_PARAMS_KEY] ?: return + val root = runCatching { Json.parseToJsonElement(jsonString).jsonObject }.getOrNull() + if (root == null) { + remove(LIBRARY_FILTER_PARAMS_KEY) + return + } + + this[LIBRARY_FILTER_PARAMS_KEY] = root + .migrateLegacySortCriteria() + .migrateLegacyRating() + .toString() +} + +private fun JsonObject.migrateLegacySortCriteria(): JsonObject { + val sortCriteria = (this[SORT_CRITERIA_KEY] as? JsonPrimitive)?.contentOrNull + val migrated = LEGACY_SORT_CRITERIA[sortCriteria] ?: return this + + return JsonObject(this + (SORT_CRITERIA_KEY to JsonPrimitive(migrated))) +} + +private fun JsonObject.migrateLegacyRating(): JsonObject { + val legacyRating = (this[LEGACY_NOVEL_RATING_KEY] as? JsonPrimitive)?.floatOrNull + val hasNewRating = containsKey(RATING_MIN_KEY) || containsKey(RATING_MAX_KEY) + if (hasNewRating || legacyRating == null) return JsonObject(this - LEGACY_NOVEL_RATING_KEY) + + return JsonObject( + this - LEGACY_NOVEL_RATING_KEY + + mapOf( + RATING_MIN_KEY to JsonPrimitive(legacyRating.coerceIn(DEFAULT_RATING_MIN, DEFAULT_RATING_MAX)), + RATING_MAX_KEY to JsonPrimitive(DEFAULT_RATING_MAX), + IS_RATINGLESS_KEY to JsonPrimitive(DEFAULT_IS_RATINGLESS), + ), + ) +} 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 3b1ef245a..e0d3a67a2 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 @@ -5,6 +5,7 @@ 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 com.into.websoso.core.datastore.datasource.library.migration.libraryFilterMigration import com.into.websoso.core.datastore.migration.VersionedPreferencesMigration import dagger.Module import dagger.Provides @@ -26,6 +27,7 @@ internal object DataStoreModule { private const val LIBRARY_FILTER_DATASTORE = "LIBRARY_FILTER_DATASTORE" private val Context.libraryFilterDataStore: DataStore by preferencesDataStore( name = LIBRARY_FILTER_DATASTORE, + produceMigrations = { listOf(libraryFilterMigration) }, ) private const val USER_DATASTORE = "USER_DATASTORE" From d4fd5f3caf1b3922850113cd880493e7015c75dc Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Wed, 14 Oct 2026 13:52:53 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20DataStore=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EB=B2=84=EC=A0=84=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=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 Co-Authored-By: Claude Opus 5.5 (1M context) --- core/datastore/build.gradle.kts | 1 + .../migration/LibraryFilterMigrationTest.kt | 93 +++++++++++++++++++ .../VersionedPreferencesMigrationTest.kt | 73 +++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/library/migration/LibraryFilterMigrationTest.kt create mode 100644 core/datastore/src/test/java/com/into/websoso/core/datastore/migration/VersionedPreferencesMigrationTest.kt diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index a92b6d3a0..997397010 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -19,4 +19,5 @@ dependencies { implementation(libs.serialization.json) testImplementation(libs.junit) + testImplementation(libs.coroutines.test) } diff --git a/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/library/migration/LibraryFilterMigrationTest.kt b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/library/migration/LibraryFilterMigrationTest.kt new file mode 100644 index 000000000..7f3bca1b8 --- /dev/null +++ b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/library/migration/LibraryFilterMigrationTest.kt @@ -0,0 +1,93 @@ +package com.into.websoso.core.datastore.datasource.library.migration + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.preferencesOf +import com.into.websoso.core.datastore.datasource.library.mapper.toData +import com.into.websoso.core.datastore.datasource.library.model.LibraryFilterPreferences +import com.into.websoso.core.datastore.migration.VersionedPreferencesMigration.Companion.SCHEMA_VERSION +import com.into.websoso.data.filter.model.LibraryFilter +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class LibraryFilterMigrationTest { + private val json = Json { ignoreUnknownKeys = true } + + private suspend fun migrate(stored: String?): Preferences { + val preferences = stored?.let { preferencesOf(LIBRARY_FILTER_PARAMS_KEY to it) } ?: preferencesOf() + return libraryFilterMigration.migrate(preferences) + } + + private fun Preferences.toLibraryFilter(): LibraryFilter = + json.decodeFromString(this[LIBRARY_FILTER_PARAMS_KEY]!!).toData() + + private fun storedFilter( + sortCriteria: String, + extra: String = "", + ): String = """{"sortCriteria":"$sortCriteria","isInterested":false,"readStatuses":[],"attractivePoints":[]$extra}""" + + @Test + fun `구버전 정렬 기준을 서버 명세 key로 변환해 저장한다`() = + runTest { + mapOf( + "RECENT" to "created_desc", + "OLD" to "created_asc", + ).forEach { (stored, expected) -> + assertEquals(expected, migrate(storedFilter(stored)).toLibraryFilter().sortCriteria) + } + } + + @Test + fun `핫픽스 이후 저장된 새 정렬 기준은 버전 키가 없어도 그대로 유지한다`() = + runTest { + listOf("created_desc", "created_asc", "title", "read_date", "rating_desc", "rating_asc").forEach { stored -> + val migrated = migrate(storedFilter(stored)) + + assertEquals(stored, migrated.toLibraryFilter().sortCriteria) + assertEquals(1, migrated[SCHEMA_VERSION]) + } + } + + @Test + fun `단일 별점을 별점 범위로 변환하고 구 키를 제거한다`() = + runTest { + val migrated = migrate(storedFilter("RECENT", extra = ""","novelRating":3.5""")) + + val filter = migrated.toLibraryFilter() + assertEquals(3.5f, filter.ratingMin) + assertEquals(5.0f, filter.ratingMax) + assertFalse(filter.isRatingless) + assertFalse(migrated[LIBRARY_FILTER_PARAMS_KEY]!!.contains("novelRating")) + } + + @Test + fun `별점 범위가 이미 있으면 단일 별점을 무시한다`() = + runTest { + val migrated = migrate(storedFilter("created_desc", extra = ""","novelRating":3.5,"ratingMin":1.0,"ratingMax":4.0""")) + + val filter = migrated.toLibraryFilter() + assertEquals(1.0f, filter.ratingMin) + assertEquals(4.0f, filter.ratingMax) + } + + @Test + fun `JSON으로 읽을 수 없는 값은 폐기한다`() = + runTest { + val migrated = migrate("not json") + + assertNull(migrated[LIBRARY_FILTER_PARAMS_KEY]) + assertEquals(1, migrated[SCHEMA_VERSION]) + } + + @Test + fun `저장된 필터가 없으면 버전만 기록한다`() = + runTest { + val migrated = migrate(null) + + assertNull(migrated[LIBRARY_FILTER_PARAMS_KEY]) + assertEquals(1, migrated[SCHEMA_VERSION]) + } +} diff --git a/core/datastore/src/test/java/com/into/websoso/core/datastore/migration/VersionedPreferencesMigrationTest.kt b/core/datastore/src/test/java/com/into/websoso/core/datastore/migration/VersionedPreferencesMigrationTest.kt new file mode 100644 index 000000000..9b6e9846f --- /dev/null +++ b/core/datastore/src/test/java/com/into/websoso/core/datastore/migration/VersionedPreferencesMigrationTest.kt @@ -0,0 +1,73 @@ +package com.into.websoso.core.datastore.migration + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.preferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +import com.into.websoso.core.datastore.migration.VersionedPreferencesMigration.Companion.SCHEMA_VERSION +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class VersionedPreferencesMigrationTest { + private val history = stringPreferencesKey("history") + + private val migration = VersionedPreferencesMigration( + currentVersion = 3, + steps = mapOf( + 0 to { this[history] = this[history].orEmpty() + "0" }, + 1 to { this[history] = this[history].orEmpty() + "1" }, + 2 to { this[history] = this[history].orEmpty() + "2" }, + ), + ) + + private suspend fun migrateIfNeeded(preferences: Preferences): Preferences = + if (migration.shouldMigrate(preferences)) migration.migrate(preferences) else preferences + + @Test + fun `버전 키가 없으면 모든 단계를 순서대로 적용하고 현재 버전을 기록한다`() = + runTest { + val migrated = migrateIfNeeded(preferencesOf()) + + assertEquals("012", migrated[history]) + assertEquals(3, migrated[SCHEMA_VERSION]) + } + + @Test + fun `저장된 버전 이후의 단계만 적용한다`() = + runTest { + val migrated = migrateIfNeeded(preferencesOf(SCHEMA_VERSION to 1)) + + assertEquals("12", migrated[history]) + assertEquals(3, migrated[SCHEMA_VERSION]) + } + + @Test + fun `저장된 버전이 현재 버전 이상이면 마이그레이션하지 않는다`() = + runTest { + assertFalse(migration.shouldMigrate(preferencesOf(SCHEMA_VERSION to 3))) + assertFalse(migration.shouldMigrate(preferencesOf(SCHEMA_VERSION to 4))) + assertTrue(migration.shouldMigrate(preferencesOf(SCHEMA_VERSION to 2))) + } + + @Test + fun `마이그레이션은 한 번만 적용된다`() = + runTest { + val once = migrateIfNeeded(preferencesOf()) + val twice = migrateIfNeeded(once) + + assertEquals(once, twice) + } + + @Test + fun `현재 버전 범위를 벗어난 단계는 등록할 수 없다`() { + assertThrows(IllegalArgumentException::class.java) { + VersionedPreferencesMigration(currentVersion = 1, steps = mapOf(1 to {})) + } + assertThrows(IllegalArgumentException::class.java) { + VersionedPreferencesMigration(currentVersion = 0) + } + } +}