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

grain.social/grain-android

1 collaborator
0
1
main
542 lines · 19.3 KB Raw History
package social.grain

import androidx.lifecycle.SavedStateHandle
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.data.local.ProfileInvalidation
import social.grain.feature.profile.ProfileTab
import social.grain.feature.profile.ProfileViewModel
import social.grain.support.Fixtures
import social.grain.support.Harness
import social.grain.support.MainDispatcherRule
import social.grain.support.awaitCall
import social.grain.support.awaitValue
import social.grain.support.json
import social.grain.support.settle

/**
 * A profile screen's state, for the viewer's own account and for someone else's.
 *
 * The two cases diverge more than they look: only your own profile has tabs
 * past the first, only someone else's can be followed, blocked or muted, and
 * every one of those is flipped optimistically and has to be put back when the
 * write fails.
 */
@RunWith(AndroidJUnit4::class)
class ProfileViewModelTest {

    @get:Rule
    val mainDispatcher = MainDispatcherRule()

    private lateinit var harness: Harness
    private lateinit var invalidation: ProfileInvalidation

    private val alice = "did:plc:alice"

    @Before
    fun setUp() {
        harness = Harness()
        invalidation = ProfileInvalidation()
        harness.server
            .on("social.grain.actor.getProfile", json(Fixtures.profileDetailed()))
            .on("social.grain.feed.getFeed", """{"items":[${json(Fixtures.gallery())}]}""")
            .on("social.grain.story.getStories", """{"stories":[]}""")
            .on("social.grain.actor.getFavorites", """{"items":[]}""")
            .on("social.grain.story.getArchive", """{"stories":[]}""")
    }

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

    private fun viewModel(did: String? = alice) = ProfileViewModel(
        client = harness.client,
        auth = harness.auth,
        profileInvalidation = invalidation,
        labelDefsCache = harness.labelDefinitions,
        savedStateHandle = SavedStateHandle(
            if (did == null) emptyMap() else mapOf("did" to did),
        ),
    )

    private fun ownProfile() = json(
        Fixtures.profileDetailed(did = harness.did, handle = harness.handle, displayName = "Me"),
    )

    @Test
    fun someoneElsesProfileLoadsWithTheirGalleries() {
        val model = viewModel()
        val state = model.state.awaitValue("the profile") { it.profile != null }

        assertEquals("Alice", state.profile!!.displayName)
        assertEquals(1, state.galleries.size)
        assertFalse(state.isLoading)
        assertFalse(state.isOwnProfile)
        assertNull(state.errorMessage)
        assertEquals("The actor feed is what fills the grid", "actor", harness.server.lastCall("social.grain.feed.getFeed").params["feed"]?.substringAfterLast('/'))
        assertEquals(alice, harness.server.lastCall("social.grain.feed.getFeed").params["actor"])
    }

    @Test
    fun theProfileTabWithNoRouteArgumentIsTheViewersOwn() {
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        val model = viewModel(did = null)
        val state = model.state.awaitValue("the profile") { it.profile != null }

        assertTrue(state.isOwnProfile)
        assertEquals(harness.did, state.did)
    }

    @Test
    fun aLiveStoryPutsARingOnTheAvatar() {
        harness.server.on(
            "social.grain.story.getStories",
            """{"stories":[${json(Fixtures.story())}]}""",
        )
        val model = viewModel()
        assertTrue(model.state.awaitValue("the ring") { it.hasLiveStory }.hasLiveStory)
    }

    @Test
    fun anExpiredStoryIsNotALiveOne() {
        harness.server.on(
            "social.grain.story.getStories",
            """{"stories":[${json(Fixtures.story(expired = true))}]}""",
        )
        val model = viewModel()
        assertFalse(model.state.awaitValue("the profile") { it.profile != null }.hasLiveStory)
    }

    @Test
    fun aProfileThatFailsToLoadReportsIt() {
        harness.server.on(
            "social.grain.actor.getProfile",
            """{"error":"gone"}""",
            code = 404,
        )
        val model = viewModel()
        val state = model.state.awaitValue("the failure") { !it.isLoading }

        assertNull(state.profile)
        assertEquals("HTTP error 404", state.errorMessage)

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

    @Test
    fun theRelationshipFlagsReadOffTheViewerState() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(
                Fixtures.profileDetailed(
                    following = "at://follow/1",
                    followedBy = "at://follow/2",
                    muted = true,
                ),
            ),
        )
        val model = viewModel()
        val state = model.state.awaitValue("the profile") { it.profile != null }

