Yuito-app-android/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediato...

536 lines
17 KiB
Kotlin

package com.keylesspalace.tusky.components.timeline
import android.os.Looper.getMainLooper
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
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.viewmodel.CachedTimelineRemoteMediator
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.nhaarman.mockitokotlin2.anyOrNull
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class CachedTimelineRemoteMediatorTest {
private val accountManager: AccountManager = mock {
on { activeAccount } doReturn AccountEntity(
id = 1,
domain = "mastodon.example",
accessToken = "token",
isActive = true
)
}
private lateinit var db: AppDatabase
@Before
@ExperimentalCoroutinesApi
fun setup() {
shadowOf(getMainLooper()).idle()
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(Gson()))
.build()
}
@After
@ExperimentalCoroutinesApi
fun tearDown() {
db.close()
}
@Test
@ExperimentalPagingApi
fun `should return error when network call returns error code`() {
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.just(Response.error(500, "".toResponseBody()))
},
db = db,
gson = Gson()
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
assertTrue(result is RemoteMediator.MediatorResult.Error)
assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is HttpException)
assertEquals(500, (result.throwable as HttpException).code())
}
@Test
@ExperimentalPagingApi
fun `should return error when network call fails`() {
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.error(IOException())
},
db = db,
gson = Gson()
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
assertTrue(result is RemoteMediator.MediatorResult.Error)
assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is IOException)
}
@Test
@ExperimentalPagingApi
fun `should not prepend statuses`() {
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock(),
db = db,
gson = Gson()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = listOf(
mockStatusEntityWithAccount("3")
),
prevKey = null,
nextKey = 1
)
)
)
val result = runBlocking { remoteMediator.load(LoadType.PREPEND, state) }
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
}
@Test
@ExperimentalPagingApi
fun `should refresh and insert placeholder`() {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
)
db.insert(statusesAlreadyInDb)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
on { homeTimeline(limit = 20) } doReturn Single.just(
Response.success(
listOf(
mockStatus("8"),
mockStatus("7"),
mockStatus("5")
)
)
)
on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just(
Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
)
)
)
},
db = db,
gson = Gson()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = statusesAlreadyInDb,
prevKey = null,
nextKey = 0
)
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockStatusEntityWithAccount("5"),
TimelineStatusWithAccount().apply {
status = Placeholder("4", loading = false).toEntity(1)
},
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
)
)
}
@Test
@ExperimentalPagingApi
fun `should refresh and not insert placeholders`() {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
)
db.insert(statusesAlreadyInDb)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
on { homeTimeline(limit = 20) } doReturn Single.just(
Response.success(
listOf(
mockStatus("6"),
mockStatus("4"),
mockStatus("3")
)
)
)
on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just(
Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
)
)
)
},
db = db,
gson = Gson()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = statusesAlreadyInDb,
prevKey = null,
nextKey = 0
)
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
listOf(
mockStatusEntityWithAccount("6"),
mockStatusEntityWithAccount("4"),
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
)
)
}
@Test
@ExperimentalPagingApi
fun `should not try to refresh already cached statuses when db is empty`() {
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
on { homeTimeline(limit = 20) } doReturn Single.just(
Response.success(
listOf(
mockStatus("5"),
mockStatus("4"),
mockStatus("3")
)
)
)
},
db = db,
gson = Gson()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = emptyList(),
prevKey = null,
nextKey = 0
)
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
listOf(
mockStatusEntityWithAccount("5"),
mockStatusEntityWithAccount("4"),
mockStatusEntityWithAccount("3")
)
)
}
@Test
@ExperimentalPagingApi
fun `should remove deleted status from db and keep state of other cached statuses`() {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("3", expanded = true),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1", expanded = false),
)
db.insert(statusesAlreadyInDb)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
on { homeTimeline(limit = 20) } doReturn Single.just(
Response.success(emptyList())
)
on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just(
Response.success(
listOf(
mockStatus("3"),
mockStatus("1")
)
)
)
},
db = db,
gson = Gson()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = statusesAlreadyInDb,
prevKey = null,
nextKey = 0
)
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
listOf(
mockStatusEntityWithAccount("3", expanded = true),
mockStatusEntityWithAccount("1", expanded = false)
)
)
}
@Test
@ExperimentalPagingApi
fun `should not remove placeholder in timeline`() {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockPlaceholderEntityWithAccount("6"),
mockStatusEntityWithAccount("1"),
)
db.insert(statusesAlreadyInDb)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
on { homeTimeline(sinceId = "6", limit = 20) } doReturn Single.just(
Response.success(
listOf(
mockStatus("9"),
mockStatus("8"),
mockStatus("7")
)
)
)
on { homeTimeline(maxId = "8", sinceId = "6", limit = 20) } doReturn Single.just(
Response.success(
listOf(
mockStatus("8"),
mockStatus("7")
)
)
)
},
db = db,
gson = Gson()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = statusesAlreadyInDb,
prevKey = null,
nextKey = 0
)
)
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertFalse((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
listOf(
mockStatusEntityWithAccount("9"),
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockPlaceholderEntityWithAccount("6"),
mockStatusEntityWithAccount("1"),
)
)
}
@Test
@ExperimentalPagingApi
fun `should append statuses`() {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockStatusEntityWithAccount("5"),
)
db.insert(statusesAlreadyInDb)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
on { homeTimeline(maxId = "5", limit = 20) } doReturn Single.just(
Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1")
)
)
)
},
db = db,
gson = Gson()
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = statusesAlreadyInDb,
prevKey = null,
nextKey = 0
)
)
)
val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockStatusEntityWithAccount("5"),
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
)
)
}
private fun state(pages: List<PagingSource.LoadResult.Page<Int, TimelineStatusWithAccount>> = emptyList()) = PagingState(
pages = pages,
anchorPosition = null,
config = PagingConfig(
pageSize = 20
),
leadingPlaceholderCount = 0
)
private fun AppDatabase.insert(statuses: List<TimelineStatusWithAccount>) {
runBlocking {
statuses.forEach { statusWithAccount ->
if (statusWithAccount.status.authorServerId != null) {
timelineDao().insertAccount(statusWithAccount.account)
}
statusWithAccount.reblogAccount?.let { account ->
timelineDao().insertAccount(account)
}
timelineDao().insertStatus(statusWithAccount.status)
}
}
}
private fun AppDatabase.assertStatuses(
expected: List<TimelineStatusWithAccount>,
forAccount: Long = 1
) {
val pagingSource = timelineDao().getStatuses(forAccount)
val loadResult = runBlocking {
pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false))
}
val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data
assertEquals(expected.size, loadedStatuses.size)
for ((exp, prov) in expected.zip(loadedStatuses)) {
assertEquals(exp.status, prov.status)
if (exp.status.authorServerId != null) { // only check if no placeholder
assertEquals(exp.account, prov.account)
assertEquals(exp.reblogAccount, prov.reblogAccount)
}
}
}
}