307 lines
10 KiB
Swift
307 lines
10 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 testPayloadRoutesTopLevelAndKnownDestinations() {
|
||
XCTAssertEqual(PushPayload(userInfo: ["type": "1"]).destination, .payment)
|
||
XCTAssertEqual(PushPayload(userInfo: ["route": "photographer_orders"]).destination, .orders)
|
||
XCTAssertEqual(PushPayload(userInfo: ["route": "verification_order"]).destination, .verificationOrders)
|
||
XCTAssertEqual(PushPayload(userInfo: ["action": "task_management"]).destination, .task)
|
||
XCTAssertEqual(PushPayload(userInfo: ["uri": "/scenic-queue"]).destination, .queue)
|
||
XCTAssertEqual(PushPayload(userInfo: ["route": "message_center"]).destination, .messageCenter)
|
||
}
|
||
|
||
func testPayloadRoutesNestedJSONStringAndUnknownFallsBackToMessageCenter() {
|
||
let nested = PushPayload(userInfo: [
|
||
"extras": #"{"data":{"action":"叫号提醒"}}"#,
|
||
])
|
||
|
||
XCTAssertEqual(nested.destination, .queue)
|
||
XCTAssertEqual(PushPayload(userInfo: ["title": "未知消息"]).destination, .messageCenter)
|
||
}
|
||
|
||
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: ["route": "task_management"]),
|
||
requestIdentifier: "request-1"
|
||
)
|
||
XCTAssertEqual(router.destinations, [.task])
|
||
}
|
||
|
||
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: .queue, requestIdentifier: "cold-request")
|
||
authenticate(userID: "100")
|
||
appStore.session.currentScenicId = 9
|
||
let mainTab = MainTabBarController()
|
||
mainTab.loadViewIfNeeded()
|
||
window.rootViewController = mainTab
|
||
coordinator.routePendingIfPossible()
|
||
|
||
let navigationController = mainTab.selectedViewController as? UINavigationController
|
||
XCTAssertTrue(navigationController?.topViewController is ScenicQueueViewController)
|
||
|
||
coordinator.handle(destination: .messageCenter, requestIdentifier: "cold-request")
|
||
XCTAssertTrue(navigationController?.topViewController is ScenicQueueViewController)
|
||
}
|
||
|
||
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
|
||
) -> 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
|
||
)
|
||
}
|
||
|
||
private func waitUntil(
|
||
timeoutIterations: Int = 100,
|
||
_ condition: () -> Bool
|
||
) async {
|
||
for _ in 0..<timeoutIterations {
|
||
if condition() { return }
|
||
await Task.yield()
|
||
}
|
||
XCTFail("等待异步条件超时")
|
||
}
|
||
}
|
||
|
||
/// 可控的极光 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
|
||
}
|