Files
suixinkan_uikit/suixinkanTests/PushNotificationTests.swift
T

405 lines
14 KiB
Swift

import UIKit
import XCTest
@testable import suixinkan
/// 极光推送测试,覆盖 payload、Registration ID 上报、生命周期和冷启动路由。
@MainActor
final class PushNotificationTests: XCTestCase {
private var defaults: UserDefaults!
private var appStore: AppStore!
private var suiteName: String!
override func setUp() {
super.setUp()
suiteName = "PushNotificationTests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
appStore = AppStore(defaults: defaults)
}
override func tearDown() {
if let suiteName {
defaults.removePersistentDomain(forName: suiteName)
}
defaults = nil
appStore = nil
suiteName = nil
super.tearDown()
}
func testPayloadRoutesPaymentTypesToTheirPages() {
XCTAssertEqual(
PushPayload(userInfo: ["type": 1, "route": "message_center"]).destination,
.paymentRecord
)
XCTAssertEqual(
PushPayload(userInfo: ["type": "6", "action": "task_management"]).destination,
.paymentDetails
)
}
func testPayloadIgnoresNonTypeRoutingFieldsAndOtherTypesFallBackToMessageCenter() {
let conflictingFields: [AnyHashable: Any] = [
"type": 2,
"route": "payment",
"uri": "/payment_qr",
"action": "收款",
]
[0, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 100, 999].forEach { type in
XCTAssertEqual(PushPayload(userInfo: ["type": type]).destination, .messageCenter)
}
XCTAssertEqual(PushPayload(userInfo: conflictingFields).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["route": "payment"]).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["data": ["type": 6]]).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["type": 999]).destination, .messageCenter)
}
func testPayloadReadsTypeFromNestedJSONStringWithoutUsingOtherFields() {
let nested = PushPayload(userInfo: [
"extras": #"{"type":6,"route":"message_center"}"#,
])
XCTAssertEqual(nested.destination, .paymentDetails)
}
func testAIRetouchPushRoutesToDetailAndFallsBackToList() {
XCTAssertEqual(
PushPayload(userInfo: [
"type": 14,
"data": ["ai_retouch_batch_id": 91, "status": "succeeded"],
]).destination,
.aiRetouchTaskDetail(batchId: 91)
)
XCTAssertEqual(
PushPayload(userInfo: ["type": 14, "data": [:]]).destination,
.aiRetouchTaskList
)
XCTAssertEqual(
PushPayload(userInfo: ["type": 14, "data": ["ai_retouch_batch_id": "0"]]).destination,
.aiRetouchTaskList
)
XCTAssertEqual(PushPayload(userInfo: ["type": 10]).destination, .messageCenter)
}
func testAIRetouchPushReadsEncodedDataAndVendorWrapper() {
let payload = PushPayload(userInfo: [
"n_extras": #"{"type":14,"data":"{\"ai_retouch_batch_id\":92}"}"#,
])
XCTAssertEqual(payload.destination, .aiRetouchTaskDetail(batchId: 92))
XCTAssertEqual(payload.normalizedValues["ai_retouch_batch_id"], "92")
}
func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws {
let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())])
let api = PushAPI(client: APIClient(environment: .testing, session: session))
try await api.registerJPushID("registration-id-123")
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/app/user/register-jpush-id")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "jpush_reg_id" }?.value, "registration-id-123")
}
func testPrivacyGatePreventsSDKInitialization() {
let sdk = PushSDKMock(registrationID: "reg-id")
let manager = makeManager(sdk: sdk)
manager.initializeIfPrivacyAccepted()
XCTAssertEqual(sdk.initializeCount, 0)
appStore.session.privacyAgreementAccepted = true
manager.initializeIfPrivacyAccepted()
manager.initializeIfPrivacyAccepted()
XCTAssertEqual(sdk.initializeCount, 1)
}
func testEmptyRegistrationIDDoesNotUpload() async {
authenticate(userID: "100")
let sdk = PushSDKMock(registrationID: nil)
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: sdk, api: api)
manager.handleLoginCompleted()
await Task.yield()
await Task.yield()
XCTAssertTrue(api.registrationIDs.isEmpty)
}
/// 注销状态核验期间,不因 SDK 回调或前台重试调用普通业务绑定接口。
func testDeregistrationCheckSuspendsBindingWithoutClearingSession() async {
authenticate(userID: "100")
let token = appStore.session.token
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: PushSDKMock(registrationID: "reg-id"), api: api)
manager.setAccountBindingSuspended(true)
manager.initializeIfPrivacyAccepted()
manager.handleLoginCompleted()
manager.retryPendingRegistrationUpload()
for _ in 0..<10 { await Task.yield() }
XCTAssertTrue(api.registrationIDs.isEmpty)
XCTAssertEqual(appStore.session.token, token)
manager.setAccountBindingSuspended(false)
manager.handleLoginCompleted()
await waitUntil { api.registrationIDs.count == 1 }
XCTAssertEqual(api.registrationIDs, ["reg-id"])
}
func testLoggedOutStateDoesNotUpload() async {
appStore.session.privacyAgreementAccepted = true
let sdk = PushSDKMock(registrationID: "reg-id")
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: sdk, api: api)
manager.initializeIfPrivacyAccepted()
await Task.yield()
await Task.yield()
XCTAssertTrue(api.registrationIDs.isEmpty)
}
func testLoginRequestsAuthorizationAndUploadsRegistrationIDOnce() async {
authenticate(userID: "100")
let sdk = PushSDKMock(registrationID: "reg-id")
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: sdk, api: api)
manager.handleLoginCompleted()
await waitUntil { api.registrationIDs.count == 1 }
manager.retryPendingRegistrationUpload()
await Task.yield()
XCTAssertEqual(sdk.initializeCount, 1)
XCTAssertEqual(sdk.authorizationRequestCount, 1)
XCTAssertEqual(api.registrationIDs, ["reg-id"])
}
func testAccountSwitchForcesRegistrationIDRebind() async {
authenticate(userID: "100")
let sdk = PushSDKMock(registrationID: "reg-id")
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: sdk, api: api)
manager.handleLoginCompleted()
await waitUntil { api.registrationIDs.count == 1 }
appStore.session.userId = "200"
manager.handleAccountSwitched()
await waitUntil { api.registrationIDs.count == 2 }
XCTAssertEqual(api.registrationIDs, ["reg-id", "reg-id"])
}
func testFailedRegistrationUploadRetriesOnNextActivation() async {
authenticate(userID: "100")
let sdk = PushSDKMock(registrationID: "reg-id")
let api = PushRegistrationAPIMock(errors: [PushTestError.expectedFailure, nil])
let manager = makeManager(sdk: sdk, api: api)
manager.handleLoginCompleted()
await waitUntil { api.registrationIDs.count == 1 }
await waitUntil { api.completedCallCount == 1 }
manager.retryPendingRegistrationUpload()
await waitUntil { api.registrationIDs.count == 2 }
XCTAssertEqual(api.registrationIDs, ["reg-id", "reg-id"])
}
func testNotificationTapRoutesButOrdinaryLifecycleDoesNot() {
authenticate(userID: "100")
let router = PushRouterMock()
let manager = makeManager(router: router)
manager.initializeIfPrivacyAccepted()
XCTAssertTrue(router.destinations.isEmpty)
manager.handleNotificationTap(
payload: PushPayload(userInfo: ["type": 1, "route": "task_management"]),
requestIdentifier: "request-1"
)
XCTAssertEqual(router.destinations, [.paymentRecord])
}
func testRemoteNotificationNotifiesUnreadMessageStateChange() async {
let manager = makeManager()
let unreadChanged = expectation(
forNotification: NotificationName.unreadMessageCountDidChange,
object: nil
)
manager.handleRemoteNotification(["route": "message_center"])
await fulfillment(of: [unreadChanged], timeout: 1)
}
func testApplicationIconBadgeUsesLatestNonnegativeCount() async {
let badgeSetter = ApplicationIconBadgeSetterMock()
let manager = makeManager(badgeSetter: badgeSetter)
await manager.updateApplicationIconBadgeCount(12)
await manager.updateApplicationIconBadgeCount(-2)
XCTAssertEqual(badgeSetter.counts, [12, 0])
}
func testRouteCoordinatorWaitsForLoginAndDeduplicatesColdStartResponse() {
let coordinator = PushRouteCoordinator(appStore: appStore)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UINavigationController(rootViewController: UIViewController())
coordinator.attach(window: window)
coordinator.handle(destination: .paymentDetails, requestIdentifier: "cold-request")
authenticate(userID: "100")
let mainTab = MainTabBarController()
mainTab.loadViewIfNeeded()
window.rootViewController = mainTab
coordinator.routePendingIfPossible()
let navigationController = mainTab.selectedViewController as? UINavigationController
XCTAssertTrue(navigationController?.topViewController is PaymentCollectionDetailsViewController)
coordinator.handle(destination: .messageCenter, requestIdentifier: "cold-request")
XCTAssertTrue(navigationController?.topViewController is PaymentCollectionDetailsViewController)
}
private func authenticate(userID: String) {
appStore.session.privacyAgreementAccepted = true
appStore.session.saveToken("token")
appStore.session.userId = userID
appStore.session.accountType = .storeUser
}
private func makeManager(
sdk: PushSDKMock? = nil,
api: PushRegistrationAPIMock? = nil,
router: PushRouterMock? = nil,
badgeSetter: ApplicationIconBadgeSetterMock? = nil
) -> PushNotificationManager {
let resolvedSDK: PushSDKMock
if let sdk {
resolvedSDK = sdk
} else {
resolvedSDK = PushSDKMock(registrationID: nil)
}
let resolvedAPI: PushRegistrationAPIMock
if let api {
resolvedAPI = api
} else {
resolvedAPI = PushRegistrationAPIMock()
}
let resolvedRouter: PushRouterMock
if let router {
resolvedRouter = router
} else {
resolvedRouter = PushRouterMock()
}
return PushNotificationManager(
sdk: resolvedSDK,
api: resolvedAPI,
appStore: appStore,
defaults: defaults,
router: resolvedRouter,
applicationIconBadgeSetter: badgeSetter ?? ApplicationIconBadgeSetterMock()
)
}
private func waitUntil(
timeoutIterations: Int = 100,
_ condition: () -> Bool
) async {
for _ in 0..<timeoutIterations {
if condition() { return }
await Task.yield()
}
XCTFail("等待异步条件超时")
}
}
/// 记录 App 桌面图标角标更新的测试替身。
@MainActor
private final class ApplicationIconBadgeSetterMock: ApplicationIconBadgeSetting {
private(set) var counts: [Int] = []
func setBadgeCount(_ count: Int) async {
counts.append(count)
}
}
/// 可控的极光 SDK 测试替身,记录初始化与通知授权行为。
@MainActor
private final class PushSDKMock: PushSDKProviding {
private let registrationID: String?
private(set) var initializeCount = 0
private(set) var authorizationRequestCount = 0
private(set) var deviceTokens: [Data] = []
init(registrationID: String?) {
self.registrationID = registrationID
}
func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?, isProduction: Bool) {
initializeCount += 1
}
func requestAuthorization(delegate: JPUSHRegisterDelegate) {
authorizationRequestCount += 1
}
func registerDeviceToken(_ deviceToken: Data) {
deviceTokens.append(deviceToken)
}
func fetchRegistrationID(completion: @escaping (Int32, String?) -> Void) {
completion(0, registrationID)
}
}
/// 可控制成功或失败结果的 Registration ID 上报接口测试替身。
@MainActor
private final class PushRegistrationAPIMock: PushRegistrationServing {
private var errors: [Error?]
private(set) var registrationIDs: [String] = []
private(set) var completedCallCount = 0
init(errors: [Error?] = []) {
self.errors = errors
}
func registerJPushID(_ registrationID: String) async throws {
registrationIDs.append(registrationID)
let error = errors.isEmpty ? nil : errors.removeFirst()
completedCallCount += 1
if let error { throw error }
}
}
/// 记录通知点击路由行为的测试替身。
@MainActor
private final class PushRouterMock: PushRouting {
private(set) var destinations: [PushDestination] = []
private(set) var resetCount = 0
func attach(window: UIWindow) {}
func handle(destination: PushDestination, requestIdentifier: String) {
destinations.append(destination)
}
func routePendingIfPossible() {}
func resetPendingRoute() {
resetCount += 1
}
}
/// 推送单元测试使用的可预期失败错误。
private enum PushTestError: Error {
case expectedFailure
}