package social.grain import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull 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.auth.SecureStore import social.grain.data.local.Appearance import social.grain.data.local.AppearanceStore import social.grain.data.push.PushManager import social.grain.data.upload.GalleryDraftStore import social.grain.data.upload.GalleryUploadCenter import social.grain.feature.notifications.UnseenNotificationCount import social.grain.navigation.DeepLink import social.grain.support.Fixtures import social.grain.support.Harness import social.grain.support.MainDispatcherRule import social.grain.support.appContext import social.grain.support.awaitCall import social.grain.support.awaitValue import social.grain.support.json import social.grain.support.settle import social.grain.support.testJson /** * The pieces that outlive any one screen. * * The OAuth callback in particular can't live on the login screen — the * redirect is delivered to the Activity, by which time that screen's view model * may be gone — and a deep link tapped before sign-in has to survive it rather * than being dropped. */ @RunWith(AndroidJUnit4::class) class MainViewModelTest { @get:Rule val mainDispatcher = MainDispatcherRule() private lateinit var harness: Harness private lateinit var appearance: AppearanceStore @Before fun setUp() { harness = Harness() appearance = AppearanceStore(appContext) appearance.set(Appearance.AUTOMATIC) harness.server .on( "social.grain.unspecced.getActorProfile", json( Fixtures.profileDetailed( did = harness.did, handle = "renamed.grain.social", ), ), ) .on("social.grain.unspecced.getNotifications", """{"notifications":[],"unseenCount":3}""") } @After fun tearDown() { appearance.set(Appearance.AUTOMATIC) harness.tearDown() } private fun viewModel(): MainViewModel { val uploads = GalleryUploadCenter(appContext, GalleryDraftStore(appContext, testJson)) return MainViewModel( auth = harness.auth, client = harness.client, uploadCenter = uploads, feedCache = harness.feedCache, unseenNotifications = UnseenNotificationCount( appContext, harness.client, harness.auth, ), recentSearches = harness.recentSearches, push = PushManager( appContext, harness.client, harness.auth, SecureStore(appContext), harness.scope, ), storyStatusCache = harness.storyStatus, viewedStories = harness.viewedStories, appearanceStore = appearance, ) } @Test fun signingInPullsTheOwnProfileSoSelfAvatarsAreNotPlaceholders() { val model = viewModel() harness.server.awaitCall("social.grain.unspecced.getActorProfile") model.authState.awaitValue("the resolved handle") { it.handle == "renamed.grain.social" } assertEquals( "https://cdn.grain.test/avatar/renamed.grain.social.jpg", harness.auth.state.value.avatar, ) } @Test fun theProfileIsPulledOnceRatherThanOnEveryStateChange() { viewModel() harness.server.awaitCall("social.grain.unspecced.getActorProfile") settle() Thread.sleep(400) assertEquals( "Storing the avatar re-emits the auth state; that must not loop", 1, harness.server.countOf("social.grain.unspecced.getActorProfile"), ) } @Test fun theBadgeIsFilledInFromTheCheapPollOnLaunch() { val model = viewModel() model.unseenCount.awaitValue("the badge") { it == 3 } } @Test fun theAppearanceChoiceIsPublishedForTheWholeApp() { val model = viewModel() assertEquals(Appearance.AUTOMATIC, model.appearance.value) appearance.set(Appearance.DARK) assertEquals(Appearance.DARK, model.appearance.value) } @Test fun storyRingsAreAssembledFromWhoHasOneAndWhatHasBeenWatched() { val model = viewModel() harness.storyStatus.update( listOf(Fixtures.storyAuthor(profile = Fixtures.profile(did = "did:plc:alice"))), ) val presence = runBlocking { withTimeoutOrNull(5_000) { model.storyPresence.first { it.hasStory("did:plc:alice") } } } assertTrue("Alice's ring lights up", presence!!.hasStory("did:plc:alice")) assertFalse("Nothing watched yet, so it isn't grey", presence.isCaughtUp("did:plc:alice")) } @Test fun aLinkIsHeldUntilSomethingNavigatesToIt() { val model = viewModel() val link = DeepLink.Profile("alice.grain.social") model.onDeepLink(link) assertEquals(link, model.pendingDeepLink.value) model.consumeDeepLink() assertNull("A consumed link must not replay", model.pendingDeepLink.value) } @Test fun aDeniedSignInIsACancellationRatherThanAnErrorToShow() { harness.server.on("par", """{"request_uri":"urn:abc"}""") val model = viewModel() runBlocking { harness.auth.beginLogin() } model.handleOAuthCallback(Uri.parse("grain://oauth/callback?error=access_denied")) settle() Thread.sleep(300) assertNull(model.signInError.value) } @Test fun anyOtherCallbackFailureIsSurfacedAndCanBeDismissed() { harness.server.on("par", """{"request_uri":"urn:abc"}""") val model = viewModel() runBlocking { harness.auth.beginLogin() } model.handleOAuthCallback( Uri.parse("grain://oauth/callback?error=server_error&error_description=Boom"), ) model.signInError.awaitValue("the error") { it != null } assertEquals("Boom", model.signInError.value) model.dismissSignInError() assertNull(model.signInError.value) } @Test fun comingBackToTheAppReReadsTheBadge() { val model = viewModel() model.unseenCount.awaitValue("the badge") { it == 3 } val before = harness.server.countOf("social.grain.unspecced.getNotifications") harness.server.on( "social.grain.unspecced.getNotifications", """{"notifications":[],"unseenCount":9}""", ) model.onForeground() model.unseenCount.awaitValue("the refreshed badge") { it == 9 } assertTrue(harness.server.countOf("social.grain.unspecced.getNotifications") > before) } @Test fun signingOutFromAnywhereClearsTheSession() { val model = viewModel() model.authState.awaitValue("the session") { it.isAuthenticated } model.signOut() model.authState.awaitValue("the sign-out") { !it.isAuthenticated } } @Test fun theUploadStageIsPublishedForTheResumeBanner() { assertEquals( social.grain.data.upload.UploadStage.Idle, viewModel().uploadStage.value, ) } }