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

grain.social/grain-android

1 collaborator
0
1
main
606 lines · 21.3 KB Raw History
package social.grain

import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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.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.local.FeedCache
import social.grain.data.local.LabelDefinitionsCache
import social.grain.data.local.RecentProfileSearch
import social.grain.data.local.RecentSearchStore
import social.grain.data.local.StoryStatusCache
import social.grain.data.local.ViewedStoryStore
import social.grain.data.model.GrainLabels
import social.grain.data.model.StoryViewerState
import social.grain.support.Fixtures
import social.grain.support.Harness
import social.grain.support.FakeAndroidKeyStore
import social.grain.support.StubServer
import social.grain.support.awaitCall
import social.grain.support.awaitValue
import social.grain.support.appContext
import social.grain.support.awaitCall
import social.grain.support.json
import social.grain.support.testJson
import social.grain.support.xrpc
import java.time.Instant

/**
 * The small on-device stores every screen leans on.
 *
 * Each one exists to answer a question cheaply that the network would answer
 * slowly, so the failures they can have are all of the same shape: a stale or
 * cross-account answer that looks perfectly plausible.
 */
@RunWith(AndroidJUnit4::class)
class LocalStoresTest {

    private lateinit var scope: CoroutineScope

    @Before
    fun setUp() {
        scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    }

    @After
    fun tearDown() {
        scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
    }

    // MARK: - SecureStore

    @Test
    fun aSealedValueComesBackOutAndCanBeRemoved() {
        FakeAndroidKeyStore.install()
        val store = SecureStore(appContext)

        store.putString("credential", "hunter2")

        assertEquals(
            "A second store finds the master key the first one made",
            "hunter2",
            SecureStore(appContext).getString("credential"),
        )
        assertTrue(store.contains("credential"))

        store.remove("credential")
        assertNull(store.getString("credential"))
        assertFalse(store.contains("credential"))
    }

    @Test
    fun writingNullIsTheSameAsRemoving() {
        FakeAndroidKeyStore.install()
        val store = SecureStore(appContext)
        store.putString("gone", "value")

        store.putString("gone", null)

        assertNull(store.getString("gone"))
    }

    @Test
    fun rawBytesRoundTripAsWellAsStrings() {
        FakeAndroidKeyStore.install()
        val store = SecureStore(appContext)
        val bytes = byteArrayOf(1, 2, 3, 4, 5)

        store.putBytes("key", bytes)

        assertTrue(bytes.contentEquals(store.getBytes("key")))
        store.remove("key")
    }

    @Test
    fun aValueThatCannotBeDecryptedIsDroppedRatherThanThrowingForever() {
        FakeAndroidKeyStore.install()
        val store = SecureStore(appContext)
        store.putString("corrupt", "value")
        // What a cleared app or a restored device leaves behind: ciphertext
        // that no key on this install can open.
        appContext.getSharedPreferences("social.grain.secure", android.content.Context.MODE_PRIVATE)
            .edit().putString("corrupt", "bm90IGEgY2lwaGVydGV4dA==").commit()

        assertNull(store.getString("corrupt"))
        assertFalse("The unreadable value is swept up on the way past", store.contains("corrupt"))
    }

    // MARK: - FeedCache

    @Test
    fun aCachedFeedComesBackForTheAccountThatFetchedIt() = runBlocking {
        val cache = FeedCache(appContext, testJson)
        cache.activeDid = "did:plc:one"
        cache.save(listOf(Fixtures.gallery(title = "Mine")), "recent")

        assertEquals("Mine", cache.load("recent").single().title)

        cache.activeDid = "did:plc:two"
        assertTrue("Another account must not see it", cache.load("recent").isEmpty())

        cache.activeDid = "did:plc:one"
        cache.purge("did:plc:one")
        assertTrue(cache.load("recent").isEmpty())
    }

    @Test
    fun anEmptyPageIsNotWorthCaching() = runBlocking {
        val cache = FeedCache(appContext, testJson)
        cache.activeDid = "did:plc:empty"
        cache.save(listOf(Fixtures.gallery()), "recent")
        cache.save(emptyList(), "recent")

        assertEquals("An empty save must not wipe what was there", 1, cache.load("recent").size)
        cache.purge("did:plc:empty")
    }

