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
This commit is contained in:
2026-03-29 20:41:51 -04:00
parent f0922e3c03
commit 473457df2f
15 changed files with 1328 additions and 0 deletions

View File

@@ -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())
}
}
}

View File

@@ -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<List<FeedItemEntity>>
@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<String>): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE isRead = 0 ORDER BY published DESC")
fun getUnreadItems(): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE isStarred = 1 ORDER BY published DESC")
fun getStarredItems(): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE published > :date ORDER BY published DESC")
fun getItemsAfterDate(date: Date): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId AND published > :date ORDER BY published DESC")
fun getSubscriptionItemsAfterDate(subscriptionId: String, date: Date): Flow<List<FeedItemEntity>>
@Query("SELECT COUNT(*) FROM feed_items WHERE subscriptionId = :subscriptionId AND isRead = 0")
fun getUnreadCount(subscriptionId: String): Flow<Int>
@Query("SELECT COUNT(*) FROM feed_items WHERE isRead = 0")
fun getTotalUnreadCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: FeedItemEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List<FeedItemEntity>): List<Long>
@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<FeedItemEntity>
}

View File

@@ -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<List<SearchHistoryEntity>>
@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<List<SearchHistoryEntity>>
@Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT :limit")
fun getRecentSearches(limit: Int): Flow<List<SearchHistoryEntity>>
@Query("SELECT COUNT(*) FROM search_history")
fun getSearchHistoryCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSearchHistory(search: SearchHistoryEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSearchHistories(searches: List<SearchHistoryEntity>): List<Long>
@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
}

View File

@@ -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<List<SubscriptionEntity>>
@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<List<SubscriptionEntity>>
@Query("SELECT * FROM subscriptions WHERE category = :category ORDER BY title ASC")
fun getSubscriptionsByCategory(category: String): Flow<List<SubscriptionEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSubscription(subscription: SubscriptionEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSubscriptions(subscriptions: List<SubscriptionEntity>): List<Long>
@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<Int>
@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)
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)
}
}

View File

@@ -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<Context>()
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
)
}
}

View File

@@ -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<Context>()
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)
}
}

View File

@@ -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<Context>()
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
)
}
}

View File

@@ -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<Context>()
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
)
}
}