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("dev.hatk.describeLabels", """{"definitions":[]}""") .on("social.grain.unspecced.getActorProfile", json(Fixtures.profileDetailed())) .on("dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}]}""") .on("social.grain.unspecced.getStories", """{"stories":[]}""") .on("social.grain.unspecced.getActorFavorites", """{"items":[]}""") .on("social.grain.unspecced.getStoryArchive", """{"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("dev.hatk.getFeed").params["feed"]) assertEquals(alice, harness.server.lastCall("dev.hatk.getFeed").params["actor"]) } @Test fun theProfileTabWithNoRouteArgumentIsTheViewersOwn() { harness.server.on("social.grain.unspecced.getActorProfile", 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.unspecced.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.unspecced.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.unspecced.getActorProfile", """{"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.unspecced.getActorProfile", 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.unspecced.getActorProfile", 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.unspecced.getActorFavorites", """{"items":[${json(Fixtures.gallery(rkey = "faved", title = "Faved"))}]}""", ) harness.server.on("social.grain.unspecced.getActorProfile", ownProfile()) val model = viewModel(did = harness.did) model.state.awaitValue("the profile") { it.profile != null } assertEquals(0, harness.server.countOf("social.grain.unspecced.getActorFavorites")) 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.unspecced.getStoryArchive", """{"stories":[${json(Fixtures.story(rkey = "old", expired = true))}]}""", ) harness.server.on("social.grain.unspecced.getActorProfile", 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.unspecced.getActorFavorites")) } @Test fun aRefreshRenewsTabsThatHaveBeenOpenedAndNothingElse() { harness.server.on("social.grain.unspecced.getActorProfile", 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.unspecced.getActorFavorites") model.refresh() harness.server.awaitCall("social.grain.unspecced.getActorFavorites", favoritesBefore + 1) assertEquals( "The archive was never opened, so a refresh doesn't fetch it", 0, harness.server.countOf("social.grain.unspecced.getStoryArchive"), ) } @Test fun morGalleriesAppendBehindTheCursor() { harness.server.on( "dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}],"cursor":"c1"}""", ) val model = viewModel() model.state.awaitValue("the grid") { it.galleries.isNotEmpty() } harness.server.on( "dev.hatk.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("dev.hatk.getFeed") model.loadMoreGalleries() settle() assertEquals(before, harness.server.countOf("dev.hatk.getFeed")) } @Test fun moreArchivedStoriesAppendBehindTheirOwnCursor() { harness.server.on("social.grain.unspecced.getActorProfile", ownProfile()) harness.server.on( "social.grain.unspecced.getStoryArchive", """{"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.unspecced.getStoryArchive", """{"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("dev.hatk.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.unspecced.getActorProfile", 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("dev.hatk.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("dev.hatk.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.unspecced.getActorProfile", ownProfile()) val model = viewModel(did = harness.did) model.state.awaitValue("the profile") { it.profile != null } model.toggleFollow() settle() assertEquals(0, harness.server.countOf("dev.hatk.createRecord")) } @Test fun blockingWritesTheRecordAndReloadsTheScreen() { harness.server.on("dev.hatk.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.unspecced.getActorProfile") model.toggleBlock() assertTrue("The state flips first", model.state.value.isBlocking) harness.server.awaitCall("social.grain.unspecced.getActorProfile", profileFetches + 1) } @Test fun unblockingDeletesTheBlockRecord() { harness.server.on( "social.grain.unspecced.getActorProfile", 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("dev.hatk.deleteRecord").body.contains("\"rkey\":\"b1\"")) } @Test fun aBlockThatFailsIsPutBackWithAMessage() { harness.server.on("dev.hatk.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.unspecced.getActorProfile", 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( "social.grain.unspecced.deleteGallery", """{"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.unspecced.getActorProfile") invalidation.invalidate("did:plc:someone-else") settle() assertEquals(before, harness.server.countOf("social.grain.unspecced.getActorProfile")) invalidation.invalidate(alice) harness.server.awaitCall("social.grain.unspecced.getActorProfile", before + 1) } }