Files
buzz/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift
2026-08-26 11:59:44 -07:00

405 lines
13 KiB
Swift

import BuzzPushKit
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))
},
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)
},
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) },
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)
}
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 testMetadataAuthorityUsesCurrentAppProfileForMatchingRelay() {
let correctProfile = grant(
appProfile: BuzzDevPushEnrollmentDriver.appProfile,
generation: 2,
metadataPubkey: String(repeating: "a", count: 64)
)
let wrongProfile = grant(
appProfile: "other-profile",
generation: 99,
metadataPubkey: String(repeating: "b", count: 64)
)
XCTAssertEqual(
BuzzPushSnapshotBridge.relayMetadataPubkey(
relayURL: "wss://relay.example/",
grants: [wrongProfile, correctProfile]
),
correctProfile.relayMetadataPubkey
)
}
private func grant(
appProfile: String,
generation: Int64,
metadataPubkey: String
) -> BuzzPushEndpointGrantRecord {
BuzzPushEndpointGrantRecord(
relayOrigin: "https://relay.example",
relayPubkey: String(repeating: "c", count: 64),
relayMetadataPubkey: metadataPubkey,
installationId: "installation",
endpointGrant: "opaque-grant",
endpointHash: String(repeating: "d", count: 64),
appProfile: appProfile,
endpointEpoch: 1,
generation: generation,
expiresAt: 1_900_000_000
)
}
}
final class BuzzPushNotificationResponseTests: XCTestCase {
func testMessageCategoryUsesConciseTitlesAndPublicActionIcons() {
let category = BuzzPushNotificationCategoryRegistrar.messageCategory()
XCTAssertEqual(category.identifier, BuzzPushNotificationActions.messageCategoryIdentifier)
XCTAssertEqual(
category.actions.map(\.title),
["Reply", "Remind Me in 1 Hour", "Remind Me Tomorrow", "Remind Me Next Week"]
)
XCTAssertTrue(category.actions.allSatisfy { $0.icon != nil })
XCTAssertEqual(BuzzPushReminderPreset.allCases.map(\.systemImageName), ["clock", "clock", "clock"])
}
func testBackgroundCompletionReturnsToMainThreadExactlyOnce() {
let completed = expectation(description: "completion returned on main thread")
var completionCount = 0
let completion = BuzzOneShotCompletion {
XCTAssertTrue(Thread.isMainThread)
completionCount += 1
completed.fulfill()
}
DispatchQueue.global().async {
completion.call()
completion.call()
}
wait(for: [completed], timeout: 1)
XCTAssertEqual(completionCount, 1)
}
func testReminderActionsRoutePresetWithoutOpeningFlutter() throws {
let context = BuzzPushReplyContext(
eventID: "message-id",
rootEventID: "root-id",
communityID: "community-id",
channelID: "opaque-channel-id",
senderPubkey: String(repeating: "a", count: 64),
replyKind: 9
)
for preset in BuzzPushReminderPreset.allCases {
var reminder: (BuzzPushReplyContext, BuzzPushReminderPreset)?
var reminderCompletion: (() -> Void)?
var forwarded = 0
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: preset.actionIdentifier,
userInfo: [BuzzPushReplyContext.userInfoKey: context.userInfoValue],
onTarget: { _ in XCTFail("Reminder must not navigate as an ordinary tap") },
onReminder: { routedContext, routedPreset, completion in
reminder = (routedContext, routedPreset)
reminderCompletion = completion
},
forwardToFlutter: { _ in forwarded += 1 },
completion: { completions += 1 }
)
XCTAssertEqual(reminder?.0, context)
XCTAssertEqual(reminder?.1, preset)
XCTAssertEqual(forwarded, 0)
XCTAssertEqual(completions, 0)
try XCTUnwrap(reminderCompletion)()
XCTAssertEqual(completions, 1)
}
}
func testReminderPresetsMatchExistingBuzzDurations() throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "America/Los_Angeles"))
let now = try XCTUnwrap(
calendar.date(from: DateComponents(year: 2026, month: 8, day: 26, hour: 11))
)
XCTAssertEqual(
BuzzPushReminderPreset.oneHour.fireDate(now: now, calendar: calendar),
now.addingTimeInterval(60 * 60)
)
XCTAssertEqual(
BuzzPushReminderPreset.tomorrowAt9AM.fireDate(now: now, calendar: calendar),
calendar.date(from: DateComponents(year: 2026, month: 8, day: 27, hour: 9))
)
XCTAssertEqual(
BuzzPushReminderPreset.nextMondayAt9AM.fireDate(now: now, calendar: calendar),
calendar.date(from: DateComponents(year: 2026, month: 8, day: 31, hour: 9))
)
}
func testValidTextReplyWaitsForPublisherAndDoesNotOpenFlutter() throws {
let context = BuzzPushReplyContext(
eventID: "message-id",
rootEventID: "root-id",
communityID: "community-id",
channelID: "opaque-channel-id",
senderPubkey: String(repeating: "a", count: 64),
replyKind: 9
)
var published: (BuzzPushReplyContext, String)?
var publishCompletion: (() -> Void)?
var forwarded = 0
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: BuzzPushNotificationActions.replyActionIdentifier,
userInfo: [BuzzPushReplyContext.userInfoKey: context.userInfoValue],
textInput: "A lock-screen reply",
onTarget: { _ in XCTFail("Reply must not navigate") },
onReply: { replyContext, content, completion in
published = (replyContext, content)
publishCompletion = completion
},
forwardToFlutter: { _ in forwarded += 1 },
completion: { completions += 1 }
)
XCTAssertEqual(published?.0, context)
XCTAssertEqual(published?.1, "A lock-screen reply")
XCTAssertEqual(forwarded, 0)
XCTAssertEqual(completions, 0)
try XCTUnwrap(publishCompletion)()
XCTAssertEqual(completions, 1)
}
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)
}
}