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