diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 64c02825d..961d61fae 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -67,6 +67,10 @@ import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel @@ -169,6 +173,52 @@ fun FeedScreen( var lightboxState by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } + // Subscribe to contact list (kind 3) — populates localCache.followedUsers + rememberSubscription(connectedRelays, account, relayManager = relayManager) { + if (connectedRelays.isNotEmpty() && account != null) { + createContactListSubscription( + relays = connectedRelays, + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } else { + null + } + } + + // Subscribe to feed events (kind 1) — populates cache via coordinator + rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) { + if (connectedRelays.isEmpty()) return@rememberSubscription null + + when (feedMode) { + FeedMode.GLOBAL -> { + createGlobalFeedSubscription( + relays = connectedRelays, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } + + FeedMode.FOLLOWING -> { + val follows = followedUsers.toList() + if (follows.isNotEmpty()) { + createFollowingFeedSubscription( + relays = connectedRelays, + followedUsers = follows, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } else { + null + } + } + } + } + // DesktopFeedViewModel keyed on feedMode — recreated on mode switch val viewModel = remember(feedMode) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 79dce41fb..e772eda71 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -163,6 +163,29 @@ fun UserProfileScreen( } var retryTrigger by remember { mutableStateOf(0) } + // Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + SubscriptionConfig( + subId = generateSubId("profile-notes-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.textNotesFromAuthors( + authors = listOf(pubKeyHex), + limit = 200, + ), + ), + relays = connectedRelays, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Tab and gallery state var selectedTab by remember { mutableStateOf(0) } var lightboxState by remember { mutableStateOf(null) } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt new file mode 100644 index 000000000..eb3d04558 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Integration tests for the Coordinator → Cache → ViewModel pipeline. + * + * These tests use a stub INostrClient (no real relay connections) and exercise + * the full event consumption path through DesktopRelaySubscriptionsCoordinator. + * + * Key invariant being tested: when coordinator.consumeEvent() is called, + * the event should flow through cache → eventStream → ViewModel.feedState. + */ +class CoordinatorPipelineTest { + private val userPubKey = "a".repeat(64) + private val followedPubKey = "b".repeat(64) + private val dummySig = "0".repeat(128) + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + private suspend fun waitForBundler() = delay(600) + + /** + * Stub INostrClient — records subscription calls but doesn't connect to any relay. + * This lets us test the coordinator's event routing without network dependencies. + */ + private class StubNostrClient : INostrClient { + val openedSubs = mutableMapOf>, IRequestListener?>>() + + override fun connectedRelaysFlow(): StateFlow> = MutableStateFlow(emptySet()) + + override fun availableRelaysFlow(): StateFlow> = MutableStateFlow(emptySet()) + + override fun connect() {} + + override fun disconnect() {} + + override fun reconnect( + onlyIfChanged: Boolean, + ignoreRetryDelays: Boolean, + ) {} + + override fun isActive() = false + + override fun renewFilters(relay: IRelayClient) {} + + override fun openReqSubscription( + subId: String, + filters: Map>, + listener: IRequestListener?, + ) { + openedSubs[subId] = filters to listener + } + + override fun queryCount( + subId: String, + filters: Map>, + ) {} + + override fun close(subId: String) { + openedSubs.remove(subId) + } + + override fun send( + event: Event, + relayList: Set, + ) {} + + override fun subscribe(listener: IRelayClientListener) {} + + override fun unsubscribe(listener: IRelayClientListener) {} + + override fun getReqFiltersOrNull(subId: String): Map>? = null + + override fun getCountFiltersOrNull(subId: String): Map>? = null + + override fun activeRequests(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + } + + private fun createCoordinator( + cache: DesktopLocalCache, + scope: CoroutineScope, + ): Pair { + val client = StubNostrClient() + val coordinator = + DesktopRelaySubscriptionsCoordinator( + client = client, + scope = scope, + indexRelays = setOf(relayUrl), + localCache = cache, + ) + return coordinator to client + } + + // ----------------------------------------------------------------------- + // 1. Coordinator → Cache → ViewModel flow + // ----------------------------------------------------------------------- + + @Test + fun `consumeEvent routes text note into cache and triggers ViewModel update`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + waitForBundler() + assertIs(vm.feedState.feedContent.value) + + // Simulate relay event arriving through coordinator + val event = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "Hello from relay", + sig = dummySig, + ) + coordinator.consumeEvent(event, relayUrl) + + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs( + state, + "ViewModel should be Loaded after coordinator.consumeEvent()", + ) + assertTrue( + vm.feedState.visibleNotes().any { it.idHex == event.id }, + "Note should appear in feed", + ) + + vm.destroy() + scope.cancel() + } + + @Test + fun `consumeEvent updates lastEventAt timestamp`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + assertTrue(coordinator.lastEventAt.value == null, "lastEventAt should be null initially") + + val event = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "test", + sig = dummySig, + ) + coordinator.consumeEvent(event, relayUrl) + waitForBundler() + + assertTrue(coordinator.lastEventAt.value != null, "lastEventAt should be set after consumeEvent") + + scope.cancel() + } + + @Test + fun `contact list consumed via coordinator updates followedUsers`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + val contactEvent = + ContactListEvent( + id = "cl1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = arrayOf(arrayOf("p", followedPubKey)), + content = "", + sig = dummySig, + ) + coordinator.consumeEvent(contactEvent, relayUrl) + waitForBundler() + + assertTrue( + cache.followedUsers.value.contains(followedPubKey), + "followedUsers should contain the followed pubkey after contact list consumption", + ) + + scope.cancel() + } + + @Test + fun `following feed shows notes after contact list and text notes arrive via coordinator`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + // Step 1: Contact list arrives + val contactEvent = + ContactListEvent( + id = "cl1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = arrayOf(arrayOf("p", followedPubKey)), + content = "", + sig = dummySig, + ) + coordinator.consumeEvent(contactEvent, relayUrl) + waitForBundler() + + // Step 2: Create following feed ViewModel + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + assertIs(vm.feedState.feedContent.value) + + // Step 3: Text note from followed user arrives + val textEvent = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = followedPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "Note from followed user", + sig = dummySig, + ) + coordinator.consumeEvent(textEvent, relayUrl) + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs( + state, + "Following feed should show notes from followed users", + ) + assertTrue(vm.feedState.visibleNotes().size == 1) + + vm.destroy() + scope.cancel() + } + + @Test + fun `following feed remains empty when no contact list has been consumed`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + // No contact list consumed — followedUsers is empty + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + + // Text note arrives but not from a followed user (because no follows) + val textEvent = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = followedPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "Note that won't show", + sig = dummySig, + ) + coordinator.consumeEvent(textEvent, relayUrl) + waitForBundler() + + assertIs( + vm.feedState.feedContent.value, + "Following feed should be empty when no contact list loaded — " + + "this is the bug the user sees (0 notes, 0 followed)", + ) + + vm.destroy() + scope.cancel() + } + + // ----------------------------------------------------------------------- + // 2. Duplicate event handling + // ----------------------------------------------------------------------- + + @Test + fun `duplicate events are not double-counted in feed`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, _) = createCoordinator(cache, scope) + + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + waitForBundler() + + val event = + TextNoteEvent( + id = "n1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = "test", + sig = dummySig, + ) + + // Consume same event twice (can happen with multiple relays) + coordinator.consumeEvent(event, relayUrl) + coordinator.consumeEvent(event, NormalizedRelayUrl("wss://relay2.test/")) + waitForBundler() + + assertTrue( + vm.feedState.visibleNotes().size == 1, + "Same event from multiple relays should appear only once", + ) + + vm.destroy() + scope.cancel() + } + + // ----------------------------------------------------------------------- + // 3. Interaction subscriptions + // ----------------------------------------------------------------------- + + @Test + fun `requestInteractions opens subscription on client`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, client) = createCoordinator(cache, scope) + + val noteIds = listOf("n1".padEnd(64, '0')) + val subId = coordinator.requestInteractions(noteIds, setOf(relayUrl)) + + // openReqSubscription is launched in scope — wait for it + delay(200) + + assertTrue(client.openedSubs.containsKey(subId), "Interaction subscription should be opened") + + coordinator.releaseInteractions(subId) + assertTrue(!client.openedSubs.containsKey(subId), "Subscription should be closed after release") + + scope.cancel() + } + + @Test + fun `requestInteractions with empty noteIds returns without opening subscription`() = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val cache = DesktopLocalCache() + val (coordinator, client) = createCoordinator(cache, scope) + + coordinator.requestInteractions(emptyList(), setOf(relayUrl)) + delay(200) + + assertTrue(client.openedSubs.isEmpty(), "Should not open subscription for empty noteIds") + + scope.cancel() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt new file mode 100644 index 000000000..9c92e54f3 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -0,0 +1,571 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopNotificationFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter +import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Integration tests for the Desktop cache → filter → ViewModel pipeline. + * + * These tests verify that events consumed into DesktopLocalCache flow through + * feed filters and into DesktopFeedViewModel's FeedState correctly. + * + * The test structure mirrors how the app works: + * 1. Events arrive from relays + * 2. DesktopLocalCache.consume() stores them + emits via eventStream + * 3. DesktopFeedViewModel collects eventStream and updates FeedState + * 4. FeedFilter determines which notes appear in which feed + */ +class DesktopCachePipelineTest { + // Deterministic test keys + private val userPubKey = "a".repeat(64) + private val followedPubKey = "b".repeat(64) + private val unfollowedPubKey = "c".repeat(64) + private val dummySig = "0".repeat(128) + private val relayUrl = + com.vitorpamplona.quartz.nip01Core.relay.normalizer + .NormalizedRelayUrl("wss://relay.test/") + + /** Wait for async bundling (250ms bundler + margin) */ + private suspend fun waitForBundler() = delay(500) + + private fun textNote( + id: String, + pubKey: String, + content: String = "Hello world", + createdAt: Long = System.currentTimeMillis() / 1000, + replyToId: String? = null, + ): TextNoteEvent { + val tags = + if (replyToId != null) { + arrayOf(arrayOf("e", replyToId, "", "reply")) + } else { + emptyArray() + } + return TextNoteEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = tags, + content = content, + sig = dummySig, + ) + } + + private fun contactList( + id: String, + pubKey: String, + follows: List, + createdAt: Long = System.currentTimeMillis() / 1000, + ): ContactListEvent = + ContactListEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = follows.map { arrayOf("p", it) }.toTypedArray(), + content = "", + sig = dummySig, + ) + + private fun reaction( + id: String, + pubKey: String, + targetNoteId: String, + createdAt: Long = System.currentTimeMillis() / 1000, + ): ReactionEvent = + ReactionEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = arrayOf(arrayOf("e", targetNoteId)), + content = "+", + sig = dummySig, + ) + + // ----------------------------------------------------------------------- + // 1. Cache consumption basics + // ----------------------------------------------------------------------- + + @Test + fun `consume text note creates Note in cache`() { + val cache = DesktopLocalCache() + val event = textNote("note1".padEnd(64, '0'), userPubKey) + + val consumed = cache.consume(event, relayUrl) + + assertTrue(consumed, "First consume should return true") + val note = cache.getNoteIfExists("note1".padEnd(64, '0')) + assertTrue(note != null, "Note should exist in cache after consume") + assertEquals(event.id, note.event?.id) + } + + @Test + fun `consume same note twice returns false`() { + val cache = DesktopLocalCache() + val event = textNote("note1".padEnd(64, '0'), userPubKey) + + cache.consume(event, relayUrl) + val secondConsume = cache.consume(event, relayUrl) + + assertTrue(!secondConsume, "Second consume of same event should return false") + } + + @Test + fun `consume contact list updates followedUsers`() { + val cache = DesktopLocalCache() + val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey)) + + cache.consume(event, relayUrl) + + assertEquals(setOf(followedPubKey), cache.followedUsers.value) + } + + @Test + fun `newer contact list replaces older`() { + val cache = DesktopLocalCache() + val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) + val newer = + contactList( + "cl2".padEnd(64, '0'), + userPubKey, + listOf(followedPubKey, unfollowedPubKey), + createdAt = 200, + ) + + cache.consume(old, relayUrl) + cache.consume(newer, relayUrl) + + assertEquals(setOf(followedPubKey, unfollowedPubKey), cache.followedUsers.value) + } + + @Test + fun `older contact list is rejected`() { + val cache = DesktopLocalCache() + val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200) + val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) + + cache.consume(newer, relayUrl) + cache.consume(old, relayUrl) + + assertEquals( + setOf(followedPubKey, unfollowedPubKey), + cache.followedUsers.value, + "Older contact list should not overwrite newer", + ) + } + + @Test + fun `consume reaction links to target note`() { + val cache = DesktopLocalCache() + val noteId = "note1".padEnd(64, '0') + val note = textNote(noteId, userPubKey) + val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId) + + cache.consume(note, relayUrl) + cache.consume(react, relayUrl) + + val cachedNote = cache.getNoteIfExists(noteId)!! + assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event") + } + + // ----------------------------------------------------------------------- + // 2. Event stream emission + // ----------------------------------------------------------------------- + + @Test + fun `consume emits to eventStream`() = + runBlocking { + val cache = DesktopLocalCache() + val collected = mutableListOf>() + + val job = + launch(Dispatchers.IO) { + cache.eventStream.newEventBundles.collect { collected.add(it) } + } + + // Give collector time to start + delay(50) + + val event = textNote("note1".padEnd(64, '0'), userPubKey) + cache.consume(event, relayUrl) + val note = cache.getNoteIfExists(event.id)!! + cache.emitNewNotes(setOf(note)) + + delay(100) + job.cancel() + + assertTrue(collected.isNotEmpty(), "EventStream should emit after consume + emitNewNotes") + assertTrue(collected.any { batch -> batch.any { it.idHex == event.id } }) + } + + // ----------------------------------------------------------------------- + // 3. Filter logic + // ----------------------------------------------------------------------- + + @Test + fun `GlobalFeedFilter includes all text notes`() { + val cache = DesktopLocalCache() + val filter = DesktopGlobalFeedFilter(cache) + + // Add notes from different authors + cache.consume(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200), relayUrl) + cache.consume(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300), relayUrl) + + val feed = filter.feed() + assertEquals(3, feed.size, "Global feed should contain all text notes") + } + + @Test + fun `FollowingFeedFilter only includes notes from followed users`() { + val cache = DesktopLocalCache() + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val feed = filter.feed() + + assertEquals(1, feed.size, "Following feed should only contain notes from followed users") + assertEquals("n1".padEnd(64, '0'), feed[0].idHex) + } + + @Test + fun `FollowingFeedFilter returns empty when no follows`() { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { emptySet() } + val feed = filter.feed() + + assertTrue(feed.isEmpty(), "Following feed should be empty when no follows") + } + + @Test + fun `ProfileFeedFilter only shows notes from target pubkey`() { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl) + + val filter = DesktopProfileFeedFilter(followedPubKey, cache) + val feed = filter.feed() + + assertEquals(1, feed.size) + assertEquals(followedPubKey, feed[0].author?.pubkeyHex) + } + + @Test + fun `ThreadFilter returns root and replies`() { + val cache = DesktopLocalCache() + val rootId = "root".padEnd(64, '0') + val replyId = "reply".padEnd(64, '0') + + cache.consume(textNote(rootId, userPubKey, createdAt = 100), relayUrl) + cache.consume(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId), relayUrl) + + val filter = DesktopThreadFilter(rootId, cache) + val feed = filter.feed() + + assertEquals(2, feed.size, "Thread should contain root + reply") + } + + @Test + fun `NotificationFeedFilter shows events tagging user`() { + val cache = DesktopLocalCache() + val noteId = "note1".padEnd(64, '0') + cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl) + + // Reaction from someone else targeting user's note + val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId, createdAt = 200) + cache.consume(react, relayUrl) + + val filter = DesktopNotificationFeedFilter(userPubKey, cache) + val feed = filter.feed() + + // ReactionEvent tags "e" not "p" — notification filter requires isTaggedUser + // This test documents the current behavior + val reactNote = cache.getNoteIfExists("react1".padEnd(64, '0')) + val reactEvent = reactNote?.event + assertTrue(reactEvent != null, "Reaction event should exist in cache") + } + + // ----------------------------------------------------------------------- + // 4. ViewModel integration + // ----------------------------------------------------------------------- + + @Test + fun `ViewModel starts in Loading then transitions to Loaded after refresh`() = + runBlocking { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl) + + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + + // Wait for init refresh + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs(state, "After consuming notes and refreshing, state should be Loaded") + + val notes = vm.feedState.visibleNotes() + assertEquals(1, notes.size) + vm.destroy() + } + + @Test + fun `ViewModel shows Empty when cache has no matching notes`() = + runBlocking { + val cache = DesktopLocalCache() + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs(state, "ViewModel should show Empty when no notes in cache") + vm.destroy() + } + + @Test + fun `ViewModel updates when new notes arrive via eventStream`() = + runBlocking { + val cache = DesktopLocalCache() + val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) + + waitForBundler() + assertIs(vm.feedState.feedContent.value) + + // Simulate relay event arriving + val event = textNote("n1".padEnd(64, '0'), userPubKey) + cache.consume(event, relayUrl) + val note = cache.getNoteIfExists(event.id)!! + cache.emitNewNotes(setOf(note)) + + waitForBundler() + + val state = vm.feedState.feedContent.value + assertIs(state, "ViewModel should transition to Loaded after new notes arrive") + assertEquals(1, vm.feedState.visibleNotes().size) + vm.destroy() + } + + @Test + fun `Following ViewModel only shows followed users notes via eventStream`() = + runBlocking { + val cache = DesktopLocalCache() + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + + // Add followed user's note + val e1 = textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100) + cache.consume(e1, relayUrl) + val note1 = cache.getNoteIfExists(e1.id)!! + cache.emitNewNotes(setOf(note1)) + waitForBundler() + + assertEquals(1, vm.feedState.visibleNotes().size, "Should show followed user's note") + + // Add unfollowed user's note + val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200) + cache.consume(e2, relayUrl) + val note2 = cache.getNoteIfExists(e2.id)!! + cache.emitNewNotes(setOf(note2)) + waitForBundler() + + assertEquals(1, vm.feedState.visibleNotes().size, "Should NOT show unfollowed user's note") + vm.destroy() + } + + @Test + fun `Following ViewModel feed is empty when followedUsers is empty`() = + runBlocking { + val cache = DesktopLocalCache() + // No contact list consumed — followedUsers remains empty + + val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) + cache.consume(e1, relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + val vm = DesktopFeedViewModel(filter, cache) + waitForBundler() + + assertIs( + vm.feedState.feedContent.value, + "Following feed should be empty when no contact list loaded", + ) + vm.destroy() + } + + // ----------------------------------------------------------------------- + // 5. Cache clear + // ----------------------------------------------------------------------- + + @Test + fun `clear resets all cache state`() { + val cache = DesktopLocalCache() + cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl) + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + cache.clear() + + assertEquals(0, cache.noteCount()) + assertEquals(0, cache.userCount()) + assertTrue(cache.followedUsers.value.isEmpty()) + } + + // ----------------------------------------------------------------------- + // 6. Feed ordering + // ----------------------------------------------------------------------- + + @Test + fun `global feed is sorted newest first`() { + val cache = DesktopLocalCache() + cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl) + cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl) + cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl) + + val filter = DesktopGlobalFeedFilter(cache) + val feed = filter.feed() + + assertEquals("new".padEnd(64, '0'), feed[0].idHex, "Newest note should be first") + assertEquals("old".padEnd(64, '0'), feed[2].idHex, "Oldest note should be last") + } + + // ----------------------------------------------------------------------- + // 7. Metadata consumption + // ----------------------------------------------------------------------- + + @Test + fun `consumeMetadata updates user info`() { + val cache = DesktopLocalCache() + val metadata = + com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent( + id = "meta1".padEnd(64, '0'), + pubKey = userPubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = """{"name":"TestUser","display_name":"Test User","about":"A test user"}""", + sig = dummySig, + ) + + cache.consume(metadata, relayUrl) + + val user = cache.getUserIfExists(userPubKey) + assertTrue(user != null, "User should exist after metadata consumption") + // Metadata parsing may vary, but user object should be created + assertEquals(userPubKey, user.pubkeyHex) + } + + // ----------------------------------------------------------------------- + // 8. BoundedLargeCache eviction + // ----------------------------------------------------------------------- + + @Test + fun `BoundedLargeCache evicts when over capacity`() { + val cache = BoundedLargeCache(10, evictPercent = 0.5f) + + repeat(15) { i -> + cache.put("key_${i.toString().padStart(3, '0')}", "value_$i") + } + + assertTrue(cache.size() <= 10, "Cache should not exceed max size, got ${cache.size()}") + } + + @Test + fun `BoundedLargeCache get returns null for evicted entries`() { + val cache = BoundedLargeCache(5, evictPercent = 0.5f) + + repeat(10) { i -> + cache.put("key_${i.toString().padStart(3, '0')}", "value_$i") + } + + // Some early entries should have been evicted + val size = cache.size() + assertTrue(size <= 5, "Cache should be at or below max size") + } + + // ----------------------------------------------------------------------- + // 9. Additive filter incremental updates + // ----------------------------------------------------------------------- + + @Test + fun `GlobalFeedFilter applyFilter only accepts TextNoteEvents`() { + val cache = DesktopLocalCache() + val filter = DesktopGlobalFeedFilter(cache) + + // Create a text note + val textEvent = textNote("t1".padEnd(64, '0'), userPubKey) + cache.consume(textEvent, relayUrl) + val textNote = cache.getNoteIfExists(textEvent.id)!! + + // Create a reaction (not a text note) + val reactEvent = reaction("r1".padEnd(64, '0'), userPubKey, "t1".padEnd(64, '0')) + cache.consume(reactEvent, relayUrl) + val reactNote = cache.getNoteIfExists(reactEvent.id)!! + + val filtered = filter.applyFilter(setOf(textNote, reactNote)) + + assertEquals(1, filtered.size, "applyFilter should only pass TextNoteEvents") + assertTrue(filtered.first().event is TextNoteEvent) + } + + @Test + fun `FollowingFeedFilter applyFilter respects follow set`() { + val cache = DesktopLocalCache() + cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) + + val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } + + val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) + cache.consume(e1, relayUrl) + val note1 = cache.getNoteIfExists(e1.id)!! + + val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey) + cache.consume(e2, relayUrl) + val note2 = cache.getNoteIfExists(e2.id)!! + + val filtered = filter.applyFilter(setOf(note1, note2)) + + assertEquals(1, filtered.size, "applyFilter should only include followed users") + assertEquals(followedPubKey, filtered.first().author?.pubkeyHex) + } +}