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

grain.social/grain-android

1 collaborator
0
1
main
402 lines · 14.1 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.feature.gallery.GalleryDetailViewModel
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
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

/**
 * One gallery and its thread, loaded together and edited in place.
 *
 * The gallery and the comments are fetched as two independent requests on
 * purpose, so the interesting cases are the asymmetric ones: comments that fail
 * while the gallery lands, an optimistic favourite that has to be put back, and
 * a delete that removes a comment's replies along with it.
 */
@RunWith(AndroidJUnit4::class)
class GalleryDetailViewModelTest {

    @get:Rule
    val mainDispatcher = MainDispatcherRule()

    private lateinit var harness: Harness

    private val uri = "at://did:plc:alice/social.grain.gallery/gallery1"
    private val ownUri = "at://did:plc:me/social.grain.gallery/mine"

    private fun thread(vararg comments: String, total: Int? = null, cursor: String? = null) =
        """{"comments":[${comments.joinToString(",")}]""" +
            (total?.let { ""","totalCount":$it""" } ?: "") +
            (cursor?.let { ""","cursor":"$it"""" } ?: "") + "}"

    @Before
    fun setUp() {
        harness = Harness()
        harness.server
            .on(
                "social.grain.gallery.getGallery",
                """{"gallery":${json(Fixtures.gallery())}}""",
            )
            .on(
                "social.grain.comment.getThread",
                thread(json(Fixtures.comment()), total = 3),
            )
    }

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

    private fun viewModel(target: String = uri) = GalleryDetailViewModel(
        client = harness.client,
        auth = harness.auth,
        labelDefsCache = harness.labelDefinitions,
        savedStateHandle = SavedStateHandle(mapOf("uri" to target)),
    )

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

        assertEquals("A walk in the fog", state.gallery!!.title)
        assertEquals(1, state.comments.size)
        assertTrue(state.commentsLoaded)
        assertFalse(state.isLoading)
        assertNull(state.errorMessage)
        assertEquals(harness.did, state.viewerDid)
        assertFalse("Alice's gallery is not the viewer's", state.isOwnGallery)
    }

    @Test
    fun theViewersOwnGalleryIsMarkedAsTheirs() {
        harness.server.on(
            "social.grain.gallery.getGallery",
            """{"gallery":${json(
                Fixtures.gallery(
                    rkey = "mine",
                    creator = Fixtures.profile(did = "did:plc:me", handle = "me.grain.social"),
                ),
            )}}""",
        )
        val model = viewModel(ownUri)
        val state = model.state.awaitValue("the gallery") { it.gallery != null }

        assertTrue(state.isOwnGallery)
    }

