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

grain.social/grain-android

1 collaborator
0
1
main
399 lines · 14.2 KB Raw History
package social.grain

import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
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.feed.FeedViewModel
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

/**
 * The feed's own state machine, with the server stubbed out.
 *
 * Everything here is behaviour a screen test can't see and a decode test
 * doesn't reach: which feed the pins resolve to, what a failed refresh leaves
 * on screen, and — the one that actually shipped broken once — whether an
 * optimistic favorite is put back when the write fails.
 */
@RunWith(AndroidJUnit4::class)
class FeedViewModelTest {

    @get:Rule
    val mainDispatcher = MainDispatcherRule()

    private lateinit var harness: Harness

    private val feed = """{"items":[${json(Fixtures.gallery())}],"cursor":"page2"}"""

    @Before
    fun setUp() {
        harness = Harness()
        runBlocking { harness.feedCache.purge(harness.did) }
        harness.server
            .on("social.grain.actor.getPreferences", """{"preferences":[]}""")
            .on("social.grain.story.getAuthors", """{"authors":[]}""")
            .on("social.grain.feed.getFeed", feed)
    }

    @After
    fun tearDown() {
        runBlocking { harness.feedCache.purge(harness.did) }
        harness.tearDown()
    }

    private fun viewModel() = FeedViewModel(
        client = harness.client,
        auth = harness.auth,
        feedCache = harness.feedCache,
        viewedStories = harness.viewedStories,
        storyStatusCache = harness.storyStatus,
        labelDefsCache = harness.labelDefinitions,
    )

    @Test
    fun aColdStartLoadsTheDefaultFeedAndTheSignedInViewer() {
        val model = viewModel()
        val state = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        assertEquals("A walk in the fog", state.galleries.single().title)
        assertEquals("recent", state.selectedFeedId)
        assertFalse(state.isLoading)
        assertNull(state.errorMessage)

        val viewer = model.state.awaitValue("the viewer") { it.viewerDid != null }
        assertEquals(harness.did, viewer.viewerDid)
        assertEquals(harness.handle, viewer.viewerHandle)
    }

    @Test
    fun theFeedRequestIsAuthenticatedAndNamesTheSelectedFeed() {
        viewModel()
        val call = harness.server.awaitCall("social.grain.feed.getFeed")

        assertEquals("recent", call.params["feed"]?.substringAfterLast('/'))
        assertEquals("DPoP test-access", call.authorization)
    }

    @Test
    fun preferencesReplaceTheDefaultPinsAndKeepTheSelectionWhenItSurvives() {
        harness.server.on(
            "social.grain.actor.getPreferences",
            """{"preferences":[{"${'$'}type":"social.grain.actor.defs#pinnedFeedsPref","items":[
                {"id":"recent","label":"Recent","type":"feed","path":"/"},
                {"id":"camera:Leica M6","label":"Leica M6","type":"camera","path":"/c"}
            ]}]}""",
        )

        val model = viewModel()
        val state = model.state.awaitValue("the pins") { it.pinnedFeeds.size == 2 }

        assertEquals("recent", state.selectedFeedId)
        assertEquals("Leica M6", state.pinnedFeeds[1].label)
    }

    @Test
    fun pinsThatDoNotIncludeTheSelectedFeedFallBackToTheFirstOne() {
        harness.server.on(
            "social.grain.actor.getPreferences",
            """{"preferences":[{"${'$'}type":"social.grain.actor.defs#pinnedFeedsPref","items":[
                {"id":"foryou","label":"For you","type":"feed","path":"/f"}
            ]}]}""",
        )

        val model = viewModel()
        val state = model.state.awaitValue("the pins") { it.pinnedFeeds.size == 1 }
        assertEquals("foryou", state.selectedFeedId)
    }

    @Test
    fun aFollowingFeedSendsTheViewerAsTheActor() {
        val model = viewModel()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        val before = harness.server.countOf("social.grain.feed.getFeed")
        model.selectFeed("following")
        val call = harness.server.awaitCall("social.grain.feed.getFeed", count = before + 1)

        assertEquals("following", call.params["feed"]?.substringAfterLast('/'))
        assertEquals(harness.did, call.params["actor"])
    }

    @Test
    fun aCameraFeedSendsTheCameraRatherThanTheCompoundId() {
        harness.server.on(
            "social.grain.actor.getPreferences",
            """{"preferences":[{"${'$'}type":"social.grain.actor.defs#pinnedFeedsPref","items":[
                {"id":"recent","label":"Recent","type":"feed","path":"/"},
                {"id":"camera:Leica M6","label":"Leica M6","type":"camera","path":"/c"}
            ]}]}""",
        )
        val model = viewModel()
        model.state.awaitValue("the pins and the first page") {
            it.pinnedFeeds.size == 2 && it.galleries.isNotEmpty()
        }
        val before = harness.server.countOf("social.grain.feed.getFeed")

        model.selectFeed("camera:Leica M6")
        val call = harness.server.awaitCall("social.grain.feed.getFeed", count = before + 1)

        assertEquals("camera", call.params["feed"]?.substringAfterLast('/'))
        assertEquals("Leica M6", call.params["camera"])
        assertNull(call.params["actor"])
    }

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

        model.selectFeed("recent")
        settle()

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

