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/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/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 d173fdd45..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,8 @@ 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 import dagger.hilt.InstallIn @@ -16,22 +18,31 @@ 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( name = LIBRARY_FILTER_DATASTORE, + produceMigrations = { listOf(libraryFilterMigration) }, ) 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 + } +} 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) + } + } +}