package com.keylesspalace.tusky.db import androidx.paging.PagingSource import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.entity.Status import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config @Config(sdk = [28]) @RunWith(AndroidJUnit4::class) class TimelineDaoTest { private lateinit var timelineDao: TimelineDao private lateinit var db: AppDatabase @Before fun createDb() { val context = InstrumentationRegistry.getInstrumentation().targetContext db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .addTypeConverter(Converters(Gson())) .allowMainThreadQueries() .build() timelineDao = db.timelineDao() } @After fun closeDb() { db.close() } @Test fun insertGetStatus() = runBlocking { val setOne = makeStatus(statusId = 3) val setTwo = makeStatus(statusId = 20, reblog = true) val ignoredOne = makeStatus(statusId = 1) val ignoredTwo = makeStatus(accountId = 2) for ((status, author, reblogger) in listOf(setOne, setTwo, ignoredOne, ignoredTwo)) { timelineDao.insertAccount(author) reblogger?.let { timelineDao.insertAccount(it) } timelineDao.insertStatus(status) } val pagingSource = timelineDao.getStatusesForAccount(setOne.first.timelineUserId) val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false)) val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data assertEquals(2, loadedStatuses.size) assertStatuses(listOf(setTwo, setOne), loadedStatuses) } @Test fun cleanup() = runBlocking { val statusesBeforeCleanup = listOf( makeStatus(statusId = 100), makeStatus(statusId = 10, authorServerId = "3"), makeStatus(statusId = 8, reblog = true, authorServerId = "10"), makeStatus(statusId = 5), makeStatus(statusId = 3, authorServerId = "4"), makeStatus(statusId = 2, accountId = 2, authorServerId = "5"), makeStatus(statusId = 1, authorServerId = "5") ) val statusesAfterCleanup = listOf( makeStatus(statusId = 100), makeStatus(statusId = 10, authorServerId = "3"), makeStatus(statusId = 8, reblog = true, authorServerId = "10"), makeStatus(statusId = 2, accountId = 2, authorServerId = "5"), ) for ((status, author, reblogAuthor) in statusesBeforeCleanup) { timelineDao.insertAccount(author) reblogAuthor?.let { timelineDao.insertAccount(it) } timelineDao.insertStatus(status) } timelineDao.cleanup(accountId = 1, limit = 3) timelineDao.cleanupAccounts(accountId = 1) val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, false) val loadedStatuses = (timelineDao.getStatusesForAccount(1).load(loadParams) as PagingSource.LoadResult.Page).data assertStatuses(statusesAfterCleanup, loadedStatuses) val loadedAccounts: MutableList> = mutableListOf() val accountCursor = db.query("SELECT timelineUserId, serverId FROM TimelineAccountEntity", null) accountCursor.moveToFirst() while (!accountCursor.isAfterLast) { val accountId: Long = accountCursor.getLong(accountCursor.getColumnIndex("timelineUserId")) val serverId: String = accountCursor.getString(accountCursor.getColumnIndex("serverId")) loadedAccounts.add(accountId to serverId) accountCursor.moveToNext() } val expectedAccounts = listOf( 1L to "3", 1L to "10", 1L to "R10", 1L to "20", 2L to "5" ) assertEquals(expectedAccounts, loadedAccounts) } @Test fun overwriteDeletedStatus() = runBlocking { val oldStatuses = listOf( makeStatus(statusId = 3), makeStatus(statusId = 2), makeStatus(statusId = 1) ) for ((status, author, reblogAuthor) in oldStatuses) { timelineDao.insertAccount(author) reblogAuthor?.let { timelineDao.insertAccount(it) } timelineDao.insertStatus(status) } // status 2 gets deleted, newly loaded status contain only 1 + 3 val newStatuses = listOf( makeStatus(statusId = 3), makeStatus(statusId = 1) ) timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId) for ((status, author, reblogAuthor) in newStatuses) { timelineDao.insertAccount(author) reblogAuthor?.let { timelineDao.insertAccount(it) } timelineDao.insertStatus(status) } // make sure status 2 is no longer in db val pagingSource = timelineDao.getStatusesForAccount(1) val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false)) val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data assertStatuses(newStatuses, loadedStatuses) } @Test fun deleteRange() = runBlocking { val statuses = listOf( makeStatus(statusId = 100), makeStatus(statusId = 15), makeStatus(statusId = 14), makeStatus(statusId = 13), makeStatus(statusId = 12), makeStatus(statusId = 11), makeStatus(statusId = 9) ) for ((status, author, reblogAuthor) in statuses) { timelineDao.insertAccount(author) reblogAuthor?.let { timelineDao.insertAccount(it) } timelineDao.insertStatus(status) } timelineDao.deleteRange(1, "12", "14") val pagingSource = timelineDao.getStatusesForAccount(1) val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false)) val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data val remainingStatuses = listOf( makeStatus(statusId = 100), makeStatus(statusId = 15), makeStatus(statusId = 11), makeStatus(statusId = 9) ) assertStatuses(remainingStatuses, loadedStatuses) } @Test fun deleteAllForInstance() = runBlocking { val statusWithRedDomain1 = makeStatus( statusId = 15, accountId = 1, domain = "mastodon.red", authorServerId = "1" ) val statusWithRedDomain2 = makeStatus( statusId = 14, accountId = 1, domain = "mastodon.red", authorServerId = "2" ) val statusWithRedDomainOtherAccount = makeStatus( statusId = 12, accountId = 2, domain = "mastodon.red", authorServerId = "2" ) val statusWithBlueDomain = makeStatus( statusId = 10, accountId = 1, domain = "mastodon.blue", authorServerId = "4" ) val statusWithBlueDomainOtherAccount = makeStatus( statusId = 10, accountId = 2, domain = "mastodon.blue", authorServerId = "5" ) val statusWithGreenDomain = makeStatus( statusId = 8, accountId = 1, domain = "mastodon.green", authorServerId = "6" ) for ((status, author, reblogAuthor) in listOf(statusWithRedDomain1, statusWithRedDomain2, statusWithRedDomainOtherAccount, statusWithBlueDomain, statusWithBlueDomainOtherAccount, statusWithGreenDomain)) { timelineDao.insertAccount(author) reblogAuthor?.let { timelineDao.insertAccount(it) } timelineDao.insertStatus(status) } timelineDao.deleteAllFromInstance(1, "mastodon.red") timelineDao.deleteAllFromInstance(1, "mastodon.blu") // shouldn't delete anything timelineDao.deleteAllFromInstance(1, "greenmastodon.green") // shouldn't delete anything val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, false) val statusesAccount1 = (timelineDao.getStatusesForAccount(1).load(loadParams) as PagingSource.LoadResult.Page).data val statusesAccount2 = (timelineDao.getStatusesForAccount(2).load(loadParams) as PagingSource.LoadResult.Page).data assertStatuses(listOf(statusWithBlueDomain, statusWithGreenDomain), statusesAccount1) assertStatuses(listOf(statusWithRedDomainOtherAccount, statusWithBlueDomainOtherAccount), statusesAccount2) } @Test fun `should return null as topId when db is empty`() = runBlocking { assertNull(timelineDao.getTopId(1)) } @Test fun `should return correct topId`() = runBlocking { val statusData = listOf( makeStatus( statusId = 4, accountId = 1, domain = "mastodon.test", authorServerId = "1" ), makeStatus( statusId = 33, accountId = 1, domain = "mastodon.test", authorServerId = "2" ), makeStatus( statusId = 22, accountId = 1, domain = "mastodon.test", authorServerId = "2" ) ) for ((status, author, reblogAuthor) in statusData) { timelineDao.insertAccount(author) reblogAuthor?.let { timelineDao.insertAccount(it) } timelineDao.insertStatus(status) } assertEquals("33", timelineDao.getTopId(1)) } @Test fun `should return correct placeholderId after other ids`() = runBlocking { val statusData = listOf( makeStatus(statusId = 1000), makePlaceholder(id = 99), makeStatus(statusId = 97), makeStatus(statusId = 95), makePlaceholder(id = 94), makeStatus(statusId = 90) ) for ((status, author, reblogAuthor) in statusData) { author?.let { timelineDao.insertAccount(it) } reblogAuthor?.let { timelineDao.insertAccount(it) } timelineDao.insertStatus(status) } assertEquals("99", timelineDao.getNextPlaceholderIdAfter(1, "1000")) assertEquals("94", timelineDao.getNextPlaceholderIdAfter(1, "97")) assertNull(timelineDao.getNextPlaceholderIdAfter(1, "90")) } @Test fun `should return correct top placeholderId`() = runBlocking { val statusData = listOf( makeStatus(statusId = 1000), makePlaceholder(id = 99), makeStatus(statusId = 97), makePlaceholder(id = 96), makeStatus(statusId = 90), makePlaceholder(id = 80), makeStatus(statusId = 77) ) for ((status, author, reblogAuthor) in statusData) { author?.let { timelineDao.insertAccount(it) } reblogAuthor?.let { timelineDao.insertAccount(it) } timelineDao.insertStatus(status) } assertEquals("99", timelineDao.getTopPlaceholderId(1)) } private fun makeStatus( accountId: Long = 1, statusId: Long = 10, reblog: Boolean = false, createdAt: Long = statusId, authorServerId: String = "20", domain: String = "mastodon.example" ): Triple { val author = TimelineAccountEntity( authorServerId, accountId, "localUsername@$domain", "username@$domain", "displayName", "blah", "avatar", "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]", false ) val reblogAuthor = if (reblog) { TimelineAccountEntity( "R$authorServerId", accountId, "RlocalUsername", "Rusername", "RdisplayName", "Rblah", "Ravatar", "[]", false ) } else null val even = accountId % 2 == 0L val status = TimelineStatusEntity( serverId = statusId.toString(), url = "https://$domain/whatever/$statusId", timelineUserId = accountId, authorServerId = authorServerId, inReplyToId = "inReplyToId$statusId", inReplyToAccountId = "inReplyToAccountId$statusId", content = "Content!$statusId", createdAt = createdAt, emojis = "emojis$statusId", reblogsCount = 1 * statusId.toInt(), favouritesCount = 2 * statusId.toInt(), reblogged = even, favourited = !even, bookmarked = false, sensitive = even, spoilerText = "spoier$statusId", visibility = Status.Visibility.PRIVATE, attachments = "attachments$accountId", mentions = "mentions$accountId", tags = "tags$accountId", application = "application$accountId", reblogServerId = if (reblog) (statusId * 100).toString() else null, reblogAccountId = reblogAuthor?.serverId, poll = null, muted = false, expanded = false, contentCollapsed = false, contentShowing = true, pinned = false ) return Triple(status, author, reblogAuthor) } private fun makePlaceholder( accountId: Long = 1, id: Long ): Triple { val placeholder = Placeholder(id.toString(), false).toEntity(accountId) return Triple(placeholder, null, null) } private fun assertStatuses( expected: List>, provided: List ) { for ((exp, prov) in expected.zip(provided)) { val (status, author, reblogger) = exp assertEquals(status, prov.status) assertEquals(author, prov.account) assertEquals(reblogger, prov.reblogAccount) } } }