~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
221 lines · 7.1 KB Raw History
package social.grain

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.feature.notifications.NotificationsViewModel
import social.grain.feature.notifications.UnseenNotificationCount
import social.grain.support.Fixtures
import social.grain.support.Harness
import social.grain.support.MainDispatcherRule
import social.grain.support.appContext
import social.grain.support.awaitCall
import social.grain.support.awaitValue
import social.grain.support.json
import social.grain.support.settle

/**
 * The notifications list and the badge that rides on it.
 *
 * Grouping is tested on its own elsewhere; what matters here is that a second
 * page is folded into the rows already on screen rather than regrouped from
 * scratch — regrouping reorders rows under the reader's thumb — and that the
 * badge is cleared optimistically but put back if the write fails.
 */
@RunWith(AndroidJUnit4::class)
class NotificationsViewModelTest {

    @get:Rule
    val mainDispatcher = MainDispatcherRule()

    private lateinit var harness: Harness
    private lateinit var unseen: UnseenNotificationCount

    private val nsid = "social.grain.notification.listNotifications"

    private fun page(vararg items: String, cursor: String? = null) =
        """{"notifications":[${items.joinToString(",")}]""" +
            (cursor?.let { ""","cursor":"$it"""" } ?: "") + "}"

    /** What the server counts as unread. */
    private fun unread(count: Int) =
        harness.server.on("social.grain.notification.getUnreadCount", """{"count":$count}""")

    @Before
    fun setUp() {
        harness = Harness()
        unseen = UnseenNotificationCount(appContext, harness.client, harness.auth)
        harness.server.on(nsid, page(json(Fixtures.notification())))
        unread(2)
    }

    @After
    fun tearDown() = harness.tearDown()

    private fun viewModel() = NotificationsViewModel(harness.client, harness.auth, unseen)

    @Test
    fun theFirstPageLoadsAndSetsTheBadge() {
        val model = viewModel()
        val state = model.state.awaitValue("the first page") { it.hasLoaded }

        assertEquals(1, state.groups.size)
        assertFalse(state.isLoading)
        assertNull(state.errorMessage)
        assertEquals(2, unseen.count.value)
    }

    @Test
    fun favoritesOnTheSameGalleryCollapseIntoOneRow() {
        harness.server.on(
            nsid,
            page(
                json(Fixtures.notification(rkey = "n1")),
                json(Fixtures.notification(rkey = "n2", author = Fixtures.profile(did = "did:plc:bob"))),
            ),
        )
        val model = viewModel()
        val state = model.state.awaitValue("the first page") { it.hasLoaded }

        assertEquals(1, state.groups.size)
        assertEquals(2, state.groups.single().authorCount)
        assertTrue(state.groups.single().isGrouped)
    }

    @Test
    fun commentsStayOnTheirOwnRows() {
        harness.server.on(
            nsid,
            page(
                json(Fixtures.notification(rkey = "n1", reason = "gallery-comment", commentText = "One")),
                json(Fixtures.notification(rkey = "n2", reason = "gallery-comment", commentText = "Two")),
            ),
        )
        val model = viewModel()
        val state = model.state.awaitValue("the first page") { it.hasLoaded }

        assertEquals(2, state.groups.size)
    }

    @Test
    fun aFailedLoadReportsItsMessage() {
        harness.server.on(nsid, """{"error":"nope"}""", code = 500)
        val model = viewModel()

        val state = model.state.awaitValue("the error") { it.errorMessage != null }
        assertEquals("HTTP error 500", state.errorMessage)
        assertTrue(state.hasLoaded)

        model.dismissError()
        assertNull(model.state.value.errorMessage)
    }

    @Test
    fun asecondPageIsMergedIntoTheRowsAlreadyShowing() {
        harness.server.on(nsid, page(json(Fixtures.notification(rkey = "n1")), cursor = "c1"))
        val model = viewModel()
        model.state.awaitValue("the first page") { it.hasLoaded }

        harness.server.on(
            nsid,
            page(json(Fixtures.notification(rkey = "n2", author = Fixtures.profile(did = "did:plc:bob")))),
        )
        model.loadMore()

        val state = model.state.awaitValue("the merge") { it.groups.firstOrNull()?.isGrouped == true }
        assertEquals("Still one row, two faces", 1, state.groups.size)
        assertEquals(2, state.groups.single().authorCount)
        assertFalse(state.isLoadingMore)
    }

    @Test
    fun aPageWithNoCursorIsTheEndOfTheList() {
        val model = viewModel()
        model.state.awaitValue("the first page") { it.hasLoaded }
        val before = harness.server.countOf(nsid)

        model.loadMore()
        settle()

        assertEquals(before, harness.server.countOf(nsid))
    }

    @Test
    fun refreshingStartsTheListOver() {
        val model = viewModel()
        model.state.awaitValue("the first page") { it.hasLoaded }

        harness.server.on(
            nsid,
            page(json(Fixtures.notification(rkey = "fresh", reason = "follow"))),
        )
        model.refresh()

        model.state.awaitValue("the refreshed list") {
            it.groups.singleOrNull()?.notification?.reason == "follow"
        }
    }

    @Test
    fun markingSeenClearsTheBadgeAndTellsTheServer() {
        val model = viewModel()
        model.state.awaitValue("the first page") { it.hasLoaded }
        assertEquals(2, unseen.count.value)

        model.markSeen()

        assertEquals(0, unseen.count.value)
        assertTrue(
            harness.server.awaitCall("social.grain.notification.updateSeen")
                .body.contains("seenAt"),
        )
    }

    @Test
    fun aFailedSeenWritePutsTheBadgeBack() {
        harness.server.on("social.grain.notification.updateSeen", """{"error":"no"}""", code = 500)
        val model = viewModel()
        model.state.awaitValue("the first page") { it.hasLoaded }

        model.markSeen()

        val deadline = System.currentTimeMillis() + 5_000
        while (unseen.count.value != 2 && System.currentTimeMillis() < deadline) Thread.sleep(10)
        assertEquals(2, unseen.count.value)
    }

    @Test
    fun anAlreadyEmptyBadgeIsStillTakenDownWithoutWritingToTheServer() {
        unread(0)
        val model = viewModel()
        model.state.awaitValue("the first page") { it.hasLoaded }

        model.markSeen()
        settle()

        assertEquals(0, unseen.count.value)
        assertEquals(0, harness.server.countOf("social.grain.notification.updateSeen"))
    }

    @Test
    fun theCheapPollUpdatesTheBadgeOnItsOwn() {
        unread(7)
        kotlinx.coroutines.runBlocking { unseen.refresh() }

        assertEquals(7, unseen.count.value)
        assertEquals(0, harness.server.countOf(nsid))
    }

    @Test
    fun theBadgeNeverGoesNegative() {
        unseen.set(-3)
        assertEquals(0, unseen.count.value)
    }
}