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 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: .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, 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.. 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 }