From 4b673a8bbc383ba2f461db3433ca09de8517d890 Mon Sep 17 00:00:00 2001 From: Yang Date: Tue, 19 Mar 2024 17:34:59 +1100 Subject: [PATCH] `FeedDataSource` implementation. --- .../kmp/database/FeedItemEntity.sq | 5 +- kmp/feed-datasource/build.gradle.kts | 1 + .../kmp/feed/datasource/FeedDataSource.kt | 158 +++--- .../feed/datasource/mapper/FeedItemMappers.kt | 52 ++ .../datasource/mapper/FeedOriginMappers.kt | 19 + .../kmp/feed/datasource/FeedDataSourceTest.kt | 529 ++++++++++++++++++ .../datasource/mapper/FeedItemMappersTest.kt | 178 ++++++ .../mapper/FeedOriginMappersTest.kt | 92 +++ .../sync/mapper/FeedItemEntityMappersTest.kt | 2 +- .../TalkingKotlinEpisodePresenter.kt | 5 +- 10 files changed, 952 insertions(+), 89 deletions(-) create mode 100644 kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedItemMappers.kt create mode 100644 kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedOriginMappers.kt create mode 100644 kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FeedDataSourceTest.kt create mode 100644 kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedItemMappersTest.kt create mode 100644 kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedOriginMappersTest.kt diff --git a/kmp/database/src/commonMain/sqldelight/io/github/reactivecircus/kstreamlined/kmp/database/FeedItemEntity.sq b/kmp/database/src/commonMain/sqldelight/io/github/reactivecircus/kstreamlined/kmp/database/FeedItemEntity.sq index c7fc8259..784d3f6d 100644 --- a/kmp/database/src/commonMain/sqldelight/io/github/reactivecircus/kstreamlined/kmp/database/FeedItemEntity.sq +++ b/kmp/database/src/commonMain/sqldelight/io/github/reactivecircus/kstreamlined/kmp/database/FeedItemEntity.sq @@ -36,5 +36,8 @@ upsertFeedItem: INSERT OR REPLACE INTO feedItemEntity VALUES (:id, :feed_origin_key, :title, :publish_time, :content_url, :image_url, :description, :issue_number, :podcast_audio_url, :podcast_duration, :podcast_start_position, :saved_for_later); +updatePotcastStartPositionById: +UPDATE feedItemEntity SET podcast_start_position = :podcast_start_position WHERE id = :id; + updateSavedForLaterById: -UPDATE feedItemEntity SET saved_for_later = :savedForLater WHERE id = :id; +UPDATE feedItemEntity SET saved_for_later = :saved_for_later WHERE id = :id; diff --git a/kmp/feed-datasource/build.gradle.kts b/kmp/feed-datasource/build.gradle.kts index 09e3f218..a6ad9541 100644 --- a/kmp/feed-datasource/build.gradle.kts +++ b/kmp/feed-datasource/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { implementation(project(":kmp:networking:testing")) implementation(project(":kmp:database-testing")) implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) } } } diff --git a/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FeedDataSource.kt b/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FeedDataSource.kt index 32ca4074..1e70e946 100644 --- a/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FeedDataSource.kt +++ b/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FeedDataSource.kt @@ -1,118 +1,106 @@ package io.github.reactivecircus.kstreamlined.kmp.feed.datasource +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import app.cash.sqldelight.coroutines.mapToOneOrNull import io.github.reactivecircus.kstreamlined.kmp.database.KStreamlinedDatabase import io.github.reactivecircus.kstreamlined.kmp.feed.datasource.mapper.asExternalModel import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedOrigin import io.github.reactivecircus.kstreamlined.kmp.model.feed.KotlinWeeklyIssueItem import io.github.reactivecircus.kstreamlined.kmp.networking.FeedService -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.mapLatest -import kotlinx.datetime.toInstant +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext public class FeedDataSource( private val feedService: FeedService, - private val database: KStreamlinedDatabase, + private val db: KStreamlinedDatabase, + private val dbDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { - public suspend fun selectFeedSource(feedOrigin: FeedOrigin) { - feedOrigin.selected - TODO() - } - - public suspend fun unselectFeedSource(feedOrigin: FeedOrigin) { - feedOrigin.selected - TODO() - } - - @Suppress("MaxLineLength") - @OptIn(ExperimentalCoroutinesApi::class) - public fun streamFeedItemById(id: String): Flow { - database.feedItemEntityQueries - return savedItemsIds.mapLatest { savedItemsIds -> - when { - id.contains("kotlin-weekly") -> { - FeedItem.KotlinWeekly( - id = id, - title = "Kotlin Weekly #386", - publishTime = "2023-11-05T08:13:58Z".toInstant(), - contentUrl = id, - savedForLater = savedItemsIds.contains(id), - issueNumber = 386, - ) - } - - id.contains("tag:soundcloud") -> fakeTalkingKotlinEpisode.copy( - id = id, - savedForLater = savedItemsIds.contains(id) - ) - - id.contains("blog.jetbrains.com") -> FeedItem.KotlinBlog( - id = id, - title = "Tackle Advent of Code 2023 With Kotlin and Win Prizes!", - publishTime = "2023-11-23T17:00:38Z".toInstant(), - contentUrl = "https://blog.jetbrains.com/kotlin/2023/11/advent-of-code-2023-with-kotlin/", - savedForLater = savedItemsIds.contains(id), - featuredImageUrl = "https://blog.jetbrains.com/wp-content/uploads/2023/11/DSGN-18072-Social-media-banners_Blog-Featured-image-1280x720-1.png", - ) - id.contains("yt:video") -> FeedItem.KotlinYouTube( - id = id, - title = "Coil Goes Multiplatform with Colin White | Talking Kotlin #127", - publishTime = "2023-11-29T17:30:08Z".toInstant(), - contentUrl = "https://www.youtube.com/watch?v=apiVJfLvUBE", - savedForLater = savedItemsIds.contains(id), - thumbnailUrl = "https://i2.ytimg.com/vi/apiVJfLvUBE/hqdefault.jpg", - description = "Welcome to another engaging episode of Talking Kotlin! In this edition, we dive into the dynamic world of Android development with Colin White, the creator of the widely acclaimed Coil library. Join us as we discuss the latest developments, insights, and the exciting roadmap for Coil. \uD83D\uDE80 Highlights from this Episode: Learn about Colin's journey in developing the Coil library. Discover the pivotal role Coil plays in simplifying image loading for Android developers. Get an exclusive sneak peek into the upcoming Coil 3.0, featuring multi-platform support and seamless integration with Jetpack Compose. \uD83D\uDD17 Helpful Links: Coil Library GitHub: https://coil-kt.github.io/coil/ Follow Colin White on Twitter: https://twitter.com/colinwhi \uD83C\uDF10 Connect with the Kotlin Community: https://kotlinlang.org/community/ Kotlin Foundation: https://kotlinfoundation.org/ \uD83D\uDC49 Don't miss out on the latest insights and updates from the Kotlin world! Subscribe, hit the bell icon, and join the conversation in the comments below. \uD83D\uDCC8 Help us reach 20,000 views by liking, sharing, and subscribing! Your support keeps the Kotlin conversation alive.", - ) - - else -> null + public fun streamFeedOrigins(): Flow> { + return db.feedOriginEntityQueries.allFeedOrigins() + .asFlow().mapToList(dbDispatcher) + .map { origins -> + origins.map { it.asExternalModel() } } - }.distinctUntilChanged() } - public suspend fun addSavedFeedItem(id: String) { - savedItemsIds.value += id + public fun streamFeedItemsForSelectedOrigins(): Flow> { + return db.feedItemEntityQueries.feedItemsForSelectedOrigins() + .asFlow().mapToList(dbDispatcher) + .map { items -> + items.map { it.asExternalModel() } + } } - public suspend fun removeSavedFeedItem(id: String) { - savedItemsIds.value -= id + public fun streamSavedFeedItems(): Flow> { + return db.feedItemEntityQueries.savedFeedItems() + .asFlow().mapToList(dbDispatcher) + .map { items -> + items.map { it.asExternalModel() } + } } - public suspend fun loadSavedFeedItems(): List { - TODO() + public fun streamFeedItemById(id: String): Flow { + return db.feedItemEntityQueries.feedItemById(id) + .asFlow().mapToOneOrNull(dbDispatcher) + .map { it?.asExternalModel() } } public suspend fun loadKotlinWeeklyIssue(url: String): List { - // TODO persist fetched entry to DB return feedService.fetchKotlinWeeklyIssue(url).map { it.asExternalModel() } } - public suspend fun saveTalkingKotlinEpisodeStartPosition(id: String, positionMillis: Long) { - // TODO persist to DB - fakeTalkingKotlinEpisode = fakeTalkingKotlinEpisode.copy( - id = id, - startPositionMillis = positionMillis, - ) + public suspend fun selectFeedSource(feedOriginKey: FeedOrigin.Key): Unit = + withContext(dbDispatcher) { + db.feedOriginEntityQueries.updateSelection(selected = true, key = feedOriginKey.name) + } + + public suspend fun unselectFeedSource(feedOriginKey: FeedOrigin.Key): Unit = + withContext(dbDispatcher) { + db.transaction { + // select all other origins if the one being unselected is the last one selected + val currentFeedOrigins = db.feedOriginEntityQueries.allFeedOrigins().executeAsList() + val selectedOrigins = currentFeedOrigins.filter { it.selected } + val isLastSelected = selectedOrigins.size == 1 && + selectedOrigins.first().key == feedOriginKey.name + if (isLastSelected) { + currentFeedOrigins.filter { it.key != feedOriginKey.name }.forEach { origin -> + db.feedOriginEntityQueries.updateSelection( + selected = true, + key = origin.key, + ) + } + } + db.feedOriginEntityQueries.updateSelection( + selected = false, + key = feedOriginKey.name + ) + } + } + + public suspend fun addSavedFeedItem(id: String): Unit = withContext(dbDispatcher) { + db.feedItemEntityQueries.updateSavedForLaterById(saved_for_later = true, id = id) } - private val savedItemsIds = MutableStateFlow(emptySet()) + public suspend fun removeSavedFeedItem(id: String): Unit = withContext(dbDispatcher) { + db.feedItemEntityQueries.updateSavedForLaterById(saved_for_later = false, id = id) + } - @Suppress("MaxLineLength") - private var fakeTalkingKotlinEpisode = FeedItem.TalkingKotlin( - id = "tag:soundcloud,2010:tracks/1689535512", - title = "Coil Goes Multiplatform with Colin White", - publishTime = "2023-11-29T22:00:00Z".toInstant(), - contentUrl = "https://soundcloud.com/user-38099918/coil-goes-multiplatform-with-colin-white", - savedForLater = false, - audioUrl = "https://feeds.soundcloud.com/stream/1689535512-user-38099918-network-resilient-applications-with-store5-talking-kotlin-128.mp3", - thumbnailUrl = "https://talkingkotlin.com/images/kotlin_talking_logo.png", - summary = "Welcome to another engaging episode of Talking Kotlin! In this edition, we dive into the dynamic world of Android development with Colin White, the creator of the widely acclaimed Coil library. Join us as we discuss the latest developments, insights, and the exciting roadmap for Coil. \uD83D\uDE80 Highlights from this Episode: Learn about Colin's journey in developing the Coil library. Discover the pivotal role Coil plays in simplifying image loading for Android developers. Get an exclusive sneak peek into the upcoming Coil 3.0, featuring multi-platform support and seamless integration with Jetpack Compose. \uD83D\uDD17 Helpful Links: Coil Library GitHub: github.com/coilkt/coil Follow Colin White on Twitter: @colinwhi \uD83C\uDF10 Connect with the Kotlin Community: https://kotlinlang.org/community/ Kotlin Foundation: https://kotlinfoundation.org/", - duration = "42min.", - startPositionMillis = 0, - ) + public suspend fun saveTalkingKotlinEpisodeStartPosition( + id: String, + positionMillis: Long, + ): Unit = withContext(dbDispatcher) { + db.feedItemEntityQueries.updatePotcastStartPositionById( + id = id, + podcast_start_position = positionMillis, + ) + } } diff --git a/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedItemMappers.kt b/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedItemMappers.kt new file mode 100644 index 00000000..c03a5fd3 --- /dev/null +++ b/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedItemMappers.kt @@ -0,0 +1,52 @@ +package io.github.reactivecircus.kstreamlined.kmp.feed.datasource.mapper + +import io.github.reactivecircus.kstreamlined.kmp.database.FeedItemEntity +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedOrigin + +internal fun FeedItemEntity.asExternalModel(): FeedItem { + return when (feed_origin_key) { + FeedOrigin.Key.KotlinBlog.name -> FeedItem.KotlinBlog( + id = id, + title = title, + publishTime = publish_time, + contentUrl = content_url, + savedForLater = saved_for_later, + featuredImageUrl = image_url!!, + ) + + FeedOrigin.Key.KotlinYouTubeChannel.name -> FeedItem.KotlinYouTube( + id = id, + title = title, + publishTime = publish_time, + contentUrl = content_url, + savedForLater = saved_for_later, + thumbnailUrl = image_url!!, + description = description!!, + ) + + FeedOrigin.Key.TalkingKotlinPodcast.name -> FeedItem.TalkingKotlin( + id = id, + title = title, + publishTime = publish_time, + contentUrl = content_url, + savedForLater = saved_for_later, + audioUrl = podcast_audio_url!!, + thumbnailUrl = image_url!!, + summary = description!!, + duration = podcast_duration!!, + startPositionMillis = podcast_start_position ?: 0, + ) + + FeedOrigin.Key.KotlinWeekly.name -> FeedItem.KotlinWeekly( + id = id, + title = title, + publishTime = publish_time, + contentUrl = content_url, + savedForLater = saved_for_later, + issueNumber = issue_number!!.toInt(), + ) + + else -> error("Unknown feed origin key: $feed_origin_key") + } +} diff --git a/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedOriginMappers.kt b/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedOriginMappers.kt new file mode 100644 index 00000000..a7a5e580 --- /dev/null +++ b/kmp/feed-datasource/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedOriginMappers.kt @@ -0,0 +1,19 @@ +package io.github.reactivecircus.kstreamlined.kmp.feed.datasource.mapper + +import io.github.reactivecircus.kstreamlined.kmp.database.FeedOriginEntity +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedOrigin + +internal fun FeedOriginEntity.asExternalModel(): FeedOrigin { + return FeedOrigin( + key = when (key) { + FeedOrigin.Key.KotlinBlog.name -> FeedOrigin.Key.KotlinBlog + FeedOrigin.Key.KotlinYouTubeChannel.name -> FeedOrigin.Key.KotlinYouTubeChannel + FeedOrigin.Key.TalkingKotlinPodcast.name -> FeedOrigin.Key.TalkingKotlinPodcast + FeedOrigin.Key.KotlinWeekly.name -> FeedOrigin.Key.KotlinWeekly + else -> error("Unknown feed origin key: $key") + }, + title = title, + description = description, + selected = selected, + ) +} diff --git a/kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FeedDataSourceTest.kt b/kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FeedDataSourceTest.kt new file mode 100644 index 00000000..21f706b1 --- /dev/null +++ b/kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FeedDataSourceTest.kt @@ -0,0 +1,529 @@ +package io.github.reactivecircus.kstreamlined.kmp.feed.datasource + +import app.cash.turbine.test +import io.github.reactivecircus.kstreamlined.kmp.database.FeedOriginEntity +import io.github.reactivecircus.kstreamlined.kmp.database.testing.createInMemoryDatabase +import io.github.reactivecircus.kstreamlined.kmp.feed.datasource.mapper.asExternalModel +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedOrigin +import io.github.reactivecircus.kstreamlined.kmp.networking.FakeFeedService +import io.github.reactivecircus.kstreamlined.kmp.networking.FakeKotlinWeeklyIssueEntries +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class FeedDataSourceTest { + + private val feedService = FakeFeedService() + + private val db = createInMemoryDatabase() + + private val testDispatcher = StandardTestDispatcher() + + private val feedDataSource = FeedDataSource( + feedService = feedService, + db = db, + dbDispatcher = testDispatcher, + ) + + @Test + fun `streamFeedOrigins emits when feed origins change in DB`() = runTest(testDispatcher) { + feedDataSource.streamFeedOrigins().test { + assertEquals(emptyList(), awaitItem()) + + db.transaction { + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = true, + ) + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinYouTubeChannel.name, + title = "Kotlin YouTube", + description = "The official YouTube channel of the Kotlin programming language", + selected = true, + ) + } + + assertEquals( + listOf( + FeedOrigin( + key = FeedOrigin.Key.KotlinBlog, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = true, + ), + FeedOrigin( + key = FeedOrigin.Key.KotlinYouTubeChannel, + title = "Kotlin YouTube", + description = "The official YouTube channel of the Kotlin programming language", + selected = true, + ) + ), + awaitItem() + ) + + db.feedOriginEntityQueries.updateSelection( + selected = false, + key = FeedOrigin.Key.KotlinBlog.name, + ) + assertEquals( + listOf( + FeedOrigin( + key = FeedOrigin.Key.KotlinBlog, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = false, + ), + FeedOrigin( + key = FeedOrigin.Key.KotlinYouTubeChannel, + title = "Kotlin YouTube", + description = "The official YouTube channel of the Kotlin programming language", + selected = true, + ) + ), + awaitItem() + ) + } + } + + @Test + fun `streamFeedItemsForSelectedOrigins emits when queried feed items change in DB`() = + runTest(testDispatcher) { + feedDataSource.streamFeedItemsForSelectedOrigins().test { + assertEquals(emptyList(), awaitItem()) + + db.transaction { + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = true, + ) + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinYouTubeChannel.name, + title = "Kotlin YouTube", + description = "The official YouTube channel of the Kotlin programming language", + selected = true, + ) + db.feedItemEntityQueries.upsertFeedItem( + id = "1", + feed_origin_key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog Post", + publish_time = "2022-01-01T00:00:00Z".toInstant(), + content_url = "content-url-1", + image_url = "image-url-1", + description = null, + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = false, + ) + db.feedItemEntityQueries.upsertFeedItem( + id = "2", + feed_origin_key = FeedOrigin.Key.KotlinYouTubeChannel.name, + title = "Kotlin YouTube Video", + publish_time = "2022-01-02T00:00:00Z".toInstant(), + content_url = "content-url-2", + image_url = "image-url-2", + description = "Desc", + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = false, + ) + } + + assertEquals( + listOf( + FeedItem.KotlinYouTube( + id = "2", + title = "Kotlin YouTube Video", + publishTime = "2022-01-02T00:00:00Z".toInstant(), + contentUrl = "content-url-2", + savedForLater = false, + thumbnailUrl = "image-url-2", + description = "Desc", + ), + FeedItem.KotlinBlog( + id = "1", + title = "Kotlin Blog Post", + publishTime = "2022-01-01T00:00:00Z".toInstant(), + contentUrl = "content-url-1", + savedForLater = false, + featuredImageUrl = "image-url-1", + ), + ), + awaitItem(), + ) + + db.feedOriginEntityQueries.updateSelection( + selected = false, + key = FeedOrigin.Key.KotlinBlog.name, + ) + + assertEquals( + listOf( + FeedItem.KotlinYouTube( + id = "2", + title = "Kotlin YouTube Video", + publishTime = "2022-01-02T00:00:00Z".toInstant(), + contentUrl = "content-url-2", + savedForLater = false, + thumbnailUrl = "image-url-2", + description = "Desc", + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `streamSavedFeedItems emits when saved feed items change in DB`() = + runTest(testDispatcher) { + feedDataSource.streamSavedFeedItems().test { + assertEquals(emptyList(), awaitItem()) + + db.transaction { + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = true, + ) + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinYouTubeChannel.name, + title = "Kotlin YouTube", + description = "The official YouTube channel of the Kotlin programming language", + selected = true, + ) + db.feedItemEntityQueries.upsertFeedItem( + id = "1", + feed_origin_key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog Post", + publish_time = "2022-01-01T00:00:00Z".toInstant(), + content_url = "content-url-1", + image_url = "image-url-1", + description = null, + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = false, + ) + db.feedItemEntityQueries.upsertFeedItem( + id = "2", + feed_origin_key = FeedOrigin.Key.KotlinYouTubeChannel.name, + title = "Kotlin YouTube Video", + publish_time = "2022-01-02T00:00:00Z".toInstant(), + content_url = "content-url-2", + image_url = "image-url-2", + description = "Desc", + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = false, + ) + } + + assertEquals(emptyList(), awaitItem()) + + db.feedItemEntityQueries.updateSavedForLaterById( + saved_for_later = true, + id = "1", + ) + + assertEquals( + listOf( + FeedItem.KotlinBlog( + id = "1", + title = "Kotlin Blog Post", + publishTime = "2022-01-01T00:00:00Z".toInstant(), + contentUrl = "content-url-1", + savedForLater = true, + featuredImageUrl = "image-url-1", + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `streamFeedItemById emits when feed item changes in DB`() = runTest(testDispatcher) { + feedDataSource.streamFeedItemById("id").test { + assertNull(awaitItem()) + + db.feedItemEntityQueries.upsertFeedItem( + id = "id", + feed_origin_key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog Post", + publish_time = "2022-01-01T00:00:00Z".toInstant(), + content_url = "content-url-1", + image_url = "image-url-1", + description = null, + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = false, + ) + + assertEquals( + FeedItem.KotlinBlog( + id = "id", + title = "Kotlin Blog Post", + publishTime = "2022-01-01T00:00:00Z".toInstant(), + contentUrl = "content-url-1", + savedForLater = false, + featuredImageUrl = "image-url-1", + ), + awaitItem(), + ) + + db.feedItemEntityQueries.upsertFeedItem( + id = "id", + feed_origin_key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog Post 2", + publish_time = "2022-01-01T00:00:00Z".toInstant(), + content_url = "content-url-2", + image_url = "image-url-2", + description = null, + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = true, + ) + + assertEquals( + FeedItem.KotlinBlog( + id = "id", + title = "Kotlin Blog Post 2", + publishTime = "2022-01-01T00:00:00Z".toInstant(), + contentUrl = "content-url-2", + savedForLater = true, + featuredImageUrl = "image-url-2", + ), + awaitItem(), + ) + } + } + + @Test + fun `loadKotlinWeeklyIssues returns result when fetching from service succeeds`() = runTest { + feedService.nextKotlinWeeklyIssueResponse = { + FakeKotlinWeeklyIssueEntries + } + + val expected = FakeKotlinWeeklyIssueEntries.map { it.asExternalModel() } + val actual = feedDataSource.loadKotlinWeeklyIssue("url") + + assertEquals(expected, actual) + } + + @Test + fun `loadKotlinWeeklyIssues throws exception when fetching from service fails`() = runTest { + feedService.nextKotlinWeeklyIssueResponse = { + error("Failed to fetch Kotlin Weekly issue") + } + + assertFailsWith { + feedDataSource.loadKotlinWeeklyIssue("url") + } + } + + @Test + fun `selectFeedSource updates feed origin selection in DB`() = runTest(testDispatcher) { + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = false, + ) + + feedDataSource.selectFeedSource(FeedOrigin.Key.KotlinBlog) + + assertEquals( + listOf( + FeedOriginEntity( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = true, + ) + ), + db.feedOriginEntityQueries.allFeedOrigins().executeAsList(), + ) + } + + @Test + fun `unselectFeedSource updates feed origin selection in DB`() = runTest(testDispatcher) { + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = true, + ) + + feedDataSource.unselectFeedSource(FeedOrigin.Key.KotlinBlog) + + assertEquals( + listOf( + FeedOriginEntity( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = false, + ) + ), + db.feedOriginEntityQueries.allFeedOrigins().executeAsList(), + ) + } + + @Test + fun `unselectFeedSource selects all other feed origin in DB when the only selected one is being unselected`() = + runTest(testDispatcher) { + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = false, + ) + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinYouTubeChannel.name, + title = "Kotlin YouTube", + description = "The official YouTube channel of the Kotlin programming language", + selected = true, + ) + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.TalkingKotlinPodcast.name, + title = "Talking Kotlin", + description = "Technical show discussing everything Kotlin, hosted by Hadi and Sebastian", + selected = false, + ) + db.feedOriginEntityQueries.upsertFeedOrigin( + key = FeedOrigin.Key.KotlinWeekly.name, + title = "Kotlin Weekly", + description = "Weekly community Kotlin newsletter, hosted by Enrique", + selected = false, + ) + + feedDataSource.unselectFeedSource(FeedOrigin.Key.KotlinYouTubeChannel) + + assertEquals( + listOf( + FeedOriginEntity( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = true, + ), + FeedOriginEntity( + key = FeedOrigin.Key.KotlinYouTubeChannel.name, + title = "Kotlin YouTube", + description = "The official YouTube channel of the Kotlin programming language", + selected = false, + ), + FeedOriginEntity( + key = FeedOrigin.Key.TalkingKotlinPodcast.name, + title = "Talking Kotlin", + description = "Technical show discussing everything Kotlin, hosted by Hadi and Sebastian", + selected = true, + ), + FeedOriginEntity( + key = FeedOrigin.Key.KotlinWeekly.name, + title = "Kotlin Weekly", + description = "Weekly community Kotlin newsletter, hosted by Enrique", + selected = true, + ), + ), + db.feedOriginEntityQueries.allFeedOrigins().executeAsList(), + ) + } + + @Test + fun `addSavedFeedItem updates saved_for_later for item in DB`() = runTest(testDispatcher) { + db.feedItemEntityQueries.upsertFeedItem( + id = "1", + feed_origin_key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog Post", + publish_time = "2022-01-01T00:00:00Z".toInstant(), + content_url = "https://blog.kotlinlang.org/post", + image_url = "https://blog.kotlinlang.org/image", + description = null, + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = false, + ) + + feedDataSource.addSavedFeedItem("1") + + assertEquals( + true, + db.feedItemEntityQueries.feedItemById("1").executeAsOne().saved_for_later, + ) + } + + @Test + fun `removeSavedFeedItem updates saved_for_later for item in DB`() = runTest(testDispatcher) { + db.feedItemEntityQueries.upsertFeedItem( + id = "1", + feed_origin_key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog Post", + publish_time = "2022-01-01T00:00:00Z".toInstant(), + content_url = "https://blog.kotlinlang.org/post", + image_url = "https://blog.kotlinlang.org/image", + description = null, + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = true, + ) + + feedDataSource.removeSavedFeedItem("1") + + assertEquals( + false, + db.feedItemEntityQueries.feedItemById("1").executeAsOne().saved_for_later, + ) + } + + @Test + fun `saveTalkingKotlinEpisodeStartPosition updates podcast_start_position for item in DB`() = + runTest(testDispatcher) { + db.feedItemEntityQueries.upsertFeedItem( + id = "1", + feed_origin_key = FeedOrigin.Key.TalkingKotlinPodcast.name, + title = "Talking Kotlin Episode", + publish_time = "2022-01-03T00:00:00Z".toInstant(), + content_url = "content-url", + image_url = "image-url", + description = "Desc", + issue_number = null, + podcast_audio_url = "audio.mp3", + podcast_duration = "35min.", + podcast_start_position = null, + saved_for_later = false, + ) + + feedDataSource.saveTalkingKotlinEpisodeStartPosition("1", 30_000) + + assertEquals( + 30_000, + db.feedItemEntityQueries.feedItemById("1").executeAsOne().podcast_start_position, + ) + } +} diff --git a/kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedItemMappersTest.kt b/kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedItemMappersTest.kt new file mode 100644 index 00000000..917c0ec7 --- /dev/null +++ b/kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedItemMappersTest.kt @@ -0,0 +1,178 @@ +package io.github.reactivecircus.kstreamlined.kmp.feed.datasource.mapper + +import io.github.reactivecircus.kstreamlined.kmp.database.FeedItemEntity +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedOrigin +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class FeedItemMappersTest { + + @Test + fun `FeedItemEntity maps to FeedItem_KotlinBlog`() { + val entity = FeedItemEntity( + id = "1", + feed_origin_key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog Post", + publish_time = "2022-01-01T00:00:00Z".toInstant(), + content_url = "https://blog.kotlinlang.org/post", + image_url = "https://blog.kotlinlang.org/image", + description = null, + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = false, + ) + val expected = FeedItem.KotlinBlog( + id = "1", + title = "Kotlin Blog Post", + publishTime = "2022-01-01T00:00:00Z".toInstant(), + contentUrl = "https://blog.kotlinlang.org/post", + savedForLater = false, + featuredImageUrl = "https://blog.kotlinlang.org/image", + ) + assertEquals(expected, entity.asExternalModel()) + } + + @Test + fun `FeedItemEntity maps to FeedItem_KotlinYouTube`() { + val entity = FeedItemEntity( + id = "1", + feed_origin_key = FeedOrigin.Key.KotlinYouTubeChannel.name, + title = "Kotlin YouTube Video", + publish_time = "2022-01-02T00:00:00Z".toInstant(), + content_url = "https://youtube.com/kotlinvideo", + image_url = "https://youtube.com/kotlinvideo/thumbnail", + description = "A YouTube video about Kotlin", + issue_number = null, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = false, + ) + val expected = FeedItem.KotlinYouTube( + id = "1", + title = "Kotlin YouTube Video", + publishTime = "2022-01-02T00:00:00Z".toInstant(), + contentUrl = "https://youtube.com/kotlinvideo", + savedForLater = false, + thumbnailUrl = "https://youtube.com/kotlinvideo/thumbnail", + description = "A YouTube video about Kotlin", + ) + assertEquals(expected, entity.asExternalModel()) + } + + @Test + fun `FeedItemEntity maps to FeedItem_TalkingKotlin`() { + val entity = FeedItemEntity( + id = "1", + feed_origin_key = FeedOrigin.Key.TalkingKotlinPodcast.name, + title = "Talking Kotlin Episode", + publish_time = "2022-01-03T00:00:00Z".toInstant(), + content_url = "content-url", + image_url = "image-url", + description = "Desc", + issue_number = null, + podcast_audio_url = "audio.mp3", + podcast_duration = "35min.", + podcast_start_position = 30_000, + saved_for_later = false, + ) + val expected = FeedItem.TalkingKotlin( + id = "1", + title = "Talking Kotlin Episode", + publishTime = "2022-01-03T00:00:00Z".toInstant(), + contentUrl = "content-url", + savedForLater = false, + audioUrl = "audio.mp3", + thumbnailUrl = "image-url", + summary = "Desc", + duration = "35min.", + startPositionMillis = 30_000, + ) + assertEquals(expected, entity.asExternalModel()) + } + + @Test + fun `FeedItemEntity with podcast_start_position = null maps to FeedItem_TalkingKotlin with startPositionMillis = 0`() { + val entity = FeedItemEntity( + id = "1", + feed_origin_key = FeedOrigin.Key.TalkingKotlinPodcast.name, + title = "Talking Kotlin Episode", + publish_time = "2022-01-03T00:00:00Z".toInstant(), + content_url = "content-url", + image_url = "image-url", + description = "Desc", + issue_number = null, + podcast_audio_url = "audio.mp3", + podcast_duration = "35min.", + podcast_start_position = null, + saved_for_later = false, + ) + val expected = FeedItem.TalkingKotlin( + id = "1", + title = "Talking Kotlin Episode", + publishTime = "2022-01-03T00:00:00Z".toInstant(), + contentUrl = "content-url", + savedForLater = false, + audioUrl = "audio.mp3", + thumbnailUrl = "image-url", + summary = "Desc", + duration = "35min.", + startPositionMillis = 0, + ) + assertEquals(expected, entity.asExternalModel()) + } + + @Test + fun `FeedItemEntity maps to FeedItem_KotlinWeekly`() { + val entity = FeedItemEntity( + id = "1", + feed_origin_key = FeedOrigin.Key.KotlinWeekly.name, + title = "Kotlin Weekly Issue", + publish_time = "2022-01-04T00:00:00Z".toInstant(), + content_url = "content-url", + image_url = "image-url", + description = null, + issue_number = 100, + podcast_audio_url = null, + podcast_duration = null, + podcast_start_position = null, + saved_for_later = false, + ) + val expected = FeedItem.KotlinWeekly( + id = "1", + title = "Kotlin Weekly Issue", + publishTime = "2022-01-04T00:00:00Z".toInstant(), + contentUrl = "content-url", + savedForLater = false, + issueNumber = 100, + ) + assertEquals(expected, entity.asExternalModel()) + } + + @Test + fun `throws error when mapping to FeedItemEntity with unknown key`() { + val entity = FeedItemEntity( + id = "1", + feed_origin_key = "UnknownKey", + title = "Unknown", + publish_time = "2022-01-01T00:00:00Z".toInstant(), + content_url = "content-url", + image_url = "image-url", + description = "Desc", + issue_number = null, + podcast_audio_url = "audio.mp3", + podcast_duration = "35min.", + podcast_start_position = 30_000, + saved_for_later = false, + ) + val error = assertFailsWith { + entity.asExternalModel() + } + assertEquals("Unknown feed origin key: UnknownKey", error.message) + } +} diff --git a/kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedOriginMappersTest.kt b/kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedOriginMappersTest.kt new file mode 100644 index 00000000..f14b9a60 --- /dev/null +++ b/kmp/feed-datasource/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedOriginMappersTest.kt @@ -0,0 +1,92 @@ +package io.github.reactivecircus.kstreamlined.kmp.feed.datasource.mapper + +import io.github.reactivecircus.kstreamlined.kmp.database.FeedOriginEntity +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedOrigin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class FeedOriginMappersTest { + + @Test + fun `FeedOriginEntity maps to FeedOrigin with KotlinBlog key`() { + val entity = FeedOriginEntity( + key = FeedOrigin.Key.KotlinBlog.name, + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = true, + ) + val expected = FeedOrigin( + title = "Kotlin Blog", + description = "Latest news from the official Kotlin Blog", + selected = true, + key = FeedOrigin.Key.KotlinBlog + ) + assertEquals(expected, entity.asExternalModel()) + } + + @Test + fun `FeedOriginEntity maps to FeedOrigin with KotlinYouTubeChannel key`() { + val entity = FeedOriginEntity( + key = FeedOrigin.Key.KotlinYouTubeChannel.name, + title = "Kotlin YouTube", + description = "The official YouTube channel of the Kotlin programming language", + selected = true, + ) + val expected = FeedOrigin( + title = "Kotlin YouTube", + description = "The official YouTube channel of the Kotlin programming language", + selected = true, + key = FeedOrigin.Key.KotlinYouTubeChannel + ) + assertEquals(expected, entity.asExternalModel()) + } + + @Test + fun `FeedOriginEntity maps to FeedOrigin with TalkingKotlinPodcast key`() { + val entity = FeedOriginEntity( + key = FeedOrigin.Key.TalkingKotlinPodcast.name, + title = "Talking Kotlin", + description = "Technical show discussing everything Kotlin, hosted by Hadi and Sebastian", + selected = true, + ) + val expected = FeedOrigin( + title = "Talking Kotlin", + description = "Technical show discussing everything Kotlin, hosted by Hadi and Sebastian", + selected = true, + key = FeedOrigin.Key.TalkingKotlinPodcast + ) + assertEquals(expected, entity.asExternalModel()) + } + + @Test + fun `FeedOriginEntity maps to FeedOrigin with KotlinWeekly key`() { + val entity = FeedOriginEntity( + key = FeedOrigin.Key.KotlinWeekly.name, + title = "Kotlin Weekly", + description = "Weekly community Kotlin newsletter, hosted by Enrique", + selected = true, + ) + val expected = FeedOrigin( + title = "Kotlin Weekly", + description = "Weekly community Kotlin newsletter, hosted by Enrique", + selected = true, + key = FeedOrigin.Key.KotlinWeekly + ) + assertEquals(expected, entity.asExternalModel()) + } + + @Test + fun `throws error when mapping FeedOriginEntity with unknown key`() { + val entity = FeedOriginEntity( + key = "UnknownKey", + title = "Unknown", + description = "Unknown", + selected = true, + ) + val error = assertFailsWith { + entity.asExternalModel() + } + assertEquals("Unknown feed origin key: UnknownKey", error.message) + } +} diff --git a/kmp/feed-sync/runtime/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/sync/mapper/FeedItemEntityMappersTest.kt b/kmp/feed-sync/runtime/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/sync/mapper/FeedItemEntityMappersTest.kt index d8050507..e409ece6 100644 --- a/kmp/feed-sync/runtime/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/sync/mapper/FeedItemEntityMappersTest.kt +++ b/kmp/feed-sync/runtime/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/sync/mapper/FeedItemEntityMappersTest.kt @@ -80,7 +80,7 @@ class FeedItemEntityMappersTest { title = "Title", publish_time = "2022-01-03T00:00:00Z".toInstant(), content_url = "url", - image_url = "imageUrl", + image_url = "image-url", description = "desc", issue_number = null, podcast_audio_url = "audio.mp3", diff --git a/kmp/presentation/talking-kotlin-episode/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/presentation/talkingkotlinepisode/TalkingKotlinEpisodePresenter.kt b/kmp/presentation/talking-kotlin-episode/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/presentation/talkingkotlinepisode/TalkingKotlinEpisodePresenter.kt index 830866ce..b0e38df1 100644 --- a/kmp/presentation/talking-kotlin-episode/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/presentation/talkingkotlinepisode/TalkingKotlinEpisodePresenter.kt +++ b/kmp/presentation/talking-kotlin-episode/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/presentation/talkingkotlinepisode/TalkingKotlinEpisodePresenter.kt @@ -45,8 +45,9 @@ public class TalkingKotlinEpisodePresenter( } public suspend fun saveStartPosition(startPositionMillis: Long) { - val id = (uiState.value as TalkingKotlinEpisodeUiState.Content).episode.id - feedDataSource.saveTalkingKotlinEpisodeStartPosition(id, startPositionMillis) + (uiState.value as? TalkingKotlinEpisodeUiState.Content)?.episode?.let { episode -> + feedDataSource.saveTalkingKotlinEpisodeStartPosition(episode.id, startPositionMillis) + } } public fun togglePlayPause() {