Files
buzz/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift
Matt Toohey 4278e99441 fix(mobile): answer push snapshot bridge calls at user-initiated QoS
`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>
2026-11-02 15:16:54 +11:00

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"])
}
}