    @Test
    fun feedKeysWithSlashesAndColonsStillNameOneFileEach() = runBlocking {
        val cache = FeedCache(appContext, testJson)
        cache.activeDid = "did:plc:paths"
        cache.save(listOf(Fixtures.gallery(title = "Camera")), "camera:Leica M6")

        assertEquals("Camera", cache.load("camera:Leica M6").single().title)
        cache.purge("did:plc:paths")
    }

    // MARK: - StoryStatusCache

    @Test
    fun anAuthorWithALiveStoryWearsARingUntilItExpires() {
        val cache = StoryStatusCache()
        val now = Instant.parse("2026-03-01T12:00:00Z")
        cache.update(
            listOf(
                Fixtures.storyAuthor(
                    profile = Fixtures.profile(did = "did:plc:alice"),
                    latestAt = "2026-03-01T11:00:00Z",
                ),
            ),
        )

        assertTrue(cache.hasStory("did:plc:alice", now))
        assertNotNull(cache.author("did:plc:alice", now))
        assertEquals(1, cache.liveAuthors(now).size)

        val tomorrow = now.plusSeconds(60 * 60 * 25)
        assertFalse("A day later the story is gone", cache.hasStory("did:plc:alice", tomorrow))
        assertTrue(cache.liveAuthors(tomorrow).isEmpty())
    }

    @Test
    fun anAuthorWithNoStoriesIsNotInTheCacheAtAll() {
        val cache = StoryStatusCache()
        cache.update(
            listOf(
                Fixtures.storyAuthor(
                    profile = Fixtures.profile(did = "did:plc:quiet"),
                    storyCount = 0,
                ),
            ),
        )
        assertFalse(cache.hasStory("did:plc:quiet"))
    }

    @Test
    fun theLiveListIsNewestStoryFirst() {
        val cache = StoryStatusCache()
        val now = Instant.parse("2026-03-01T12:00:00Z")
        cache.update(
            listOf(
                Fixtures.storyAuthor(
                    profile = Fixtures.profile(did = "did:plc:older"),
                    latestAt = "2026-03-01T06:00:00Z",
                ),
                Fixtures.storyAuthor(
                    profile = Fixtures.profile(did = "did:plc:newer"),
                    latestAt = "2026-03-01T11:00:00Z",
                ),
            ),
        )

        assertEquals("did:plc:newer", cache.liveAuthors(now).first().did)
    }

    @Test
    fun anAuthorCanBeForgottenAndTheWholeCacheCleared() {
        val cache = StoryStatusCache()
        cache.update(
            listOf(
                Fixtures.storyAuthor(profile = Fixtures.profile(did = "did:plc:a")),
                Fixtures.storyAuthor(profile = Fixtures.profile(did = "did:plc:b")),
            ),
        )

        cache.remove("did:plc:a")
        assertFalse(cache.hasStory("did:plc:a"))
        assertTrue(cache.hasStory("did:plc:b"))

        cache.clear()
        assertTrue(cache.statuses.value.entries.isEmpty())
    }

    @Test
    fun expiredEntriesCanBeSweptOut() {
        val cache = StoryStatusCache()
        cache.update(
            listOf(
                Fixtures.storyAuthor(
                    profile = Fixtures.profile(did = "did:plc:old"),
                    latestAt = "2026-03-01T00:00:00Z",
                ),
            ),
        )

        cache.purgeExpired(Instant.parse("2026-03-03T00:00:00Z"))

        assertTrue(cache.statuses.value.entries.isEmpty())
    }

    @Test
    fun anAuthorWithAnUnparseableTimestampNeverCountsAsLive() {
        val cache = StoryStatusCache()
        cache.update(
            listOf(
                Fixtures.storyAuthor(
                    profile = Fixtures.profile(did = "did:plc:bad"),
                    latestAt = "not a date",
                ),
            ),
        )
        assertFalse(cache.hasStory("did:plc:bad"))
    }

    @Test
    fun askingAboutNobodyIsAnswerable() {
        assertFalse(StoryStatusCache().statuses.value.hasStory(null))
        assertNull(StoryStatusCache().statuses.value.author(null))
    }

    // MARK: - ViewedStoryStore

