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