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) } }