    @Test
    fun aGalleryThatFailsToLoadReportsItWhileTheThreadStillArrives() {
        harness.server.on("social.grain.gallery.getGallery", """{"error":"gone"}""", code = 404)

        val model = viewModel()
        val state = model.state.awaitValue("the failure") { !it.isLoading }

        assertNull(state.gallery)
        assertEquals("HTTP error 404", state.errorMessage)
        assertEquals(1, state.comments.size)

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

    @Test
    fun aThreadThatFailsLeavesTheGalleryIntactAndUnloaded() {
        harness.server.on(
            "social.grain.comment.getThread",
            """{"error":"nope"}""",
            code = 500,
        )

        val model = viewModel()
        val state = model.state.awaitValue("the gallery") { it.gallery != null }

        assertFalse(state.commentsLoaded)
        assertTrue(state.comments.isEmpty())
    }

    @Test
    fun repliesAreGroupedUnderTheirRoot() {
        val root = Fixtures.comment(rkey = "root", text = "Root")
        harness.server.on(
            "social.grain.comment.getThread",
            thread(
                json(root),
                json(Fixtures.comment(rkey = "reply", text = "Reply", replyTo = root.uri)),
            ),
        )

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

        assertEquals(1, state.threads.size)
        assertEquals("Root", state.threads.single().root.text)
        assertEquals("Reply", state.threads.single().replies.single().text)
    }

    @Test
    fun moreCommentsAreAppendedBehindTheCursor() {
        harness.server.on(
            "social.grain.comment.getThread",
            thread(json(Fixtures.comment()), cursor = "c1"),
        )
        val model = viewModel()
        model.state.awaitValue("the thread") { it.comments.isNotEmpty() }

        harness.server.on(
            "social.grain.comment.getThread",
            thread(json(Fixtures.comment(rkey = "comment2", text = "Second"))),
        )
        model.loadMoreComments()

        val state = model.state.awaitValue("the second page") { it.comments.size == 2 }
        assertEquals("Second", state.comments[1].text)
        assertFalse(state.isLoadingComments)
    }

    @Test
    fun aThreadWithNoCursorHasNothingMoreToLoad() {
        val model = viewModel()
        model.state.awaitValue("the thread") { it.comments.isNotEmpty() }
        val before = harness.server.countOf("social.grain.comment.getThread")

        model.loadMoreComments()
        settle()

        assertEquals(before, harness.server.countOf("social.grain.comment.getThread"))
    }

    @Test
    fun postingACommentWritesTheRecordAndReReadsTheThread() {
        harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/comment/new"}""")
        val model = viewModel()
        model.state.awaitValue("the thread") { it.comments.isNotEmpty() }

        harness.server.on(
            "social.grain.comment.getThread",
            thread(json(Fixtures.comment()), json(Fixtures.comment(rkey = "c2", text = "Mine")), total = 4),
        )
        model.postComment("  Mine  ", replyTo = null)

        val state = model.state.awaitValue("the reread thread") { it.comments.size == 2 }
        assertFalse(state.isPostingComment)
        assertEquals(4, state.gallery!!.commentCount)

        val record = harness.server.awaitCall("com.atproto.repo.createRecord")
        assertTrue("Whitespace is trimmed off the comment", record.body.contains("\"text\":\"Mine\""))
        assertTrue(record.body.contains(uri))
    }

    @Test
    fun replyingCarriesTheCommentBeingRepliedTo() {
        harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/comment/new"}""")
        val model = viewModel()
        val existing = model.state.awaitValue("the thread") { it.comments.isNotEmpty() }
            .comments.single()

        model.postComment("Agreed", replyTo = existing)