    @Test
    fun aFailedRefreshKeepsTheGalleriesAndRaisesAMessage() {
        val model = viewModel()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        harness.server.on("social.grain.feed.getFeed", """{"error":"boom"}""", code = 500)
        model.refresh()

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

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

    @Test
    fun loadingMoreAppendsThePageBehindTheCursor() {
        val model = viewModel()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

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

        val state = model.state.awaitValue("the second page") { it.galleries.size == 2 }
        assertEquals("Second", state.galleries[1].title)
        assertEquals("page2", harness.server.lastCall("social.grain.feed.getFeed").params["cursor"])
        assertFalse(state.isLoadingMore)
    }

    @Test
    fun theEndOfTheFeedStopsLoadingMore() {
        harness.server.on("social.grain.feed.getFeed", """{"items":[${json(Fixtures.gallery())}]}""")
        val model = viewModel()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
        val before = harness.server.countOf("social.grain.feed.getFeed")

        model.loadMore()
        settle()

        assertEquals("A cursorless page is the end", before, harness.server.countOf("social.grain.feed.getFeed"))
    }

    @Test
    fun favoritingFlipsTheHeartBeforeTheWriteAndKeepsTheRecordUri() {
        harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/fav/1"}""")
        val model = viewModel()
        val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.toggleFavorite(gallery.uri)

        val optimistic = model.state.value.galleries.single()
        assertTrue("The heart fills before the write lands", optimistic.isFavorited)
        assertEquals(13, optimistic.favCount)

        val settled = model.state.awaitValue("the record uri") {
            it.galleries.single().viewer?.fav == "at://did:plc:me/fav/1"
        }
        assertEquals(13, settled.galleries.single().favCount)
    }

    @Test
    fun aFavoriteThatFailsIsPutBack() {
        harness.server.on("com.atproto.repo.createRecord", """{"error":"nope"}""", code = 500)
        val model = viewModel()
        val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.toggleFavorite(gallery.uri)

        val reverted = model.state.awaitValue("the heart to empty again") {
            !it.galleries.single().isFavorited
        }
        assertEquals(12, reverted.galleries.single().favCount)
    }

    @Test
    fun unfavoritingDeletesTheRecordAndDecrementsTheCount() {
        harness.server.on(
            "social.grain.feed.getFeed",
            """{"items":[${json(Fixtures.gallery(favUri = "at://did:plc:me/social.grain.favorite/f1"))}]}""",
        )
        val model = viewModel()
        val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
            .galleries.single()
        assertTrue(gallery.isFavorited)

        model.toggleFavorite(gallery.uri)

        val call = harness.server.awaitCall("com.atproto.repo.deleteRecord")
        assertTrue(call.body.contains("\"rkey\":\"f1\""))
        assertFalse(model.state.value.galleries.single().isFavorited)
        assertEquals(11, model.state.value.galleries.single().favCount)
    }

    @Test
    fun favoritingSomethingNotInTheFeedIsIgnored() {
        val model = viewModel()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        model.toggleFavorite("at://nothing/here")
        settle()

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

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

        model.setCommentCount(gallery.uri, 9)

        assertEquals(9, model.state.value.galleries.single().commentCount)
    }

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

        model.deleteGallery(gallery.uri)

        model.state.awaitValue("the card to go") { it.galleries.isEmpty() }
        assertTrue(
            harness.server.awaitCall("com.atproto.repo.applyWrites")
                .body.contains("gallery1"),
        )
    }

    @Test
    fun aDeleteThatFailsLeavesTheCardAndSaysSo() {
        harness.server.on(
            "com.atproto.repo.applyWrites",
            """{"error":"nope"}""",
            code = 500,
        )
        val model = viewModel()
        val gallery = model.state.awaitValue("the first page") { 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 theStoryStripIsFilledFromTheAuthorsCall() {
        harness.server.on(
            "social.grain.story.getAuthors",
            """{"authors":[${json(Fixtures.storyAuthor())},""" +
                """${json(Fixtures.storyAuthor(profile = Fixtures.profile(did = "did:plc:bob"), storyCount = 0))}]}""",
        )

        val model = viewModel()
        val state = model.state.awaitValue("the strip") { it.storyAuthors.isNotEmpty() }

        assertEquals("An author with no live story isn't in the strip", 1, state.storyAuthors.size)
        assertTrue(harness.storyStatus.hasStory("did:plc:alice"))
        assertFalse(model.hasViewedAll(state.storyAuthors.single()))
    }

    @Test
    fun aFailedStoryStripDoesNotTakeTheFeedDownWithIt() {
        harness.server.on(
            "social.grain.story.getAuthors",
            """{"error":"nope"}""",
            code = 500,
        )
        val model = viewModel()
        val state = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        assertTrue(state.storyAuthors.isEmpty())
        assertNull(state.errorMessage)
    }

    @Test
    fun closingTheStoryViewerReReadsTheStrip() {
        val model = viewModel()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
        val before = harness.server.countOf("social.grain.story.getAuthors")

        model.onStoriesViewed()

        harness.server.awaitCall("social.grain.story.getAuthors", count = before + 1)
    }

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

        model.refreshIfStale()
        settle()

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

    @Test
    fun theSelectedFeedResolvesThroughThePinsAndFallsBackWhenUnknown() {
        val model = viewModel()
        val state = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        assertEquals("Recent", state.selectedFeed.label)
        assertEquals("Recent", state.copy(selectedFeedId = "nope").selectedFeed.label)
    }

    @Test
    fun theCachedPageRendersBeforeTheNetworkAnswers() {
        runBlocking {
            harness.feedCache.activeDid = harness.did
            harness.feedCache.save(listOf(Fixtures.gallery(rkey = "cached", title = "From disk")), "recent")
        }

        val model = viewModel()
        val fromNetwork = model.state.awaitValue("the network page") {
            it.galleries.singleOrNull()?.title == "A walk in the fog"
        }
        assertNotNull(fromNetwork)
    }
}