    @Test
    fun watchedStoriesAreRememberedPerAccount() {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:one")
        store.markViewed("at://s/1", "did:plc:alice", "2026-03-01T10:00:00Z")

        assertTrue(store.isViewed("at://s/1"))
        assertTrue(store.hasViewedAll("did:plc:alice", "2026-03-01T10:00:00Z"))

        store.switchAccount("did:plc:two")
        assertFalse("Another account has watched nothing", store.isViewed("at://s/1"))

        store.switchAccount("did:plc:one")
        assertTrue("Coming back finds it again", store.isViewed("at://s/1"))
    }

    @Test
    fun theHighWaterMarkOnlyEverMovesForward() {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:marks")
        store.markViewed("at://s/new", "did:plc:alice", "2026-03-01T12:00:00Z")
        store.markViewed("at://s/old", "did:plc:alice", "2026-03-01T06:00:00Z")

        assertTrue(
            "Rewatching an older story must not un-see a newer one",
            store.hasViewedAll("did:plc:alice", "2026-03-01T12:00:00Z"),
        )
    }

    @Test
    fun anAuthorNeverWatchedIsNotCaughtUp() {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:fresh-${System.nanoTime()}")
        assertFalse(store.hasViewedAll("did:plc:stranger", "2026-03-01T12:00:00Z"))
    }

    @Test
    fun theViewerOpensAtTheFirstStoryNotYetWatched() {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:resume-${System.nanoTime()}")
        store.markViewed("at://s/1", "did:plc:alice", "2026-03-01T10:00:00Z")

        assertEquals(1, store.firstUnviewedIndex(listOf("at://s/1", "at://s/2")))
        assertEquals(
            "A fully watched author replays from the start",
            0,
            store.firstUnviewedIndex(listOf("at://s/1")),
        )
    }

    @Test
    fun watchedMarksOlderThanADayAreSweptOut() {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:cleanup-${System.nanoTime()}")
        store.markViewed("at://s/1", "did:plc:alice", "2026-03-01T00:00:00Z")

        store.cleanup(Instant.parse("2026-03-03T00:00:00Z"))

        assertFalse(store.hasViewedAll("did:plc:alice", "2026-03-01T00:00:00Z"))
    }

    @Test
    fun watchingSomethingBumpsTheVersionTheRingsWatch() {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:version-${System.nanoTime()}")
        val before = store.version.value

        store.markViewed("at://s/1", "did:plc:alice", "2026-03-01T10:00:00Z")

        assertTrue(store.version.value > before)
    }

    @Test
    fun theServersHighWaterMarkIsTakenOnAndOnlyEverMovesForward() {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:absorb-${System.nanoTime()}")
        store.markViewed("at://s/2", "did:plc:alice", "2026-03-01T12:00:00Z")

        store.absorb(
            listOf(
                Fixtures.storyAuthor(
                    profile = Fixtures.profile(did = "did:plc:alice"),
                    latestAt = "2026-03-01T12:00:00Z",
                ).copy(lastViewedAt = "2026-03-01T06:00:00Z"),
                Fixtures.storyAuthor(
                    profile = Fixtures.profile(did = "did:plc:bob"),
                    latestAt = "2026-03-01T09:00:00Z",
                ).copy(lastViewedAt = "2026-03-01T09:00:00Z"),
                Fixtures.storyAuthor(
                    profile = Fixtures.profile(did = "did:plc:carol"),
                    latestAt = "2026-03-01T09:00:00Z",
                ),
            ),
        )

        assertTrue("An older server mark does not un-see alice", store.hasViewedAll("did:plc:alice", "2026-03-01T12:00:00Z"))
        assertTrue("Bob was watched on another device", store.hasViewedAll("did:plc:bob", "2026-03-01T09:00:00Z"))
        assertFalse("Carol carries no mark", store.hasViewedAll("did:plc:carol", "2026-03-01T09:00:00Z"))
        assertEquals("Nothing the server said goes back up to it", 1, store.pendingUploadCount)
    }