        val record = harness.server.awaitCall("com.atproto.repo.createRecord")
        assertTrue(record.body.contains("\"replyTo\":\"${existing.uri}\""))
    }

    @Test
    fun anEmptyCommentIsNotPosted() {
        val model = viewModel()
        model.state.awaitValue("the thread") { it.comments.isNotEmpty() }

        model.postComment("   ", replyTo = null)
        settle()

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

    @Test
    fun aCommentThatFailsToPostSaysSo() {
        harness.server.on("com.atproto.repo.createRecord", """{"error":"nope"}""", code = 500)
        val model = viewModel()
        model.state.awaitValue("the thread") { it.comments.isNotEmpty() }

        model.postComment("Mine", replyTo = null)

        val state = model.state.awaitValue("the error") { it.errorMessage != null }
        assertEquals("Couldn't post that comment.", state.errorMessage)
        assertFalse(state.isPostingComment)
    }

    @Test
    fun deletingACommentTakesItsRepliesWithIt() {
        val root = Fixtures.comment(rkey = "root")
        harness.server.on(
            "social.grain.comment.getThread",
            thread(json(root), json(Fixtures.comment(rkey = "reply", replyTo = root.uri))),
        )
        val model = viewModel()
        val loaded = model.state.awaitValue("the thread") { it.comments.size == 2 }

        model.deleteComment(loaded.comments.first())

        val state = model.state.awaitValue("the rows to go") { it.comments.isEmpty() }
        assertEquals(2, state.gallery!!.commentCount)
        assertTrue(harness.server.awaitCall("com.atproto.repo.deleteRecord").body.contains("\"rkey\":\"root\""))
    }

    @Test
    fun aDeleteThatFailsPutsTheCommentBack() {
        harness.server.on("com.atproto.repo.deleteRecord", """{"error":"nope"}""", code = 500)
        val model = viewModel()
        val loaded = model.state.awaitValue("the thread") { it.comments.isNotEmpty() }

        model.deleteComment(loaded.comments.single())

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

    @Test
    fun favoritingACommentFlipsItThenKeepsTheRecordUri() {
        harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/fav/c1"}""")
        val model = viewModel()
        val comment = model.state.awaitValue("the thread") { it.comments.isNotEmpty() }
            .comments.single()

        model.toggleCommentFavorite(comment)

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

    @Test
    fun aCommentFavoriteThatFailsIsPutBack() {
        harness.server.on("com.atproto.repo.createRecord", """{"error":"no"}""", code = 500)
        val model = viewModel()
        val comment = model.state.awaitValue("the thread") { it.comments.isNotEmpty() }
            .comments.single()

        model.toggleCommentFavorite(comment)

        val state = model.state.awaitValue("the revert") { it.comments.single().viewer?.fav == null }
        assertEquals(0, state.comments.single().favCount)
    }

    @Test
    fun unfavoritingACommentDeletesTheRecord() {
        harness.server.on(
            "social.grain.comment.getThread",
            thread(json(Fixtures.comment(favUri = "at://did:plc:me/social.grain.favorite/cf1"))),
        )
        val model = viewModel()
        val comment = model.state.awaitValue("the thread") { it.comments.isNotEmpty() }
            .comments.single()

        model.toggleCommentFavorite(comment)

        assertTrue(harness.server.awaitCall("com.atproto.repo.deleteRecord").body.contains("cf1"))
        assertNull(model.state.value.comments.single().viewer?.fav)
    }

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

        model.toggleGalleryFavorite()

        assertEquals(13, model.state.value.gallery!!.favCount)
        model.state.awaitValue("the record uri") {
            it.gallery?.viewer?.fav == "at://did:plc:me/fav/g1"
        }
    }

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

        model.toggleGalleryFavorite()

        val state = model.state.awaitValue("the revert") { it.gallery?.viewer?.fav == null }
        assertEquals(12, state.gallery!!.favCount)
    }

    @Test
    fun unfavoritingTheGalleryDeletesTheRecord() {
        harness.server.on(
            "social.grain.gallery.getGallery",
            """{"gallery":${json(Fixtures.gallery(favUri = "at://did:plc:me/social.grain.favorite/gf1"))}}""",
        )
        val model = viewModel()
        model.state.awaitValue("the gallery") { it.gallery?.isFavorited == true }

        model.toggleGalleryFavorite()

        assertTrue(harness.server.awaitCall("com.atproto.repo.deleteRecord").body.contains("gf1"))
        assertEquals(11, model.state.value.gallery!!.favCount)
    }

    @Test
    fun favoritingBeforeTheGalleryArrivesIsIgnored() {
        // The fetch is held open rather than raced. Asserting on an empty state
        // right after construction tests how fast the machine is; holding the
        // answer back tests what the view model does with nothing to favorite.
        val answer = CountDownLatch(1)
        harness.server.on("social.grain.gallery.getGallery") {
            answer.await(5, TimeUnit.SECONDS)
            """{"gallery":${json(Fixtures.gallery())}}"""
        }
        val model = viewModel()

        model.toggleGalleryFavorite()
        settle()

        assertNull(model.state.value.gallery)
        assertEquals("Nothing to favorite, so nothing is written", 0, harness.server.countOf("com.atproto.repo.createRecord"))
        answer.countDown()
    }

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

        model.deleteGallery()

        model.state.awaitValue("the delete") { it.isDeleted }
    }

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

        model.deleteGallery()

        val state = model.state.awaitValue("the error") { it.errorMessage != null }
        assertFalse(state.isDeleted)
    }
}