对齐后端消息类型约定,去掉 route/uri 等兼容字段,并补充联调日志与单测。 Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
12 KiB
Swift
358 lines
12 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 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)
|
|
}
|
|
|
|
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
|
|
}
|