feat: integrate JPush notifications
This commit is contained in:
306
suixinkanTests/PushNotificationTests.swift
Normal file
306
suixinkanTests/PushNotificationTests.swift
Normal file
@ -0,0 +1,306 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user