mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
## Summary Fixes three mobile flakes from the 21–28 Oct 2026 CI runs, in test code only: - In two iOS `RunnerTests` classes, late bridge callbacks no longer fail whichever test is running when they fire. - The waits on a busy test-host main thread now cap at 10 s. - Two Flutter avatar tests poll for the ready frame instead of sleeping a fixed 50 ms. This is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | `BuzzPushSnapshotEnrichmentTests` (RunnerTests) | 40362671989 (job 121712747802, main), 39681273328#1 (job 119504560518), 40658588384 (job 122715950541, PR #8640) | The 1 s waits ran out while the test host's Flutter app held the main thread, and the test returned and deleted its temp directory. The bridge callback's assertions then ran later and were blamed on whichever test was running (`ProfileSetupRecoveryTests`, `IdentityFilePluginTests`). | Callbacks only record the value and fulfill. Every assertion, including the `isRestricted` checks, runs after the wait, so a late callback writes into a dead local. The seven waits cap at 10 s (`callbackTimeout`). | | `ProfileSetupRecoveryTests` `testStartedEditorWithoutReadyRestoresProfileAndAllowsRetry` | 40585805357 (job 122477274956, PR #8568) | Engine release and editor dismissal must both happen within 2 s, on the main thread the host app keeps busy. | Both predicate waits cap at 10 s. | | Flutter `channel_detail_page_test.dart` "profile sheet shows the poster before autoplay and restores it on tap" and `settings_profile_header_test.dart` "shows the poster until the animated avatar is ready" | 40368315739 (job 121732711851, main) | After the image response is released, both tests slept a fixed 50 ms of real time before `pumpAndSettle`. A slower real decode left no frame scheduled, so the ready key was missing. | A new `WidgetHelpers.pumpUntilFound` pumps in 10 ms real-time steps until the ready key appears, capped at about 5 s. | Local evidence is from the original `flakey-tests` commit (53aaec97c), Xcode 27.1 on the iOS 26.2 simulator: - Enrichment, with `Self.complete` delayed 1.5 s and 4 s: 6 failures before, 107/107 after. With a 12 s delay, every failure lands in its owning test. A mutated error code is still caught. - Profile setup, with the engine release held 4 s: 2 failures before, 4/4 after. A leaked engine still fails. - Avatar tests: under load, 94/96 before and 96/96 after. With a 200 ms image delay, 0/5 before and 5/5 after. A widget that never signals ready still fails. Not included: - The planned `AppDelegate` / `Runner.xcscheme` change that keeps Flutter out of the RunnerTests host. It did not work locally: restored scene sessions and the `UIMainStoryboardFile` fallback, and a normal launch came up black afterwards. - `IdentityFilePluginTests` `testSyntheticDocumentsReadInPlaceAndRejectInvalidOrOversize` (40441255307#1). It is left unchanged and stays open. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs the Flutter tests. - Every file is byte-identical to its final version on `flakey-tests` (67185df6a). - Checked for this PR: `git diff --check`, and a conflict-free merge with all six siblings onto `main`. - #8613's flake-check lane will repeat the two changed Dart test files. ### Readiness Test-only change. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order Independent of the other six. It merges cleanly onto `main`, and with every sibling, in any order. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
572 lines
20 KiB
Swift
572 lines
20 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
|
|
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")
|
|
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)
|
|
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 testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable() {
|
|
let bridge = BuzzPushSnapshotBridge(
|
|
appGroupIdentifier: nil,
|
|
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
|
|
keychainAccessGroup: nil
|
|
)
|
|
let completed = expectation(description: "strict write rejected")
|
|
var received: Any?
|
|
|
|
XCTAssertTrue(
|
|
bridge.handle(
|
|
FlutterMethodCall(
|
|
methodName: "syncAgeGatePushSnapshot",
|
|
arguments: [
|
|
"section": "communities",
|
|
"communities": [[String: Any]](),
|
|
"signingKeys": [String: String](),
|
|
"settleFence": false,
|
|
]
|
|
)
|
|
) { value in
|
|
received = value
|
|
completed.fulfill()
|
|
}
|
|
)
|
|
|
|
wait(for: [completed], timeout: callbackTimeout)
|
|
XCTAssertEqual((received as? FlutterError)?.code, "snapshot_sync_unavailable")
|
|
}
|
|
|
|
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"])
|
|
}
|
|
}
|