    @Test
    fun storiesTheServerFlagsAsWatchedJoinTheSet() {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:flags-${System.nanoTime()}")
        val alice = Fixtures.profile(did = "did:plc:alice")

        store.absorb(
            listOf(
                Fixtures.story(rkey = "s1", creator = alice, createdAt = "2026-03-01T10:00:00Z")
                    .copy(viewer = StoryViewerState(viewed = true)),
                Fixtures.story(rkey = "s2", creator = alice, createdAt = "2026-03-01T11:00:00Z"),
            ),
        )

        assertTrue(store.isViewed("at://did:plc:alice/social.grain.story/s1"))
        assertFalse(store.isViewed("at://did:plc:alice/social.grain.story/s2"))
        assertEquals(
            "The viewer opens on the one not yet watched",
            1,
            store.firstUnviewedIndex(
                listOf("at://did:plc:alice/social.grain.story/s1", "at://did:plc:alice/social.grain.story/s2"),
            ),
        )
        assertFalse(store.hasViewedAll("did:plc:alice", "2026-03-01T11:00:00Z"))
        assertEquals(0, store.pendingUploadCount)
    }

    @Test
    fun watchedStoriesAreReportedAndAFailedReportStaysQueued() = runBlocking {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:report-${System.nanoTime()}")
        val sent = mutableListOf<List<String>>()
        store.uploader = { uris -> throw java.io.IOException("offline") }

        store.markViewed("at://s/1", "did:plc:alice", "2026-03-01T10:00:00Z")
        store.markViewed("at://s/2", "did:plc:alice", "2026-03-01T11:00:00Z")
        store.flushPending()

        assertEquals("Both wait for a connection", 2, store.pendingUploadCount)
        assertTrue("Locally they are still watched", store.isViewed("at://s/1"))

        store.uploader = { uris -> sent += uris.sorted() }
        store.flushPending()

        assertEquals(listOf(listOf("at://s/1", "at://s/2")), sent)
        assertEquals(0, store.pendingUploadCount)
    }

    @Test
    fun reportsGoUpInServerSizedBatches() = runBlocking {
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount("did:plc:batches-${System.nanoTime()}")
        val batches = mutableListOf<Int>()
        store.uploader = { uris -> batches += uris.size }
        repeat(150) { store.markViewed("at://s/$it", "did:plc:alice", "2026-03-01T10:00:00Z") }

        store.flushPending()

        assertEquals(listOf(100, 50), batches)
        assertEquals(0, store.pendingUploadCount)
    }

    @Test
    fun theReportQueueSurvivesARelaunch() {
        val did = "did:plc:relaunch-${System.nanoTime()}"
        val store = ViewedStoryStore(appContext, testJson, scope)
        store.switchAccount(did)
        store.markViewed("at://s/1", "did:plc:alice", "2026-03-01T10:00:00Z")

        val relaunched = ViewedStoryStore(appContext, testJson, scope)
        relaunched.switchAccount(did)

        assertEquals(1, relaunched.pendingUploadCount)
    }

    // MARK: - RecentSearchStore

    @Test
    fun recentTermsAreDedupedCaseInsensitivelyAndMostRecentFirst() {
        val store = RecentSearchStore(appContext, testJson)
        store.switchAccount("did:plc:terms-${System.nanoTime()}")

        store.addTerm("fog")
        store.addTerm("mist")
        store.addTerm("  FOG  ")

        assertEquals(listOf("FOG", "mist"), store.terms)
    }

    @Test
    fun anEmptyTermIsNotRemembered() {
        val store = RecentSearchStore(appContext, testJson)
        store.switchAccount("did:plc:blank-${System.nanoTime()}")
        store.addTerm("   ")
        assertTrue(store.terms.isEmpty())
    }

    @Test
    fun recentProfilesAreDedupedByDidAndCapped() {
        val store = RecentSearchStore(appContext, testJson)
        store.switchAccount("did:plc:profiles-${System.nanoTime()}")

        repeat(12) { index ->
            store.addProfile(RecentProfileSearch(did = "did:plc:p$index", handle = "p$index"))
        }
        store.addProfile(RecentProfileSearch(did = "did:plc:p0", handle = "p0-again"))

        assertEquals(10, store.profiles.size)
        assertEquals("did:plc:p0", store.profiles.first().did)
        assertEquals(1, store.profiles.count { it.did == "did:plc:p0" })
    }

    @Test
    fun recentsAreRemovableOneAtATimeAndAllAtOnce() {
        val store = RecentSearchStore(appContext, testJson)
        store.switchAccount("did:plc:remove-${System.nanoTime()}")
        store.addTerm("fog")
        store.addProfile(RecentProfileSearch(did = "did:plc:bob"))

        store.removeTerm("fog")
        store.removeProfile("did:plc:bob")
        assertTrue(store.terms.isEmpty())
        assertTrue(store.profiles.isEmpty())

        store.addTerm("mist")
        store.clear()
        assertTrue(store.terms.isEmpty())
    }

