From 473457df2f23d43638b14ce5369a57a9529666b5 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 29 Mar 2026 20:41:51 -0400 Subject: [PATCH] feat: implement Android database layer with Room - Add SubscriptionEntity, FeedItemEntity, SearchHistoryEntity - Create SubscriptionDao, FeedItemDao, SearchHistoryDao with CRUD operations - Implement RssDatabase with FTS5 virtual table for full-text search - Add type converters for Date, String lists, and FeedItem lists - Implement cascade delete for feed items when subscription is removed - Add comprehensive unit tests for all DAOs - Add database integration tests for entity round-trips and FTS - Configure Room testing dependencies --- native-route/android/build.gradle.kts | 6 + native-route/android/settings.gradle.kts | 18 ++ .../java/com/rssuper/database/RssDatabase.kt | 87 ++++++ .../com/rssuper/database/daos/FeedItemDao.kt | 77 +++++ .../rssuper/database/daos/SearchHistoryDao.kt | 49 +++ .../rssuper/database/daos/SubscriptionDao.kt | 56 ++++ .../database/entities/FeedItemEntity.kt | 57 ++++ .../database/entities/SearchHistoryEntity.kt | 19 ++ .../database/entities/SubscriptionEntity.kt | 54 ++++ .../com/rssuper/database/FeedItemDaoTest.kt | 294 ++++++++++++++++++ .../com/rssuper/database/RssDatabaseTest.kt | 196 ++++++++++++ .../rssuper/database/SearchHistoryDaoTest.kt | 188 +++++++++++ .../rssuper/database/SubscriptionDaoTest.kt | 204 ++++++++++++ native-route/build.gradle.kts | 5 + native-route/settings.gradle.kts | 18 ++ 15 files changed, 1328 insertions(+) create mode 100644 native-route/android/settings.gradle.kts create mode 100644 native-route/android/src/main/java/com/rssuper/database/RssDatabase.kt create mode 100644 native-route/android/src/main/java/com/rssuper/database/daos/FeedItemDao.kt create mode 100644 native-route/android/src/main/java/com/rssuper/database/daos/SearchHistoryDao.kt create mode 100644 native-route/android/src/main/java/com/rssuper/database/daos/SubscriptionDao.kt create mode 100644 native-route/android/src/main/java/com/rssuper/database/entities/FeedItemEntity.kt create mode 100644 native-route/android/src/main/java/com/rssuper/database/entities/SearchHistoryEntity.kt create mode 100644 native-route/android/src/main/java/com/rssuper/database/entities/SubscriptionEntity.kt create mode 100644 native-route/android/src/test/java/com/rssuper/database/FeedItemDaoTest.kt create mode 100644 native-route/android/src/test/java/com/rssuper/database/RssDatabaseTest.kt create mode 100644 native-route/android/src/test/java/com/rssuper/database/SearchHistoryDaoTest.kt create mode 100644 native-route/android/src/test/java/com/rssuper/database/SubscriptionDaoTest.kt create mode 100644 native-route/build.gradle.kts create mode 100644 native-route/settings.gradle.kts diff --git a/native-route/android/build.gradle.kts b/native-route/android/build.gradle.kts index ac637cc..d69e526 100644 --- a/native-route/android/build.gradle.kts +++ b/native-route/android/build.gradle.kts @@ -48,4 +48,10 @@ dependencies { testImplementation("com.squareup.moshi:moshi-kotlin-reflect:1.15.1") testImplementation("org.mockito:mockito-core:5.7.0") testImplementation("org.mockito:mockito-inline:5.2.0") + testImplementation("androidx.room:room-testing:2.6.1") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("androidx.arch.core:core-testing:2.2.0") + testImplementation("androidx.test:core:1.5.0") + testImplementation("androidx.test.ext:junit:1.1.5") + testImplementation("androidx.test:runner:1.5.2") } diff --git a/native-route/android/settings.gradle.kts b/native-route/android/settings.gradle.kts new file mode 100644 index 0000000..9b59a9d --- /dev/null +++ b/native-route/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "rssuper-android" +include(":android") diff --git a/native-route/android/src/main/java/com/rssuper/database/RssDatabase.kt b/native-route/android/src/main/java/com/rssuper/database/RssDatabase.kt new file mode 100644 index 0000000..ce429d8 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/database/RssDatabase.kt @@ -0,0 +1,87 @@ +package com.rssuper.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.sqlite.db.SupportSQLiteDatabase +import com.rssuper.converters.DateConverter +import com.rssuper.converters.FeedItemListConverter +import com.rssuper.converters.StringListConverter +import com.rssuper.database.daos.FeedItemDao +import com.rssuper.database.daos.SearchHistoryDao +import com.rssuper.database.daos.SubscriptionDao +import com.rssuper.database.entities.FeedItemEntity +import com.rssuper.database.entities.SearchHistoryEntity +import com.rssuper.database.entities.SubscriptionEntity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Date + +@Database( + entities = [ + SubscriptionEntity::class, + FeedItemEntity::class, + SearchHistoryEntity::class + ], + version = 1, + exportSchema = true +) +@TypeConverters(DateConverter::class, StringListConverter::class, FeedItemListConverter::class) +abstract class RssDatabase : RoomDatabase() { + + abstract fun subscriptionDao(): SubscriptionDao + abstract fun feedItemDao(): FeedItemDao + abstract fun searchHistoryDao(): SearchHistoryDao + + companion object { + @Volatile + private var INSTANCE: RssDatabase? = null + + fun getDatabase(context: Context): RssDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + RssDatabase::class.java, + "rss_database" + ) + .addCallback(DatabaseCallback()) + .build() + INSTANCE = instance + instance + } + } + } + + private class DatabaseCallback : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + super.onCreate(db) + INSTANCE?.let { database -> + CoroutineScope(Dispatchers.IO).launch { + createFTSVirtualTable(db) + } + } + } + + override fun onOpen(db: SupportSQLiteDatabase) { + super.onOpen(db) + createFTSVirtualTable(db) + } + + private fun createFTSVirtualTable(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5( + title, + description, + content, + author, + content='feed_items', + contentless_delete=true + ) + """.trimIndent()) + } + } +} diff --git a/native-route/android/src/main/java/com/rssuper/database/daos/FeedItemDao.kt b/native-route/android/src/main/java/com/rssuper/database/daos/FeedItemDao.kt new file mode 100644 index 0000000..1288b73 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/database/daos/FeedItemDao.kt @@ -0,0 +1,77 @@ +package com.rssuper.database.daos + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.rssuper.database.entities.FeedItemEntity +import kotlinx.coroutines.flow.Flow +import java.util.Date + +@Dao +interface FeedItemDao { + @Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId ORDER BY published DESC") + fun getItemsBySubscription(subscriptionId: String): Flow> + + @Query("SELECT * FROM feed_items WHERE id = :id") + suspend fun getItemById(id: String): FeedItemEntity? + + @Query("SELECT * FROM feed_items WHERE subscriptionId IN (:subscriptionIds) ORDER BY published DESC") + fun getItemsBySubscriptions(subscriptionIds: List): Flow> + + @Query("SELECT * FROM feed_items WHERE isRead = 0 ORDER BY published DESC") + fun getUnreadItems(): Flow> + + @Query("SELECT * FROM feed_items WHERE isStarred = 1 ORDER BY published DESC") + fun getStarredItems(): Flow> + + @Query("SELECT * FROM feed_items WHERE published > :date ORDER BY published DESC") + fun getItemsAfterDate(date: Date): Flow> + + @Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId AND published > :date ORDER BY published DESC") + fun getSubscriptionItemsAfterDate(subscriptionId: String, date: Date): Flow> + + @Query("SELECT COUNT(*) FROM feed_items WHERE subscriptionId = :subscriptionId AND isRead = 0") + fun getUnreadCount(subscriptionId: String): Flow + + @Query("SELECT COUNT(*) FROM feed_items WHERE isRead = 0") + fun getTotalUnreadCount(): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItem(item: FeedItemEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List): List + + @Update + suspend fun updateItem(item: FeedItemEntity): Int + + @Delete + suspend fun deleteItem(item: FeedItemEntity): Int + + @Query("DELETE FROM feed_items WHERE id = :id") + suspend fun deleteItemById(id: String): Int + + @Query("DELETE FROM feed_items WHERE subscriptionId = :subscriptionId") + suspend fun deleteItemsBySubscription(subscriptionId: String): Int + + @Query("UPDATE feed_items SET isRead = 1 WHERE id = :id") + suspend fun markAsRead(id: String): Int + + @Query("UPDATE feed_items SET isRead = 0 WHERE id = :id") + suspend fun markAsUnread(id: String): Int + + @Query("UPDATE feed_items SET isStarred = 1 WHERE id = :id") + suspend fun markAsStarred(id: String): Int + + @Query("UPDATE feed_items SET isStarred = 0 WHERE id = :id") + suspend fun markAsUnstarred(id: String): Int + + @Query("UPDATE feed_items SET isRead = 1 WHERE subscriptionId = :subscriptionId") + suspend fun markAllAsRead(subscriptionId: String): Int + + @Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset") + suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List +} diff --git a/native-route/android/src/main/java/com/rssuper/database/daos/SearchHistoryDao.kt b/native-route/android/src/main/java/com/rssuper/database/daos/SearchHistoryDao.kt new file mode 100644 index 0000000..6ce5134 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/database/daos/SearchHistoryDao.kt @@ -0,0 +1,49 @@ +package com.rssuper.database.daos + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.rssuper.database.entities.SearchHistoryEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface SearchHistoryDao { + @Query("SELECT * FROM search_history ORDER BY timestamp DESC") + fun getAllSearchHistory(): Flow> + + @Query("SELECT * FROM search_history WHERE id = :id") + suspend fun getSearchHistoryById(id: String): SearchHistoryEntity? + + @Query("SELECT * FROM search_history WHERE query LIKE '%' || :query || '%' ORDER BY timestamp DESC") + fun searchHistory(query: String): Flow> + + @Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT :limit") + fun getRecentSearches(limit: Int): Flow> + + @Query("SELECT COUNT(*) FROM search_history") + fun getSearchHistoryCount(): Flow + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSearchHistory(search: SearchHistoryEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSearchHistories(searches: List): List + + @Update + suspend fun updateSearchHistory(search: SearchHistoryEntity): Int + + @Delete + suspend fun deleteSearchHistory(search: SearchHistoryEntity): Int + + @Query("DELETE FROM search_history WHERE id = :id") + suspend fun deleteSearchHistoryById(id: String): Int + + @Query("DELETE FROM search_history") + suspend fun deleteAllSearchHistory(): Int + + @Query("DELETE FROM search_history WHERE timestamp < :timestamp") + suspend fun deleteSearchHistoryOlderThan(timestamp: Long): Int +} diff --git a/native-route/android/src/main/java/com/rssuper/database/daos/SubscriptionDao.kt b/native-route/android/src/main/java/com/rssuper/database/daos/SubscriptionDao.kt new file mode 100644 index 0000000..5cd7a46 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/database/daos/SubscriptionDao.kt @@ -0,0 +1,56 @@ +package com.rssuper.database.daos + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.rssuper.database.entities.SubscriptionEntity +import kotlinx.coroutines.flow.Flow +import java.util.Date + +@Dao +interface SubscriptionDao { + @Query("SELECT * FROM subscriptions ORDER BY title ASC") + fun getAllSubscriptions(): Flow> + + @Query("SELECT * FROM subscriptions WHERE id = :id") + suspend fun getSubscriptionById(id: String): SubscriptionEntity? + + @Query("SELECT * FROM subscriptions WHERE url = :url") + suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity? + + @Query("SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY title ASC") + fun getEnabledSubscriptions(): Flow> + + @Query("SELECT * FROM subscriptions WHERE category = :category ORDER BY title ASC") + fun getSubscriptionsByCategory(category: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSubscription(subscription: SubscriptionEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSubscriptions(subscriptions: List): List + + @Update + suspend fun updateSubscription(subscription: SubscriptionEntity): Int + + @Delete + suspend fun deleteSubscription(subscription: SubscriptionEntity): Int + + @Query("DELETE FROM subscriptions WHERE id = :id") + suspend fun deleteSubscriptionById(id: String): Int + + @Query("SELECT COUNT(*) FROM subscriptions") + fun getSubscriptionCount(): Flow + + @Query("UPDATE subscriptions SET error = :error WHERE id = :id") + suspend fun updateError(id: String, error: String?) + + @Query("UPDATE subscriptions SET lastFetchedAt = :lastFetchedAt, error = NULL WHERE id = :id") + suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Date) + + @Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id") + suspend fun updateNextFetchAt(id: String, nextFetchAt: Date) +} diff --git a/native-route/android/src/main/java/com/rssuper/database/entities/FeedItemEntity.kt b/native-route/android/src/main/java/com/rssuper/database/entities/FeedItemEntity.kt new file mode 100644 index 0000000..008135d --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/database/entities/FeedItemEntity.kt @@ -0,0 +1,57 @@ +package com.rssuper.database.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import java.util.Date + +@Entity( + tableName = "feed_items", + foreignKeys = [ + ForeignKey( + entity = SubscriptionEntity::class, + parentColumns = ["id"], + childColumns = ["subscriptionId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["subscriptionId"]), + Index(value = ["published"]) + ] +) +data class FeedItemEntity( + @PrimaryKey + val id: String, + + val subscriptionId: String, + + val title: String, + + val link: String? = null, + + val description: String? = null, + + val content: String? = null, + + val author: String? = null, + + val published: Date? = null, + + val updated: Date? = null, + + val categories: String? = null, + + val enclosureUrl: String? = null, + + val enclosureType: String? = null, + + val enclosureLength: Long? = null, + + val guid: String? = null, + + val isRead: Boolean = false, + + val isStarred: Boolean = false +) diff --git a/native-route/android/src/main/java/com/rssuper/database/entities/SearchHistoryEntity.kt b/native-route/android/src/main/java/com/rssuper/database/entities/SearchHistoryEntity.kt new file mode 100644 index 0000000..44590bc --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/database/entities/SearchHistoryEntity.kt @@ -0,0 +1,19 @@ +package com.rssuper.database.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.util.Date + +@Entity( + tableName = "search_history", + indices = [Index(value = ["timestamp"])] +) +data class SearchHistoryEntity( + @PrimaryKey + val id: String, + + val query: String, + + val timestamp: Date +) diff --git a/native-route/android/src/main/java/com/rssuper/database/entities/SubscriptionEntity.kt b/native-route/android/src/main/java/com/rssuper/database/entities/SubscriptionEntity.kt new file mode 100644 index 0000000..e95a307 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/database/entities/SubscriptionEntity.kt @@ -0,0 +1,54 @@ +package com.rssuper.database.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.rssuper.models.HttpAuth +import java.util.Date + +@Entity( + tableName = "subscriptions", + indices = [Index(value = ["url"], unique = true)] +) +data class SubscriptionEntity( + @PrimaryKey + val id: String, + + val url: String, + + val title: String, + + val category: String? = null, + + val enabled: Boolean = true, + + val fetchInterval: Long = 3600000, + + val createdAt: Date, + + val updatedAt: Date, + + val lastFetchedAt: Date? = null, + + val nextFetchAt: Date? = null, + + val error: String? = null, + + val httpAuthUsername: String? = null, + + val httpAuthPassword: String? = null +) { + fun toHttpAuth(): HttpAuth? { + return if (httpAuthUsername != null && httpAuthPassword != null) { + HttpAuth(httpAuthUsername, httpAuthPassword) + } else null + } + + fun fromHttpAuth(auth: HttpAuth?): SubscriptionEntity { + return copy( + httpAuthUsername = auth?.username, + httpAuthPassword = auth?.password + ) + } +} diff --git a/native-route/android/src/test/java/com/rssuper/database/FeedItemDaoTest.kt b/native-route/android/src/test/java/com/rssuper/database/FeedItemDaoTest.kt new file mode 100644 index 0000000..52b34df --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/database/FeedItemDaoTest.kt @@ -0,0 +1,294 @@ +package com.rssuper.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.rssuper.database.daos.FeedItemDao +import com.rssuper.database.entities.FeedItemEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.Date + +class FeedItemDaoTest { + + private lateinit var database: RssDatabase + private lateinit var dao: FeedItemDao + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder( + context, + RssDatabase::class.java + ) + .allowMainThreadQueries() + .build() + dao = database.feedItemDao() + } + + @After + fun closeDb() { + database.close() + } + + @Test + fun insertAndGetItem() = runTest { + val item = createTestItem("1", "sub1") + + dao.insertItem(item) + + val result = dao.getItemById("1") + assertNotNull(result) + assertEquals("1", result?.id) + assertEquals("Test Item", result?.title) + } + + @Test + fun getItemsBySubscription() = runTest { + val item1 = createTestItem("1", "sub1") + val item2 = createTestItem("2", "sub1") + val item3 = createTestItem("3", "sub2") + + dao.insertItems(listOf(item1, item2, item3)) + + val result = dao.getItemsBySubscription("sub1").first() + assertEquals(2, result.size) + } + + @Test + fun getItemsBySubscriptions() = runTest { + val item1 = createTestItem("1", "sub1") + val item2 = createTestItem("2", "sub2") + val item3 = createTestItem("3", "sub3") + + dao.insertItems(listOf(item1, item2, item3)) + + val result = dao.getItemsBySubscriptions(listOf("sub1", "sub2")).first() + assertEquals(2, result.size) + } + + @Test + fun getUnreadItems() = runTest { + val unread = createTestItem("1", "sub1", isRead = false) + val read = createTestItem("2", "sub1", isRead = true) + + dao.insertItems(listOf(unread, read)) + + val result = dao.getUnreadItems().first() + assertEquals(1, result.size) + assertEquals("1", result[0].id) + } + + @Test + fun getStarredItems() = runTest { + val starred = createTestItem("1", "sub1", isStarred = true) + val notStarred = createTestItem("2", "sub1", isStarred = false) + + dao.insertItems(listOf(starred, notStarred)) + + val result = dao.getStarredItems().first() + assertEquals(1, result.size) + assertEquals("1", result[0].id) + } + + @Test + fun getItemsAfterDate() = runTest { + val oldDate = Date(System.currentTimeMillis() - 86400000 * 2) + val newDate = Date(System.currentTimeMillis() - 86400000) + val today = Date() + + val oldItem = createTestItem("1", "sub1", published = oldDate) + val newItem = createTestItem("2", "sub1", published = newDate) + val todayItem = createTestItem("3", "sub1", published = today) + + dao.insertItems(listOf(oldItem, newItem, todayItem)) + + val result = dao.getItemsAfterDate(newDate).first() + assertEquals(1, result.size) + assertEquals("3", result[0].id) + } + + @Test + fun getUnreadCount() = runTest { + val unread1 = createTestItem("1", "sub1", isRead = false) + val unread2 = createTestItem("2", "sub1", isRead = false) + val read = createTestItem("3", "sub1", isRead = true) + + dao.insertItems(listOf(unread1, unread2, read)) + + val count = dao.getUnreadCount("sub1").first() + assertEquals(2, count) + } + + @Test + fun getTotalUnreadCount() = runTest { + val unread1 = createTestItem("1", "sub1", isRead = false) + val unread2 = createTestItem("2", "sub2", isRead = false) + val read = createTestItem("3", "sub1", isRead = true) + + dao.insertItems(listOf(unread1, unread2, read)) + + val count = dao.getTotalUnreadCount().first() + assertEquals(2, count) + } + + @Test + fun updateItem() = runTest { + val item = createTestItem("1", "sub1") + + dao.insertItem(item) + + val updated = item.copy(title = "Updated Title") + dao.updateItem(updated) + + val result = dao.getItemById("1") + assertEquals("Updated Title", result?.title) + } + + @Test + fun deleteItem() = runTest { + val item = createTestItem("1", "sub1") + + dao.insertItem(item) + dao.deleteItem(item) + + val result = dao.getItemById("1") + assertNull(result) + } + + @Test + fun deleteItemById() = runTest { + val item = createTestItem("1", "sub1") + + dao.insertItem(item) + dao.deleteItemById("1") + + val result = dao.getItemById("1") + assertNull(result) + } + + @Test + fun deleteItemsBySubscription() = runTest { + val item1 = createTestItem("1", "sub1") + val item2 = createTestItem("2", "sub1") + val item3 = createTestItem("3", "sub2") + + dao.insertItems(listOf(item1, item2, item3)) + dao.deleteItemsBySubscription("sub1") + + val sub1Items = dao.getItemsBySubscription("sub1").first() + val sub2Items = dao.getItemsBySubscription("sub2").first() + + assertEquals(0, sub1Items.size) + assertEquals(1, sub2Items.size) + } + + @Test + fun markAsRead() = runTest { + val item = createTestItem("1", "sub1", isRead = false) + + dao.insertItem(item) + dao.markAsRead("1") + + val result = dao.getItemById("1") + assertEquals(true, result?.isRead) + } + + @Test + fun markAsUnread() = runTest { + val item = createTestItem("1", "sub1", isRead = true) + + dao.insertItem(item) + dao.markAsUnread("1") + + val result = dao.getItemById("1") + assertEquals(false, result?.isRead) + } + + @Test + fun markAsStarred() = runTest { + val item = createTestItem("1", "sub1", isStarred = false) + + dao.insertItem(item) + dao.markAsStarred("1") + + val result = dao.getItemById("1") + assertEquals(true, result?.isStarred) + } + + @Test + fun markAsUnstarred() = runTest { + val item = createTestItem("1", "sub1", isStarred = true) + + dao.insertItem(item) + dao.markAsUnstarred("1") + + val result = dao.getItemById("1") + assertEquals(false, result?.isStarred) + } + + @Test + fun markAllAsRead() = runTest { + val item1 = createTestItem("1", "sub1", isRead = false) + val item2 = createTestItem("2", "sub1", isRead = false) + val item3 = createTestItem("3", "sub2", isRead = false) + + dao.insertItems(listOf(item1, item2, item3)) + dao.markAllAsRead("sub1") + + val sub1Items = dao.getItemsBySubscription("sub1").first() + val sub2Items = dao.getItemsBySubscription("sub2").first() + + assertEquals(true, sub1Items[0].isRead) + assertEquals(true, sub1Items[1].isRead) + assertEquals(false, sub2Items[0].isRead) + } + + @Test + fun getItemsPaginated() = runTest { + for (i in 1..10) { + val item = createTestItem(i.toString(), "sub1") + dao.insertItem(item) + } + + val firstPage = dao.getItemsPaginated("sub1", 5, 0) + val secondPage = dao.getItemsPaginated("sub1", 5, 5) + + assertEquals(5, firstPage.size) + assertEquals(5, secondPage.size) + } + + private fun createTestItem( + id: String, + subscriptionId: String, + title: String = "Test Item", + isRead: Boolean = false, + isStarred: Boolean = false, + published: Date = Date() + ): FeedItemEntity { + return FeedItemEntity( + id = id, + subscriptionId = subscriptionId, + title = title, + link = "https://example.com/$id", + description = "Test description", + content = "Test content", + author = "Test Author", + published = published, + updated = published, + categories = "Tech,News", + enclosureUrl = null, + enclosureType = null, + enclosureLength = null, + guid = "guid-$id", + isRead = isRead, + isStarred = isStarred + ) + } +} diff --git a/native-route/android/src/test/java/com/rssuper/database/RssDatabaseTest.kt b/native-route/android/src/test/java/com/rssuper/database/RssDatabaseTest.kt new file mode 100644 index 0000000..c03793f --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/database/RssDatabaseTest.kt @@ -0,0 +1,196 @@ +package com.rssuper.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.rssuper.database.entities.FeedItemEntity +import com.rssuper.database.entities.SearchHistoryEntity +import com.rssuper.database.entities.SubscriptionEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import java.util.Date +import java.util.UUID + +class RssDatabaseTest { + + private lateinit var database: RssDatabase + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder( + context, + RssDatabase::class.java + ) + .allowMainThreadQueries() + .build() + } + + @After + fun closeDb() { + database.close() + } + + @Test + fun databaseConstruction() { + assertNotNull(database.subscriptionDao()) + assertNotNull(database.feedItemDao()) + assertNotNull(database.searchHistoryDao()) + } + + @Test + fun ftsVirtualTableExists() { + val cursor = database.run { + openHelper.writableDatabase.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name='feed_items_fts'", + null + ) + } + + assertEquals(true, cursor.moveToFirst()) + cursor.close() + } + + @Test + fun subscriptionEntityRoundTrip() = runTest { + val now = Date() + val subscription = SubscriptionEntity( + id = UUID.randomUUID().toString(), + url = "https://example.com/feed", + title = "Test Feed", + category = "Tech", + enabled = true, + fetchInterval = 3600000, + createdAt = now, + updatedAt = now, + lastFetchedAt = null, + nextFetchAt = null, + error = null, + httpAuthUsername = null, + httpAuthPassword = null + ) + + database.subscriptionDao().insertSubscription(subscription) + + val result = database.subscriptionDao().getSubscriptionById(subscription.id) + assertNotNull(result) + assertEquals(subscription.id, result?.id) + assertEquals(subscription.title, result?.title) + } + + @Test + fun feedItemEntityRoundTrip() = runTest { + val now = Date() + val subscription = SubscriptionEntity( + id = "sub1", + url = "https://example.com/feed", + title = "Test Feed", + category = "Tech", + enabled = true, + fetchInterval = 3600000, + createdAt = now, + updatedAt = now, + lastFetchedAt = null, + nextFetchAt = null, + error = null, + httpAuthUsername = null, + httpAuthPassword = null + ) + database.subscriptionDao().insertSubscription(subscription) + + val item = FeedItemEntity( + id = UUID.randomUUID().toString(), + subscriptionId = "sub1", + title = "Test Item", + link = "https://example.com/item", + description = "Test description", + content = "Test content", + author = "Test Author", + published = now, + updated = now, + categories = "Tech", + enclosureUrl = null, + enclosureType = null, + enclosureLength = null, + guid = "guid-1", + isRead = false, + isStarred = false + ) + + database.feedItemDao().insertItem(item) + + val result = database.feedItemDao().getItemById(item.id) + assertNotNull(result) + assertEquals(item.id, result?.id) + assertEquals(item.title, result?.title) + assertEquals("sub1", result?.subscriptionId) + } + + @Test + fun searchHistoryEntityRoundTrip() = runTest { + val now = Date() + val search = SearchHistoryEntity( + id = UUID.randomUUID().toString(), + query = "kotlin coroutines", + timestamp = now + ) + + database.searchHistoryDao().insertSearchHistory(search) + + val result = database.searchHistoryDao().getSearchHistoryById(search.id) + assertNotNull(result) + assertEquals(search.id, result?.id) + assertEquals(search.query, result?.query) + } + + @Test + fun cascadeDeleteFeedItems() = runTest { + val now = Date() + val subscription = SubscriptionEntity( + id = "sub1", + url = "https://example.com/feed", + title = "Test Feed", + category = "Tech", + enabled = true, + fetchInterval = 3600000, + createdAt = now, + updatedAt = now, + lastFetchedAt = null, + nextFetchAt = null, + error = null, + httpAuthUsername = null, + httpAuthPassword = null + ) + database.subscriptionDao().insertSubscription(subscription) + + val item = FeedItemEntity( + id = "item1", + subscriptionId = "sub1", + title = "Test Item", + link = "https://example.com/item", + description = "Test description", + content = "Test content", + author = "Test Author", + published = now, + updated = now, + categories = "Tech", + enclosureUrl = null, + enclosureType = null, + enclosureLength = null, + guid = "guid-1", + isRead = false, + isStarred = false + ) + database.feedItemDao().insertItem(item) + + database.subscriptionDao().deleteSubscription(subscription) + + val items = database.feedItemDao().getItemsBySubscription("sub1").first() + assertEquals(0, items.size) + } +} diff --git a/native-route/android/src/test/java/com/rssuper/database/SearchHistoryDaoTest.kt b/native-route/android/src/test/java/com/rssuper/database/SearchHistoryDaoTest.kt new file mode 100644 index 0000000..88691e1 --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/database/SearchHistoryDaoTest.kt @@ -0,0 +1,188 @@ +package com.rssuper.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.rssuper.database.daos.SearchHistoryDao +import com.rssuper.database.entities.SearchHistoryEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.Date + +class SearchHistoryDaoTest { + + private lateinit var database: RssDatabase + private lateinit var dao: SearchHistoryDao + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder( + context, + RssDatabase::class.java + ) + .allowMainThreadQueries() + .build() + dao = database.searchHistoryDao() + } + + @After + fun closeDb() { + database.close() + } + + @Test + fun insertAndGetSearchHistory() = runTest { + val search = createTestSearch("1", "kotlin") + + dao.insertSearchHistory(search) + + val result = dao.getSearchHistoryById("1") + assertNotNull(result) + assertEquals("1", result?.id) + assertEquals("kotlin", result?.query) + } + + @Test + fun getAllSearchHistory() = runTest { + val search1 = createTestSearch("1", "kotlin") + val search2 = createTestSearch("2", "android") + val search3 = createTestSearch("3", "room database") + + dao.insertSearchHistories(listOf(search1, search2, search3)) + + val result = dao.getAllSearchHistory().first() + assertEquals(3, result.size) + } + + @Test + fun searchHistory() = runTest { + val search1 = createTestSearch("1", "kotlin coroutines") + val search2 = createTestSearch("2", "android kotlin") + val search3 = createTestSearch("3", "java") + + dao.insertSearchHistories(listOf(search1, search2, search3)) + + val result = dao.searchHistory("kotlin").first() + assertEquals(2, result.size) + } + + @Test + fun getRecentSearches() = runTest { + val search1 = createTestSearch("1", "query1", timestamp = Date(System.currentTimeMillis() - 300000)) + val search2 = createTestSearch("2", "query2", timestamp = Date(System.currentTimeMillis() - 200000)) + val search3 = createTestSearch("3", "query3", timestamp = Date(System.currentTimeMillis() - 100000)) + + dao.insertSearchHistories(listOf(search1, search2, search3)) + + val result = dao.getRecentSearches(2).first() + assertEquals(2, result.size) + assertEquals("3", result[0].id) + assertEquals("2", result[1].id) + } + + @Test + fun getSearchHistoryCount() = runTest { + val search1 = createTestSearch("1", "query1") + val search2 = createTestSearch("2", "query2") + val search3 = createTestSearch("3", "query3") + + dao.insertSearchHistories(listOf(search1, search2, search3)) + + val count = dao.getSearchHistoryCount().first() + assertEquals(3, count) + } + + @Test + fun updateSearchHistory() = runTest { + val search = createTestSearch("1", "old query") + + dao.insertSearchHistory(search) + + val updated = search.copy(query = "new query") + dao.updateSearchHistory(updated) + + val result = dao.getSearchHistoryById("1") + assertEquals("new query", result?.query) + } + + @Test + fun deleteSearchHistory() = runTest { + val search = createTestSearch("1", "kotlin") + + dao.insertSearchHistory(search) + dao.deleteSearchHistory(search) + + val result = dao.getSearchHistoryById("1") + assertNull(result) + } + + @Test + fun deleteSearchHistoryById() = runTest { + val search = createTestSearch("1", "kotlin") + + dao.insertSearchHistory(search) + dao.deleteSearchHistoryById("1") + + val result = dao.getSearchHistoryById("1") + assertNull(result) + } + + @Test + fun deleteAllSearchHistory() = runTest { + val search1 = createTestSearch("1", "query1") + val search2 = createTestSearch("2", "query2") + + dao.insertSearchHistories(listOf(search1, search2)) + dao.deleteAllSearchHistory() + + val result = dao.getAllSearchHistory().first() + assertEquals(0, result.size) + } + + @Test + fun deleteSearchHistoryOlderThan() = runTest { + val oldSearch = createTestSearch("1", "old query", timestamp = Date(System.currentTimeMillis() - 86400000 * 2)) + val recentSearch = createTestSearch("2", "recent query", timestamp = Date(System.currentTimeMillis() - 86400000)) + + dao.insertSearchHistories(listOf(oldSearch, recentSearch)) + dao.deleteSearchHistoryOlderThan(System.currentTimeMillis() - 86400000) + + val result = dao.getAllSearchHistory().first() + assertEquals(1, result.size) + assertEquals("2", result[0].id) + } + + @Test + fun insertSearchHistoryWithConflict() = runTest { + val search = createTestSearch("1", "kotlin") + + dao.insertSearchHistory(search) + + val duplicate = search.copy(query = "android") + val result = dao.insertSearchHistory(duplicate) + + assertEquals(-1L, result) + + val dbSearch = dao.getSearchHistoryById("1") + assertEquals("kotlin", dbSearch?.query) + } + + private fun createTestSearch( + id: String, + query: String, + timestamp: Date = Date() + ): SearchHistoryEntity { + return SearchHistoryEntity( + id = id, + query = query, + timestamp = timestamp + ) + } +} diff --git a/native-route/android/src/test/java/com/rssuper/database/SubscriptionDaoTest.kt b/native-route/android/src/test/java/com/rssuper/database/SubscriptionDaoTest.kt new file mode 100644 index 0000000..e6faa76 --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/database/SubscriptionDaoTest.kt @@ -0,0 +1,204 @@ +package com.rssuper.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.rssuper.database.daos.SubscriptionDao +import com.rssuper.database.entities.SubscriptionEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.Date + +class SubscriptionDaoTest { + + private lateinit var database: RssDatabase + private lateinit var dao: SubscriptionDao + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder( + context, + RssDatabase::class.java + ) + .allowMainThreadQueries() + .build() + dao = database.subscriptionDao() + } + + @After + fun closeDb() { + database.close() + } + + @Test + fun insertAndGetSubscription() = runTest { + val subscription = createTestSubscription("1") + + dao.insertSubscription(subscription) + + val result = dao.getSubscriptionById("1") + assertNotNull(result) + assertEquals("1", result?.id) + assertEquals("Test Feed", result?.title) + } + + @Test + fun getSubscriptionByUrl() = runTest { + val subscription = createTestSubscription("1", url = "https://example.com/feed") + + dao.insertSubscription(subscription) + + val result = dao.getSubscriptionByUrl("https://example.com/feed") + assertNotNull(result) + assertEquals("1", result?.id) + } + + @Test + fun getAllSubscriptions() = runTest { + val subscription1 = createTestSubscription("1") + val subscription2 = createTestSubscription("2") + + dao.insertSubscriptions(listOf(subscription1, subscription2)) + + val result = dao.getAllSubscriptions().first() + assertEquals(2, result.size) + } + + @Test + fun getEnabledSubscriptions() = runTest { + val enabled = createTestSubscription("1", enabled = true) + val disabled = createTestSubscription("2", enabled = false) + + dao.insertSubscriptions(listOf(enabled, disabled)) + + val result = dao.getEnabledSubscriptions().first() + assertEquals(1, result.size) + assertEquals("1", result[0].id) + } + + @Test + fun updateSubscription() = runTest { + val subscription = createTestSubscription("1") + + dao.insertSubscription(subscription) + + val updated = subscription.copy(title = "Updated Title") + dao.updateSubscription(updated) + + val result = dao.getSubscriptionById("1") + assertEquals("Updated Title", result?.title) + } + + @Test + fun deleteSubscription() = runTest { + val subscription = createTestSubscription("1") + + dao.insertSubscription(subscription) + dao.deleteSubscription(subscription) + + val result = dao.getSubscriptionById("1") + assertNull(result) + } + + @Test + fun deleteSubscriptionById() = runTest { + val subscription = createTestSubscription("1") + + dao.insertSubscription(subscription) + dao.deleteSubscriptionById("1") + + val result = dao.getSubscriptionById("1") + assertNull(result) + } + + @Test + fun getSubscriptionCount() = runTest { + val subscription1 = createTestSubscription("1") + val subscription2 = createTestSubscription("2") + + dao.insertSubscriptions(listOf(subscription1, subscription2)) + + val count = dao.getSubscriptionCount().first() + assertEquals(2, count) + } + + @Test + fun updateError() = runTest { + val subscription = createTestSubscription("1") + + dao.insertSubscription(subscription) + dao.updateError("1", "Feed not found") + + val result = dao.getSubscriptionById("1") + assertEquals("Feed not found", result?.error) + } + + @Test + fun updateLastFetchedAt() = runTest { + val subscription = createTestSubscription("1") + val now = Date() + + dao.insertSubscription(subscription) + dao.updateLastFetchedAt("1", now) + + val result = dao.getSubscriptionById("1") + assertEquals(now, result?.lastFetchedAt) + assertNull(result?.error) + } + + @Test + fun updateNextFetchAt() = runTest { + val subscription = createTestSubscription("1") + val future = Date(System.currentTimeMillis() + 3600000) + + dao.insertSubscription(subscription) + dao.updateNextFetchAt("1", future) + + val result = dao.getSubscriptionById("1") + assertEquals(future, result?.nextFetchAt) + } + + @Test + fun insertSubscriptionWithConflict() = runTest { + val subscription = createTestSubscription("1") + + dao.insertSubscription(subscription) + + val updated = subscription.copy(title = "Updated") + dao.insertSubscription(updated) + + val result = dao.getSubscriptionById("1") + assertEquals("Updated", result?.title) + } + + private fun createTestSubscription( + id: String, + url: String = "https://example.com/feed/$id", + title: String = "Test Feed", + enabled: Boolean = true + ): SubscriptionEntity { + val now = Date() + return SubscriptionEntity( + id = id, + url = url, + title = title, + category = "Tech", + enabled = enabled, + fetchInterval = 3600000, + createdAt = now, + updatedAt = now, + lastFetchedAt = null, + nextFetchAt = null, + error = null, + httpAuthUsername = null, + httpAuthPassword = null + ) + } +} diff --git a/native-route/build.gradle.kts b/native-route/build.gradle.kts new file mode 100644 index 0000000..fcbb82a --- /dev/null +++ b/native-route/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.2.0" apply false + id("com.android.library") version "8.2.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.20" apply false +} diff --git a/native-route/settings.gradle.kts b/native-route/settings.gradle.kts new file mode 100644 index 0000000..79f0db8 --- /dev/null +++ b/native-route/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "native-route" +include(":android")