import BuzzPushKit import Flutter import Intents import UserNotifications import XCTest @testable import Buzz final class BuzzCommunicationNotificationTests: XCTestCase { func testIntentUsesVerifiedSenderAvatarAndGroupName() throws { let resolution = communicationResolution( displayName: "Alice", groupName: "General", avatarPNG: Data([0x89, 0x50, 0x4E, 0x47]) ) let descriptor = try XCTUnwrap( BuzzCommunicationNotificationDescriptor(resolution: resolution) ) let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor) XCTAssertEqual(intent.sender?.displayName, "Alice") XCTAssertEqual(intent.sender?.customIdentifier, descriptor.senderIdentifier) XCTAssertNotNil(intent.sender?.image) XCTAssertNotNil(intent.image(forParameterNamed: \.speakableGroupName)) XCTAssertEqual(intent.content, "Hello Buzz") XCTAssertEqual(intent.speakableGroupName?.spokenPhrase, "General") XCTAssertEqual(intent.conversationIdentifier, resolution.conversationIdentifier) XCTAssertNil(intent.recipients) XCTAssertEqual(descriptor.recipientCount, 1) XCTAssertEqual( (intent.donationMetadata as? INSendMessageIntentDonationMetadata)?.recipientCount, 1 ) } func testDirectMessageRetainsSenderAvatarWithoutGroupMetadata() throws { let resolution = communicationResolution( displayName: "Alice", groupName: nil, avatarPNG: Data([0x89, 0x50, 0x4E, 0x47]) ) let descriptor = try XCTUnwrap( BuzzCommunicationNotificationDescriptor(resolution: resolution) ) let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor) XCTAssertEqual(intent.sender?.displayName, "Alice") XCTAssertNotNil(intent.sender?.image) XCTAssertNil(intent.image(forParameterNamed: \.speakableGroupName)) XCTAssertNil(intent.speakableGroupName) XCTAssertNil(intent.donationMetadata) } func testMissingVerifiedRecipientCountUsesOrdinaryPresentation() { XCTAssertNil( BuzzCommunicationNotificationDescriptor( resolution: communicationResolution(recipientCount: nil) ) ) } func testPresentationFallsBackWhenDonationFails() { let ordinary = UNMutableNotificationContent() ordinary.title = "Alice" ordinary.body = "Hello Buzz" var updateCalled = false let presenter = BuzzCommunicationNotificationPresenter( donate: { _, completion in completion(NSError(domain: "test", code: 1)) }, deleteAllInteractions: { completion in completion(nil) }, updateContent: { content, _ in updateCalled = true return content } ) let completed = expectation(description: "ordinary fallback returned") presenter.present( ordinaryContent: ordinary, resolution: communicationResolution() ) { content in XCTAssertEqual(content.title, "Alice") XCTAssertEqual(content.body, "Hello Buzz") completed.fulfill() } wait(for: [completed], timeout: 1) XCTAssertFalse(updateCalled) } func testPresentationDonatesBeforeSpecializing() { let ordinary = UNMutableNotificationContent() ordinary.title = "Alice" var order: [String] = [] let presenter = BuzzCommunicationNotificationPresenter( donate: { _, completion in order.append("donate") completion(nil) }, deleteAllInteractions: { completion in completion(nil) }, updateContent: { _, _ in order.append("update") let specialized = UNMutableNotificationContent() specialized.title = "specialized" return specialized } ) let completed = expectation(description: "specialized content returned") presenter.present( ordinaryContent: ordinary, resolution: communicationResolution() ) { content in XCTAssertEqual(content.title, "specialized") completed.fulfill() } wait(for: [completed], timeout: 1) XCTAssertEqual(order, ["donate", "update"]) } func testPresentationFallsBackWhenSpecializationFails() { let ordinary = UNMutableNotificationContent() ordinary.title = "Alice" ordinary.body = "Hello Buzz" let presenter = BuzzCommunicationNotificationPresenter( donate: { _, completion in completion(nil) }, deleteAllInteractions: { completion in completion(nil) }, updateContent: { _, _ in throw NSError(domain: "test", code: 2) } ) let completed = expectation(description: "ordinary fallback returned") presenter.present( ordinaryContent: ordinary, resolution: communicationResolution() ) { content in XCTAssertEqual(content.title, "Alice") XCTAssertEqual(content.body, "Hello Buzz") completed.fulfill() } wait(for: [completed], timeout: 1) } func testFenceChangeDeletesDonatedInteractionBeforeCompleting() { let ordinary = UNMutableNotificationContent() ordinary.title = "Alice" var allowed = true var order: [String] = [] let presenter = BuzzCommunicationNotificationPresenter( donate: { _, completion in order.append("donate") allowed = false completion(nil) }, deleteAllInteractions: { completion in order.append("delete") completion(nil) }, updateContent: { content, _ in order.append("update") return content } ) let completed = expectation(description: "restricted donation deleted") presenter.present( ordinaryContent: ordinary, resolution: communicationResolution(), isStillAllowed: { allowed } ) { content in order.append("complete") XCTAssertEqual(content.title, "Alice") completed.fulfill() } wait(for: [completed], timeout: 1) XCTAssertEqual(order, ["donate", "delete", "complete"]) } func testBlockedFenceDoesNotDonate() { let ordinary = UNMutableNotificationContent() ordinary.title = "Alice" var donateCalled = false let presenter = BuzzCommunicationNotificationPresenter( donate: { _, completion in donateCalled = true completion(nil) }, deleteAllInteractions: { completion in completion(nil) }, updateContent: { content, _ in content } ) let completed = expectation(description: "blocked donation skipped") presenter.present( ordinaryContent: ordinary, resolution: communicationResolution(), isStillAllowed: { false } ) { content in XCTAssertEqual(content.title, "Alice") completed.fulfill() } wait(for: [completed], timeout: 1) XCTAssertFalse(donateCalled) } private func communicationResolution( displayName: String = "Alice", groupName: String? = "General", avatarPNG: Data? = nil, recipientCount: Int? = 1 ) -> BuzzPushResolution { let communityID = "community-id" let channelID = "channel/general:v5" return BuzzPushResolution( title: displayName, body: "Hello Buzz", subtitle: "Community", threadIdentifier: BuzzPushPresentationIdentity.conversation( communityID: communityID, channelID: channelID ), navigationTarget: BuzzPushNavigationTarget( eventID: "message-id", communityID: communityID, channelID: channelID ), senderPubkey: String(repeating: "a", count: 64), senderAvatarPNG: avatarPNG, conversationIdentifier: BuzzPushPresentationIdentity.conversation( communityID: communityID, channelID: channelID ), conversationDisplayName: groupName, conversationRecipientCount: recipientCount ) } } final class BuzzPushSnapshotEnrichmentTests: XCTestCase { func testAgeSignalRequestDoesNotDependOnNotificationStorage() { let delegate = MissingAppGroupDelegate() var completed = false delegate.handleAgeSignalMethodCall( FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil), viewController: nil ) { value in if #available(iOS 26.0, *) { XCTAssertEqual((value as? FlutterError)?.code, "age_signal_unavailable") } else { XCTAssertEqual((value as? [String: Any])?["status"] as? String, "noSignal") } completed = true } XCTAssertTrue(completed) } @MainActor func testNativeAgeRequestDeliversKnownMinorAndFailureThroughFlutterCallback() async { guard #available(iOS 26.0, *) else { return } let delegate = MissingAppGroupDelegate() delegate.requestPlatformAgeSignal = { _ in BuzzAgeSignalPayload.sharing(exclusiveUpperBound: 18, lowerBound: 13) } let minor = expectation(description: "native minor response") delegate.handleAgeSignalMethodCall( FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil), viewController: UIViewController() ) { value in XCTAssertEqual((value as? [String: Any])?["status"] as? String, "signal") XCTAssertEqual((value as? [String: Any])?["ageUpper"] as? Int, 17) minor.fulfill() } await fulfillment(of: [minor], timeout: 1) delegate.requestPlatformAgeSignal = { _ in throw NSError(domain: "InjectedAgeFailure", code: 1) } let failed = expectation(description: "native error response") delegate.handleAgeSignalMethodCall( FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil), viewController: UIViewController() ) { value in XCTAssertEqual((value as? FlutterError)?.code, "age_signal_unavailable") failed.fulfill() } await fulfillment(of: [failed], timeout: 1) } func testAllowedNotificationRestoreNeedsNoAppGroupStore() { let bridge = BuzzPushSnapshotBridge( appGroupIdentifier: nil, endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil), keychainAccessGroup: nil ) let completed = expectation(description: "allowed without storage") XCTAssertTrue(bridge.handle( FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil) ) { value in XCTAssertNil(value) completed.fulfill() }) wait(for: [completed], timeout: 1) } func testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let store = BuzzPushPresentationCacheStore(containerURL: directory) try store.replaceCommunities([ PushLeaseCommunity(id: "retained", name: "Retained", relayUrl: "https://relay.example", pubkey: nil, policies: []) ]) let snapshotURL = directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName) let original = try Data(contentsOf: snapshotURL) var deletionCalls = 0 let bridge = BuzzPushSnapshotBridge( appGroupIdentifier: nil, endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil), keychainAccessGroup: nil, containerURL: { directory }, interactionDeletionDeadline: BuzzInteractionDeletionDeadline( timeout: 5, deleteAllInteractions: { completion in deletionCalls += 1 completion(deletionCalls == 1 ? NSError(domain: "InjectedCleanupFailure", code: 1) : nil) }, scheduleTimeout: { _, _ in } ) ) let restricted = expectation(description: "restriction despite cleanup failure") XCTAssertTrue(bridge.handle( FlutterMethodCall(methodName: "purgeAgeRestrictedNotifications", arguments: nil) ) { value in XCTAssertEqual((value as? FlutterError)?.code, "age_restriction_purge_failed") XCTAssertTrue(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) restricted.fulfill() }) wait(for: [restricted], timeout: 1) XCTAssertEqual(try Data(contentsOf: snapshotURL), original) let restored = expectation(description: "allowed without restoring snapshot") XCTAssertTrue(bridge.handle( FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil) ) { value in XCTAssertNil(value) XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) restored.fulfill() }) wait(for: [restored], timeout: 1) XCTAssertEqual(deletionCalls, 2) XCTAssertEqual(try Data(contentsOf: snapshotURL), original) } func testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let failure = NSError(domain: "InjectedCleanupFailure", code: 1) let deletion = BuzzInteractionDeletionDeadline(timeout: 5, deleteAllInteractions: { $0(failure) }, scheduleTimeout: { _, _ in }) BuzzInteractionCleanupRetry(containerURL: directory, deletion: deletion).request { XCTAssertNotNil($0) } let bridge = BuzzPushSnapshotBridge( appGroupIdentifier: nil, endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil), keychainAccessGroup: nil, containerURL: { directory }, interactionDeletionDeadline: deletion ) let completed = expectation(description: "retry failure stays allowed") XCTAssertTrue(bridge.handle( FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil) ) { value in XCTAssertEqual((value as? FlutterError)?.code, "interaction_cleanup_retry_failed") XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) completed.fulfill() }) wait(for: [completed], timeout: 1) } func testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable() { let bridge = BuzzPushSnapshotBridge( appGroupIdentifier: nil, endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil), keychainAccessGroup: nil ) let completed = expectation(description: "strict write rejected") XCTAssertTrue( bridge.handle( FlutterMethodCall( methodName: "syncAgeGatePushSnapshot", arguments: [ "section": "communities", "communities": [[String: Any]](), "signingKeys": [String: String](), "settleFence": false, ] ) ) { value in XCTAssertEqual((value as? FlutterError)?.code, "snapshot_sync_unavailable") completed.fulfill() } ) wait(for: [completed], timeout: 1) } func testMetadataAuthorityUsesNewestGrantForMatchingRelay() { let correctRelay = grant( generation: 2, metadataPubkey: String(repeating: "a", count: 64) ) let wrongRelay = grant( relayOrigin: "https://other.example", generation: 99, metadataPubkey: String(repeating: "b", count: 64) ) XCTAssertEqual( BuzzPushSnapshotBridge.relayMetadataPubkey( relayURL: "wss://relay.example/", grants: [wrongRelay, correctRelay, grant( generation: 1, metadataPubkey: String(repeating: "d", count: 64) )] ), correctRelay.relayMetadataPubkey ) } private func grant( relayOrigin: String = "https://relay.example", generation: Int64, metadataPubkey: String ) -> BuzzPushEndpointGrantRecord { BuzzPushEndpointGrantRecord( gatewayOrigin: "https://push.example", relayOrigin: relayOrigin, relayPubkey: String(repeating: "c", count: 64), relayMetadataPubkey: metadataPubkey, appAttestKeyId: Data(repeating: 0xAA, count: 32).base64EncodedString(), installationId: "installation", endpointGrant: "opaque-grant", endpointHash: String(repeating: "d", count: 64), endpointEpoch: 1, generation: generation, expiresAt: 1_900_000_000 ) } } final class BuzzPushNotificationResponseTests: XCTestCase { func testValidDefaultActionRoutesAndCompletesExactlyOnce() { let target = BuzzPushNavigationTarget( eventID: "message-id", communityID: "community-id", channelID: "opaque-channel-id" ) var routedTargets: [BuzzPushNavigationTarget] = [] var forwarded = 0 var completions = 0 BuzzPushNotificationResponseCoordinator.handle( actionIdentifier: UNNotificationDefaultActionIdentifier, userInfo: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue], onTarget: { routedTargets.append($0) }, forwardToFlutter: { pluginCompletion in forwarded += 1 pluginCompletion() pluginCompletion() }, completion: { completions += 1 } ) XCTAssertEqual(routedTargets, [target]) XCTAssertEqual(forwarded, 1) XCTAssertEqual(completions, 1) } func testMalformedTargetFallsBackToOneCompletion() { var routedTargets: [BuzzPushNavigationTarget] = [] var completions = 0 BuzzPushNotificationResponseCoordinator.handle( actionIdentifier: UNNotificationDefaultActionIdentifier, userInfo: [BuzzPushNavigationTarget.userInfoKey: ["event_id": ""]], onTarget: { routedTargets.append($0) }, forwardToFlutter: { _ in }, completion: { completions += 1 } ) XCTAssertTrue(routedTargets.isEmpty) XCTAssertEqual(completions, 1) } func testNonDefaultActionIgnoresLateDuplicatePluginCompletion() throws { var routedTargets: [BuzzPushNavigationTarget] = [] var pluginCompletion: (() -> Void)? var completions = 0 BuzzPushNotificationResponseCoordinator.handle( actionIdentifier: "buzz.reply", userInfo: [:], onTarget: { routedTargets.append($0) }, forwardToFlutter: { pluginCompletion = $0 }, completion: { completions += 1 } ) let capturedCompletion = try XCTUnwrap(pluginCompletion) capturedCompletion() capturedCompletion() XCTAssertTrue(routedTargets.isEmpty) XCTAssertEqual(completions, 1) } } private final class MissingAppGroupDelegate: AppDelegate { override var appGroupIdentifier: String? { nil } }