        assertTrue(state.isFollowing)
        assertTrue(state.followsViewer)
        assertTrue(state.isMuted)
        assertFalse(state.isBlocking)
        assertFalse(state.isBlockHidden)
    }

    @Test
    fun aBlockInEitherDirectionHidesTheProfile() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed(blocking = "at://block/1")),
        )
        val blocking = viewModel().state.awaitValue("the profile") { it.profile != null }
        assertTrue(blocking.isBlocking)
        assertTrue(blocking.isBlockHidden)
    }

    @Test
    fun favoritesLoadOnceTheTabIsOpened() {
        harness.server.on(
            "social.grain.actor.getFavorites",
            """{"items":[${json(Fixtures.gallery(rkey = "faved", title = "Faved"))}]}""",
        )
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        val model = viewModel(did = harness.did)
        model.state.awaitValue("the profile") { it.profile != null }
        assertEquals(0, harness.server.countOf("social.grain.actor.getFavorites"))

        model.selectTab(ProfileTab.FAVORITES)

        val state = model.state.awaitValue("the favorites") { it.favoritesLoaded }
        assertEquals("Faved", state.favorites.single().title)
        assertEquals(ProfileTab.FAVORITES, state.tab)
    }

    @Test
    fun theArchiveLoadsOnceItsTabIsOpened() {
        harness.server.on(
            "social.grain.story.getArchive",
            """{"stories":[${json(Fixtures.story(rkey = "old", expired = true))}]}""",
        )
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        val model = viewModel(did = harness.did)
        model.state.awaitValue("the profile") { it.profile != null }

        model.selectTab(ProfileTab.STORIES)

        val state = model.state.awaitValue("the archive") { it.archiveLoaded }
        assertEquals(1, state.archivedStories.size)
    }

    @Test
    fun anotherAccountsProfileRefusesTheTabsItDoesNotHave() {
        val model = viewModel()
        model.state.awaitValue("the profile") { it.profile != null }

        model.selectTab(ProfileTab.FAVORITES)
        settle()

        assertEquals(ProfileTab.GALLERIES, model.state.value.tab)
        assertEquals(0, harness.server.countOf("social.grain.actor.getFavorites"))
    }

    @Test
    fun aRefreshRenewsTabsThatHaveBeenOpenedAndNothingElse() {
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        val model = viewModel(did = harness.did)
        model.state.awaitValue("the profile") { it.profile != null }

        model.selectTab(ProfileTab.FAVORITES)
        model.state.awaitValue("the favorites") { it.favoritesLoaded }
        val favoritesBefore = harness.server.countOf("social.grain.actor.getFavorites")

        model.refresh()
        harness.server.awaitCall("social.grain.actor.getFavorites", favoritesBefore + 1)

        assertEquals(
            "The archive was never opened, so a refresh doesn't fetch it",
            0,
            harness.server.countOf("social.grain.story.getArchive"),
        )
    }

    @Test
    fun morGalleriesAppendBehindTheCursor() {
        harness.server.on(
            "social.grain.feed.getFeed",
            """{"items":[${json(Fixtures.gallery())}],"cursor":"c1"}""",
        )
        val model = viewModel()
        model.state.awaitValue("the grid") { it.galleries.isNotEmpty() }

        harness.server.on(
            "social.grain.feed.getFeed",
            """{"items":[${json(Fixtures.gallery(rkey = "g2", title = "Second"))}]}""",
        )
        model.loadMoreGalleries()

        val state = model.state.awaitValue("the second page") { it.galleries.size == 2 }
        assertEquals("Second", state.galleries[1].title)
    }

    @Test
    fun aGridWithNoCursorHasNothingMoreToLoad() {
        val model = viewModel()
        model.state.awaitValue("the grid") { it.galleries.isNotEmpty() }
        val before = harness.server.countOf("social.grain.feed.getFeed")

        model.loadMoreGalleries()
        settle()

        assertEquals(before, harness.server.countOf("social.grain.feed.getFeed"))
    }

    @Test
    fun moreFavoritesAppendBehindTheirOwnCursor() {
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        harness.server.on(
            "social.grain.actor.getFavorites",
            """{"items":[${json(Fixtures.gallery(rkey = "f1", title = "Faved"))}],"cursor":"f1"}""",
        )
        val model = viewModel(did = null)
        model.state.awaitValue("the profile") { it.profile != null }
        model.selectTab(ProfileTab.FAVORITES)
        model.state.awaitValue("the favorites") { it.favoritesLoaded }

        harness.server.on(
            "social.grain.actor.getFavorites",
            """{"items":[${json(Fixtures.gallery(rkey = "f2", title = "Faved later"))}]}""",
        )
        model.loadMoreFavorites()

        val state = model.state.awaitValue("the second page") { it.favorites.size == 2 }
        assertEquals("Faved later", state.favorites[1].title)
        assertEquals("f1", harness.server.lastCall("social.grain.actor.getFavorites").params["cursor"])
    }

    @Test
    fun aPageThatFailsToLoadLeavesTheCursorForAnotherTry() {
        harness.server.on(
            "social.grain.feed.getFeed",
            """{"items":[${json(Fixtures.gallery())}],"cursor":"c1"}""",
        )
        val model = viewModel()
        model.state.awaitValue("the grid") { it.galleries.isNotEmpty() }

        harness.server.on("social.grain.feed.getFeed", """{"error":"no"}""", code = 500)
        model.loadMoreGalleries()
        model.state.awaitValue("the failed page") { !it.isLoadingMoreGalleries }
        val before = harness.server.countOf("social.grain.feed.getFeed")

        model.loadMoreGalleries()

        harness.server.awaitCall("social.grain.feed.getFeed", before + 1)
    }

    @Test
    fun favoritingFromTheFeedFlipsTheHeartInBothLists() {
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        harness.server.on(
            "social.grain.feed.getFeed",
            """{"items":[${json(Fixtures.gallery(rkey = "mine", favCount = 1))}]}""",
        )
        harness.server.on(
            "social.grain.actor.getFavorites",
            """{"items":[${json(Fixtures.gallery(rkey = "mine", favCount = 1))}]}""",
        )
        harness.server.on(
            "com.atproto.repo.createRecord",
            """{"uri":"at://did:plc:me/social.grain.favorite/fav1","cid":"cid-fav1"}""",
        )
        val model = viewModel(did = null)
        model.state.awaitValue("the profile") { it.profile != null }
        model.selectTab(ProfileTab.FAVORITES)
        val uri = model.state.awaitValue("the favorites") { it.favoritesLoaded }
            .favorites.single().uri

        model.toggleFavorite(uri)

        val state = model.state.awaitValue("the record") {
            it.galleries.single().viewer?.fav == "at://did:plc:me/social.grain.favorite/fav1"
        }
        assertEquals(2, state.galleries.single().favCount)
        assertEquals(2, state.favorites.single().favCount)
        assertEquals(state.galleries.single().viewer, state.favorites.single().viewer)
    }

    @Test
    fun moreArchivedStoriesAppendBehindTheirOwnCursor() {
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        harness.server.on(
            "social.grain.story.getArchive",
            """{"stories":[${json(Fixtures.story(rkey = "s1"))}],"cursor":"a1"}""",
        )
        val model = viewModel(did = harness.did)
        model.state.awaitValue("the profile") { it.profile != null }
        model.selectTab(ProfileTab.STORIES)
        model.state.awaitValue("the archive") { it.archiveLoaded }

        harness.server.on(
            "social.grain.story.getArchive",
            """{"stories":[${json(Fixtures.story(rkey = "s2"))}]}""",
        )
        model.loadMoreArchive()

        model.state.awaitValue("the second archive page") { it.archivedStories.size == 2 }
    }

    @Test
    fun followingFlipsTheButtonThenKeepsTheRecordUri() {
        harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/follow/1"}""")
        val model = viewModel()
        model.state.awaitValue("the profile") { it.profile != null }

        model.toggleFollow()

        assertTrue("The button flips first", model.state.value.isFollowing)
        assertEquals(121, model.state.value.profile!!.followersCount)

        val settled = model.state.awaitValue("the record uri") {
            it.profile?.viewer?.following == "at://did:plc:me/follow/1"
        }
        assertFalse(settled.isFollowPending)
    }

    @Test
    fun unfollowingDeletesTheRecordAndDecrementsTheCount() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed(following = "at://did:plc:me/social.grain.graph.follow/f1")),
        )
        val model = viewModel()
        model.state.awaitValue("the profile") { it.isFollowing }

        model.toggleFollow()

        assertTrue(harness.server.awaitCall("com.atproto.repo.deleteRecord").body.contains("\"rkey\":\"f1\""))
        val state = model.state.awaitValue("the write to settle") { !it.isFollowPending }
        assertFalse(state.isFollowing)
        assertEquals(119, state.profile!!.followersCount)
    }

    @Test
    fun aFollowThatFailsIsPutBack() {
        harness.server.on("com.atproto.repo.createRecord", """{"error":"no"}""", code = 500)
        val model = viewModel()
        model.state.awaitValue("the profile") { it.profile != null }

        model.toggleFollow()

        val state = model.state.awaitValue("the revert") { it.errorMessage != null }
        assertFalse(state.isFollowing)
        assertEquals(120, state.profile!!.followersCount)
        assertFalse(state.isFollowPending)
    }

    @Test
    fun nobodyCanFollowThemselves() {
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        val model = viewModel(did = harness.did)
        model.state.awaitValue("the profile") { it.profile != null }

        model.toggleFollow()
        settle()

        assertEquals(0, harness.server.countOf("com.atproto.repo.createRecord"))
    }

    @Test
    fun blockingWritesTheRecordAndReloadsTheScreen() {
        harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/block/1"}""")
        val model = viewModel()
        model.state.awaitValue("the profile") { it.profile != null }
        val profileFetches = harness.server.countOf("social.grain.actor.getProfile")

        model.toggleBlock()

        assertTrue("The state flips first", model.state.value.isBlocking)
        harness.server.awaitCall("social.grain.actor.getProfile", profileFetches + 1)
    }

    @Test
    fun unblockingDeletesTheBlockRecord() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed(blocking = "at://did:plc:me/social.grain.graph.block/b1")),
        )
        val model = viewModel()
        model.state.awaitValue("the profile") { it.isBlocking }

        model.toggleBlock()

        assertTrue(harness.server.awaitCall("com.atproto.repo.deleteRecord").body.contains("\"rkey\":\"b1\""))
    }

    @Test
    fun aBlockThatFailsIsPutBackWithAMessage() {
        harness.server.on("com.atproto.repo.createRecord", """{"error":"no"}""", code = 500)
        val model = viewModel()
        model.state.awaitValue("the profile") { it.profile != null }

        model.toggleBlock()

        val state = model.state.awaitValue("the revert") { it.errorMessage != null }
        assertFalse(state.isBlocking)
        assertEquals("Couldn't block that account. Try again.", state.errorMessage)
        assertFalse(state.isModerationPending)
    }

    @Test
    fun mutingFlipsTheStateAndTellsTheServer() {
        val model = viewModel()
        model.state.awaitValue("the profile") { it.profile != null }

        model.toggleMute()

        assertTrue(model.state.value.isMuted)
        assertTrue(
            harness.server.awaitCall("social.grain.graph.muteActor").body.contains(alice),
        )
        model.state.awaitValue("the write to settle") { !it.isModerationPending }
    }

    @Test
    fun unmutingCallsTheOtherEndpoint() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed(muted = true)),
        )
        val model = viewModel()
        model.state.awaitValue("the profile") { it.isMuted }

        model.toggleMute()

        harness.server.awaitCall("social.grain.graph.unmuteActor")
        assertFalse(model.state.value.isMuted)
    }

    @Test
    fun aMuteThatFailsIsPutBackWithAMessage() {
        harness.server.on("social.grain.graph.muteActor", """{"error":"no"}""", code = 500)
        val model = viewModel()
        model.state.awaitValue("the profile") { it.profile != null }

        model.toggleMute()

        val state = model.state.awaitValue("the revert") { it.errorMessage != null }
        assertFalse(state.isMuted)
        assertEquals("Couldn't mute that account. Try again.", state.errorMessage)
    }

    @Test
    fun deletingAGalleryRemovesItFromTheGrid() {
        val model = viewModel()
        val gallery = model.state.awaitValue("the grid") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.deleteGallery(gallery.uri)

        model.state.awaitValue("the tile to go") { it.galleries.isEmpty() }
    }

    @Test
    fun aGalleryDeleteThatFailsSaysSo() {
        harness.server.on(
            "com.atproto.repo.applyWrites",
            """{"error":"no"}""",
            code = 500,
        )
        val model = viewModel()
        val gallery = model.state.awaitValue("the grid") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.deleteGallery(gallery.uri)

        val state = model.state.awaitValue("the error") { it.errorMessage != null }
        assertEquals(1, state.galleries.size)
    }

    @Test
    fun editingThisProfileElsewhereMakesItReRead() {
        val model = viewModel()
        model.state.awaitValue("the profile") { it.profile != null }
        val before = harness.server.countOf("social.grain.actor.getProfile")

        invalidation.invalidate("did:plc:someone-else")
        settle()
        assertEquals(before, harness.server.countOf("social.grain.actor.getProfile"))

        invalidation.invalidate(alice)
        harness.server.awaitCall("social.grain.actor.getProfile", before + 1)
    }
}