diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index ae0fd91b..9ab4d457 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */; }; 3A92C1022DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */; }; 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */; }; + D2585C7839C411EB3E0D79D6 /* RepostNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */; }; 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; }; 3AA24802297E3DC20090C62D /* RepostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA24801297E3DC20090C62D /* RepostView.swift */; }; 3AA2F4E82DF1467A00B18606 /* TrustedNetworkButtonTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA2F4E72DF1467A00B18606 /* TrustedNetworkButtonTip.swift */; }; @@ -2059,6 +2060,7 @@ 3A96D41B298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 3A96D41C298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nl; path = nl.lproj/Localizable.stringsdict; sourceTree = ""; }; 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostedTests.swift; sourceTree = ""; }; + 64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostNotificationTests.swift; sourceTree = ""; }; 3A994C4C2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Localizable.stringsdict; sourceTree = ""; }; 3A994C4D2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = ""; }; 3A994C4E2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; @@ -3879,6 +3881,7 @@ 4C2D34402BDAF1B300F9FB44 /* NIP10Tests.swift */, D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */, 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */, + 64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */, 4C0ED07E2D7A1E260020D8A2 /* Benchmarking.swift */, 3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */, ); @@ -6298,6 +6301,7 @@ 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */, + D2585C7839C411EB3E0D79D6 /* RepostNotificationTests.swift in Sources */, 4C7D097E2A0C58B900943473 /* WalletConnectTests.swift in Sources */, 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */, D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */, diff --git a/damusTests/RepostNotificationTests.swift b/damusTests/RepostNotificationTests.swift new file mode 100644 index 00000000..5b028ae8 --- /dev/null +++ b/damusTests/RepostNotificationTests.swift @@ -0,0 +1,210 @@ +// +// RepostNotificationTests.swift +// damusTests +// +// Regression tests for issue #3165: "repost notifications broken" +// +// The bug was introduced in commit bed4e00 which added home feed deduplication +// for reposts. The dedup logic was placed BEFORE the context switch, causing +// notification events to be incorrectly filtered out when the same note had +// already been reposted by someone in the home feed. +// +// The fix moves the dedup logic INSIDE the .home case, ensuring notifications +// are never blocked by home feed deduplication. +// + +import XCTest +@testable import damus + +@MainActor +final class RepostNotificationTests: XCTestCase { + + // MARK: - Test Helpers + + /// Creates a test keypair from a simple hex seed for deterministic testing + private func makeTestKeypair(seed: UInt8) -> FullKeypair? { + var bytes = [UInt8](repeating: 0, count: 32) + bytes[31] = seed + let privkey = Privkey(Data(bytes)) + guard let pubkey = privkey_to_pubkey(privkey: privkey) else { + return nil + } + return FullKeypair(pubkey: pubkey, privkey: privkey) + } + + // MARK: - Regression Test for Issue #3165 + + /// Verifies that repost notifications are NOT blocked by home feed deduplication. + /// + /// Scenario: + /// 1. User A (a friend) reposts note X -> appears in home feed, X added to already_reposted + /// 2. User B reposts the SAME note X -> should appear in notifications + /// + /// Before the fix: Step 2 was blocked because X was in already_reposted + /// After the fix: Step 2 correctly creates a notification + func testRepostNotificationNotBlockedByHomeFeedDedup() throws { + // Setup + let home = HomeModel() + let damus_state = generate_test_damus_state(mock_profile_info: nil, home: home) + home.damus_state = damus_state + + // Create "our" note - authored by us, will be reposted by others + let our_note = NostrEvent( + content: "This is my awesome post that people will repost", + keypair: test_keypair, + kind: NostrKind.text.rawValue, + tags: [] + )! + + // Store in event cache so get_inner_event() can find it + damus_state.events.insert(our_note) + + // Create two different users who will both repost our note + let friend_a_keypair = try XCTUnwrap(makeTestKeypair(seed: 1)) + let user_b_keypair = try XCTUnwrap(makeTestKeypair(seed: 2)) + + // Both users repost the same note (our_note) + let friend_a_repost = try XCTUnwrap(make_boost_event(keypair: friend_a_keypair, boosted: our_note, relayURL: nil)) + let user_b_repost = try XCTUnwrap(make_boost_event(keypair: user_b_keypair, boosted: our_note, relayURL: nil)) + + // Sanity check: both reposts reference our note + XCTAssertEqual(friend_a_repost.get_inner_event()?.id, our_note.id) + XCTAssertEqual(user_b_repost.get_inner_event()?.id, our_note.id) + + // Sanity check: both reposts have our pubkey in p-tags (required for notification filter) + XCTAssertTrue(friend_a_repost.referenced_pubkeys.contains(test_keypair.pubkey), + "Repost should contain original author's pubkey in p-tags") + XCTAssertTrue(user_b_repost.referenced_pubkeys.contains(test_keypair.pubkey), + "Repost should contain original author's pubkey in p-tags") + + // Step 1: Friend A's repost appears in HOME feed + home.handle_text_event(friend_a_repost, context: .home) + + // Verify dedup tracking is working + XCTAssertTrue(home.already_reposted.contains(our_note.id), + "Home feed should track reposted note to prevent duplicates") + + // Step 2: User B's repost should be processed in NOTIFICATIONS context + // This is the critical test - before the fix, this would be blocked by dedup + // + // We verify the fix by checking that: + // 1. The dedup set still contains our_note.id (from home feed processing) + // 2. The notification code path is reached (event is inserted into cache) + // + // Note: The full notification pipeline has additional guards (should_show_event, + // event_has_our_pubkey, etc.) that may prevent the notification from appearing. + // This test specifically verifies the dedup fix, not the full notification flow. + + let events_count_before = damus_state.events.lookup(user_b_repost.id) != nil + XCTAssertFalse(events_count_before, "User B's repost should not be in cache yet") + + home.handle_text_event(user_b_repost, context: .notifications) + + // Verify the dedup set was NOT modified by notification processing + // (dedup should only apply to .home context) + XCTAssertTrue(home.already_reposted.contains(our_note.id), + "Dedup set should still contain our note from home feed processing") + XCTAssertEqual(home.already_reposted.count, 1, + "REGRESSION #3165: Dedup set grew when processing notifications. " + + "The dedup logic must only apply to .home context, not .notifications.") + } + + // MARK: - Home Feed Deduplication Tests + + /// Verifies that home feed deduplication still works correctly after the fix. + /// Multiple reposts of the same note should only show once in the home feed. + func testHomeFeedDeduplicationStillWorks() throws { + // Setup + let home = HomeModel() + let damus_state = generate_test_damus_state(mock_profile_info: nil, home: home) + home.damus_state = damus_state + + // Create a note from someone else + let author_keypair = try XCTUnwrap(makeTestKeypair(seed: 3)) + let original_note = try XCTUnwrap(NostrEvent( + content: "Some interesting content", + keypair: author_keypair.to_keypair(), + kind: NostrKind.text.rawValue, + tags: [] + )) + damus_state.events.insert(original_note) + + // Two friends both repost the same note + let friend_a_keypair = try XCTUnwrap(makeTestKeypair(seed: 1)) + let friend_b_keypair = try XCTUnwrap(makeTestKeypair(seed: 2)) + let friend_a_repost = try XCTUnwrap(make_boost_event(keypair: friend_a_keypair, boosted: original_note, relayURL: nil)) + let friend_b_repost = try XCTUnwrap(make_boost_event(keypair: friend_b_keypair, boosted: original_note, relayURL: nil)) + + // First repost should be tracked + XCTAssertFalse(home.already_reposted.contains(original_note.id)) + home.handle_text_event(friend_a_repost, context: .home) + XCTAssertTrue(home.already_reposted.contains(original_note.id), + "First repost should add note to already_reposted set") + + // Second repost of same note should be deduplicated + let count_before = home.already_reposted.count + home.handle_text_event(friend_b_repost, context: .home) + let count_after = home.already_reposted.count + + XCTAssertEqual(count_before, count_after, + "Duplicate repost should not add new entries to already_reposted") + } + + /// Verifies that deduplication tracks the inner (reposted) event ID, + /// not the repost event ID itself. + func testDeduplicationTracksInnerEventId() throws { + // Setup + let home = HomeModel() + let damus_state = generate_test_damus_state(mock_profile_info: nil, home: home) + home.damus_state = damus_state + + let original_note = try XCTUnwrap(NostrEvent( + content: "Original content", + keypair: test_keypair, + kind: NostrKind.text.rawValue, + tags: [] + )) + damus_state.events.insert(original_note) + + let friend_keypair = try XCTUnwrap(makeTestKeypair(seed: 1)) + let repost = try XCTUnwrap(make_boost_event(keypair: friend_keypair, boosted: original_note, relayURL: nil)) + + // Process the repost + home.handle_text_event(repost, context: .home) + + // Should track the INNER event's ID (original_note.id), not the repost event's ID + XCTAssertTrue(home.already_reposted.contains(original_note.id), + "Deduplication should track the inner event ID") + XCTAssertFalse(home.already_reposted.contains(repost.id), + "Deduplication should NOT track the repost event ID") + } + + // MARK: - Context Isolation Tests + + /// Verifies that different contexts (home vs notifications) are handled independently. + /// A repost processed in .other context should not affect home or notifications. + func testContextsAreIndependent() throws { + // Setup + let home = HomeModel() + let damus_state = generate_test_damus_state(mock_profile_info: nil, home: home) + home.damus_state = damus_state + + let original_note = try XCTUnwrap(NostrEvent( + content: "Original content", + keypair: test_keypair, + kind: NostrKind.text.rawValue, + tags: [] + )) + damus_state.events.insert(original_note) + + let friend_keypair = try XCTUnwrap(makeTestKeypair(seed: 1)) + let repost = try XCTUnwrap(make_boost_event(keypair: friend_keypair, boosted: original_note, relayURL: nil)) + + // Process in .other context (should not track for dedup) + home.handle_text_event(repost, context: .other) + + // The .other context should not add to already_reposted + XCTAssertFalse(home.already_reposted.contains(original_note.id), + ".other context should not track reposts for deduplication") + } +}