mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
`BuzzPushSnapshotEnrichmentTests` failed on two unrelated branches on 2026-11-01 in "Mobile Swift (Native)", step "Test native controls and Runner unit tests", and passed on re-run both times: - Run 41327410782 (PR #8896, job 124910378184): `testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority` timed out its 10 s "allowed without restoring snapshot" wait (19.3 s total). The preceding `testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess` passed but took 4.3 s. - Run 41325606910 (PR #8897, job 124905175903): `testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess` timed out its "retry failure stays allowed" wait (nil instead of `interaction_cleanup_retry_failed`), then the restriction test timed out both waits (nil result, still restricted, 0 deletion calls). Cause: scheduling, not test logic. Every reply from `BuzzPushSnapshotBridge` hops through a private serial queue created at `.utility` QoS and then `DispatchQueue.main`. In both failing runs the test host (the full Flutter app, loaded as `TEST_HOST`) saturated the 3-vCPU `macos-latest` runner for roughly 40 s after launch: `BrowserAuthBridgeTests.testAllowsOnlyBuilderlabLoginPages`, a synchronous main-thread URL-parsing test, took 2.65 s instead of 0.013 s, and the since-deleted `testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable` took 4.9 s. In that window the utility queue was the lowest-priority runnable work in the process, so it got no CPU for 10-20 s while main-thread work kept moving slowly; in both passing re-runs every test in this class took under 0.3 s. The tests themselves have nothing to fix: the deletion mocks are synchronous, the deadline scheduler is a no-op, each test uses its own temp directory and bridge, and no state is shared across tests, so neither a state leak nor an expectation ordering race explains a callback that never arrives. Utility is also the wrong class in production: Dart awaits every one of these calls, and the age-gate restore runs during app launch, exactly when the process is busiest. Change: the bridge queue and the default interaction-deletion deadline timer run at `.userInitiated`. The restriction test now records `qos_class_self()` inside the injected deletion closure, which the bridge invokes synchronously on its queue, and asserts both calls ran at `QOS_CLASS_USER_INITIATED`. The 10 s waits are unchanged. Verification (Xcode 27.2, iOS 27.2 simulator, 16-core host; the CI starvation did not reproduce locally): - Class in isolation: 10/10 runs green, 0.1-0.4 s each. - Full RunnerTests bundle with CI's `-parallel-testing-enabled NO`: 2/2 runs green (101 tests, about 22 s). - Falsification: with the queue temporarily back at `.utility` the new assertion fails, observing `[17, 17]` (utility) instead of `[25, 25]`. - A throwaway probe confirmed that async blocks on a queue with an assigned QoS run at that QoS (17 for utility, 25 for user-initiated), so the change alters real scheduling. Saturating the host process's default-QoS pool with spinning blocks did not delay a utility hop (5 ms), so the CI stall is OS-level CPU priority starvation on a saturated 3-vCPU VM, which this machine cannot reproduce; the diagnosis rests on the CI timings above. - The repo has no swiftformat or swiftlint configuration; `git diff --check` is clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
546 lines
19 KiB
Swift
546 lines
19 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Callbacks only record what they receive and assertions run after the wait,
|
|
/// so a callback that lands after its test returned cannot fail a later test.
|
|
/// The timeout is a cap; a passing test returns as soon as its callback runs.
|
|
final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
|
|
private let callbackTimeout: TimeInterval = 10
|
|
|
|
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")
|
|
var minorValue: Any?
|
|
delegate.handleAgeSignalMethodCall(
|
|
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
|
|
viewController: UIViewController()
|
|
) { value in
|
|
minorValue = value
|
|
minor.fulfill()
|
|
}
|
|
await fulfillment(of: [minor], timeout: callbackTimeout)
|
|
XCTAssertEqual((minorValue as? [String: Any])?["status"] as? String, "signal")
|
|
XCTAssertEqual((minorValue as? [String: Any])?["ageUpper"] as? Int, 17)
|
|
|
|
delegate.requestPlatformAgeSignal = { _ in
|
|
throw NSError(domain: "InjectedAgeFailure", code: 1)
|
|
}
|
|
let failed = expectation(description: "native error response")
|
|
var failedValue: Any?
|
|
delegate.handleAgeSignalMethodCall(
|
|
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
|
|
viewController: UIViewController()
|
|
) { value in
|
|
failedValue = value
|
|
failed.fulfill()
|
|
}
|
|
await fulfillment(of: [failed], timeout: callbackTimeout)
|
|
XCTAssertEqual((failedValue as? FlutterError)?.code, "age_signal_unavailable")
|
|
}
|
|
|
|
func testAllowedNotificationRestoreNeedsNoAppGroupStore() {
|
|
let bridge = BuzzPushSnapshotBridge(
|
|
appGroupIdentifier: nil,
|
|
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
|
|
keychainAccessGroup: nil
|
|
)
|
|
let completed = expectation(description: "allowed without storage")
|
|
var received: Any?
|
|
XCTAssertTrue(bridge.handle(
|
|
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
|
|
) { value in
|
|
received = value
|
|
completed.fulfill()
|
|
})
|
|
wait(for: [completed], timeout: callbackTimeout)
|
|
XCTAssertNil(received)
|
|
}
|
|
|
|
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
|
|
var deletionQoS: [qos_class_t] = []
|
|
let bridge = BuzzPushSnapshotBridge(
|
|
appGroupIdentifier: nil,
|
|
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
|
|
keychainAccessGroup: nil,
|
|
containerURL: { directory },
|
|
interactionDeletionDeadline: BuzzInteractionDeletionDeadline(
|
|
timeout: 5,
|
|
deleteAllInteractions: { completion in
|
|
deletionCalls += 1
|
|
deletionQoS.append(qos_class_self())
|
|
completion(deletionCalls == 1 ? NSError(domain: "InjectedCleanupFailure", code: 1) : nil)
|
|
},
|
|
scheduleTimeout: { _, _ in }
|
|
)
|
|
)
|
|
let restricted = expectation(description: "restriction despite cleanup failure")
|
|
var purgeResult: Any?
|
|
XCTAssertTrue(bridge.handle(
|
|
FlutterMethodCall(methodName: "purgeAgeRestrictedNotifications", arguments: nil)
|
|
) { value in
|
|
purgeResult = value
|
|
restricted.fulfill()
|
|
})
|
|
wait(for: [restricted], timeout: callbackTimeout)
|
|
XCTAssertEqual((purgeResult as? FlutterError)?.code, "age_restriction_purge_failed")
|
|
XCTAssertTrue(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
|
|
XCTAssertEqual(try Data(contentsOf: snapshotURL), original)
|
|
|
|
let restored = expectation(description: "allowed without restoring snapshot")
|
|
var restoreResult: Any?
|
|
XCTAssertTrue(bridge.handle(
|
|
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
|
|
) { value in
|
|
restoreResult = value
|
|
restored.fulfill()
|
|
})
|
|
wait(for: [restored], timeout: callbackTimeout)
|
|
XCTAssertNil(restoreResult)
|
|
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
|
|
XCTAssertEqual(deletionCalls, 2)
|
|
// Flutter awaits these replies; a lower QoS left them starved on busy hosts.
|
|
XCTAssertEqual(deletionQoS, [QOS_CLASS_USER_INITIATED, QOS_CLASS_USER_INITIATED])
|
|
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 })
|
|
var seedError: Error?
|
|
BuzzInteractionCleanupRetry(containerURL: directory, deletion: deletion).request {
|
|
seedError = $0
|
|
}
|
|
XCTAssertNotNil(seedError)
|
|
let bridge = BuzzPushSnapshotBridge(
|
|
appGroupIdentifier: nil,
|
|
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
|
|
keychainAccessGroup: nil, containerURL: { directory },
|
|
interactionDeletionDeadline: deletion
|
|
)
|
|
let completed = expectation(description: "retry failure stays allowed")
|
|
var received: Any?
|
|
XCTAssertTrue(bridge.handle(
|
|
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
|
|
) { value in
|
|
received = value
|
|
completed.fulfill()
|
|
})
|
|
wait(for: [completed], timeout: callbackTimeout)
|
|
XCTAssertEqual((received as? FlutterError)?.code, "interaction_cleanup_retry_failed")
|
|
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
final class BuzzPushNavigationBridgeTests: XCTestCase {
|
|
func testWarmAndColdNavigationCarryTheSameThreadArguments() throws {
|
|
let target = BuzzPushNavigationTarget(
|
|
eventID: String(repeating: "a", count: 64), communityID: "community",
|
|
channelID: "channel", threadRootID: String(repeating: "b", count: 64)
|
|
)
|
|
let expected = ["eventId": target.eventID, "communityId": "community",
|
|
"channelId": "channel", "threadRootId": String(repeating: "b", count: 64)]
|
|
XCTAssertEqual(target.flutterArguments, expected)
|
|
let buffer = BuzzPushNavigationBuffer()
|
|
buffer.record(target)
|
|
XCTAssertEqual(try XCTUnwrap(buffer.take()).flutterArguments, expected)
|
|
XCTAssertNil(buffer.take())
|
|
}
|
|
|
|
func testChannelNavigationOmitsOptionalThreadArgument() {
|
|
let target = BuzzPushNavigationTarget(
|
|
eventID: "event", communityID: "community", channelID: "channel"
|
|
)
|
|
XCTAssertEqual(target.flutterArguments,
|
|
["eventId": "event", "communityId": "community", "channelId": "channel"])
|
|
}
|
|
}
|