    @Test
    fun searchHistoryDoesNotCrossAccounts() {
        val store = RecentSearchStore(appContext, testJson)
        val one = "did:plc:hist-a-${System.nanoTime()}"
        val two = "did:plc:hist-b-${System.nanoTime()}"
        store.switchAccount(one)
        store.addTerm("private")

        store.switchAccount(two)
        assertTrue(store.terms.isEmpty())

        store.switchAccount(one)
        assertEquals(listOf("private"), store.terms)
        store.clear()
    }

    // MARK: - AppearanceStore

    @Test
    fun theAppearanceChoiceSurvivesANewStore() {
        AppearanceStore(appContext).set(Appearance.DARK)
        assertEquals(Appearance.DARK, AppearanceStore(appContext).appearance.value)

        AppearanceStore(appContext).set(Appearance.AUTOMATIC)
        assertEquals(Appearance.AUTOMATIC, AppearanceStore(appContext).appearance.value)
    }

    @Test
    fun everyAppearanceHasALabelToPutOnItsButton() {
        assertEquals(listOf("Automatic", "Light", "Dark"), Appearance.entries.map { it.label })
    }

    // MARK: - LabelDefinitionsCache

    private val described = """
        {"did":"did:web:stub.grain.test","policies":{
          "labelValues":["!takedown","gore"],
          "labelValueDefinitions":[{"identifier":"gore","severity":"alert","blurs":"media",
            "defaultSetting":"warn","locales":[{"lang":"en","name":"Gore","description":"Blood."}]}]}}
    """.trimIndent()

    @Test
    fun labelDefinitionsAreTheBundledOnesUntilTheServerAnswers() {
        val harness = Harness()
        try {
            val cache = LabelDefinitionsCache(harness.client, harness.auth, harness.scope)
            assertEquals(
                listOf("copyright", "csae", "gore", "harassment", "misleading", "nudity", "other", "sexual", "spam"),
                cache.definitions.value.map { it.identifier },
            )
            val gore = cache.definitions.value.first { it.identifier == "gore" }
            assertEquals("media", gore.blurs)
            assertEquals("hide", gore.defaultSetting)
            assertEquals("Graphic Media", gore.displayName)
        } finally {
            harness.tearDown()
        }
    }

    @Test
    fun labelDefinitionsAreReadFromTheServerOnceThroughTheProxy() {
        val harness = Harness(StubServer().on(DESCRIBE_LABELS, described))
        try {
            val cache = LabelDefinitionsCache(harness.client, harness.auth, harness.scope)
            cache.loadIfNeeded()
            val read = cache.definitions.awaitValue("the server's definitions") { it.size == 1 }
            assertEquals("Gore", read.single().displayName)
            assertEquals("warn", read.single().defaultSetting)
            assertNotNull(harness.server.lastCall(DESCRIBE_LABELS).headers["atproto-proxy"])

            cache.loadIfNeeded()
            Thread.sleep(200)
            assertEquals(1, harness.server.countOf(DESCRIBE_LABELS))
        } finally {
            harness.tearDown()
        }
    }

    @Test
    fun aFailedReadKeepsTheBundledDefinitionsAndIsTriedAgain() {
        val server = StubServer().on(DESCRIBE_LABELS, """{"error":"InternalServerError"}""", code = 500)
        val harness = Harness(server)
        try {
            val cache = LabelDefinitionsCache(harness.client, harness.auth, harness.scope)
            cache.loadIfNeeded()
            server.awaitCall(DESCRIBE_LABELS)
            Thread.sleep(200)
            assertEquals(GrainLabels.definitions, cache.definitions.value)

            server.on(DESCRIBE_LABELS, described)
            cache.loadIfNeeded()
            cache.definitions.awaitValue("the server's definitions") { it.size == 1 }
            assertEquals(2, server.countOf(DESCRIBE_LABELS))
        } finally {
            harness.tearDown()
        }
    }

    private companion object {
        const val DESCRIBE_LABELS = "social.grain.moderation.describeLabels"
    }
}