diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 92ad43b..ff3575c 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -35,9 +35,22 @@ EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkanTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A00000102FECC00000000010 /* Exceptions for "suixinkan" folder in "suixinkan" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 939AC7952FE3F832004B22E4 /* suixinkan */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 939AC7982FE3F832004B22E4 /* suixinkan */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A00000102FECC00000000010 /* Exceptions for "suixinkan" folder in "suixinkan" target */, + ); path = suixinkan; sourceTree = ""; }; @@ -445,22 +458,15 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览"; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置"; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "用于直播推流时采集现场声音"; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = suixinkan/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -490,22 +496,15 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览"; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置"; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "用于直播推流时采集现场声音"; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = suixinkan/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/suixinkan/App/AppDelegate.swift b/suixinkan/App/AppDelegate.swift new file mode 100644 index 0000000..64ef336 --- /dev/null +++ b/suixinkan/App/AppDelegate.swift @@ -0,0 +1,41 @@ +// +// AppDelegate.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// AppDelegate 承接 APNs 系统回调,SwiftUI App 入口通过 adaptor 注入。 +final class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Task { @MainActor in + PushNotificationManager.shared.handleDeviceToken(deviceToken) + } + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + Task { @MainActor in + PushNotificationManager.shared.handleRegistrationError(error) + } + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + let payload = PushPayload(userInfo: userInfo) + Task { @MainActor in + PushNotificationManager.shared.handleRemoteNotification(payload) + completionHandler(.newData) + } + } +} diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index e46d6c2..030f916 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -28,6 +28,7 @@ struct RootView: View { @State private var statisticsAPI: StatisticsAPI @State private var paymentAPI: PaymentAPI @State private var walletAPI: WalletAPI + @State private var pushAPI: PushAPI @State private var scenicPermissionAPI: ScenicPermissionAPI @State private var scenicSettlementAPI: ScenicSettlementAPI @State private var messageCenterAPI: MessageCenterAPI @@ -63,6 +64,7 @@ struct RootView: View { _statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient)) _paymentAPI = State(initialValue: PaymentAPI(client: apiClient)) _walletAPI = State(initialValue: WalletAPI(client: apiClient)) + _pushAPI = State(initialValue: PushAPI(client: apiClient)) _scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient)) _scenicSettlementAPI = State(initialValue: ScenicSettlementAPI(client: apiClient)) _messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient)) @@ -114,6 +116,7 @@ struct RootView: View { .environment(statisticsAPI) .environment(paymentAPI) .environment(walletAPI) + .environment(pushAPI) .environment(scenicPermissionAPI) .environment(scenicSettlementAPI) .environment(messageCenterAPI) @@ -132,6 +135,7 @@ struct RootView: View { .environment(authSessionCoordinator) .task { apiClient.bindAuthTokenProvider { appSession.token } + PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter) await globalLoading.withLoading { await sessionBootstrapper.restore( appSession: appSession, @@ -142,6 +146,9 @@ struct RootView: View { toastCenter: toastCenter ) } + if appSession.isLoggedIn { + PushNotificationManager.shared.requestAuthorizationAndRegister() + } } .task(id: scenicSpotTaskID) { guard appSession.isLoggedIn else { @@ -176,12 +183,18 @@ struct RootView: View { } } .onChange(of: appSession.phase) { _, phase in - guard phase == .loggedOut else { return } - accountContext.reset() - permissionContext.reset() - scenicSpotContext.reset() - appRouter.reset() - toastCenter.dismiss() + switch phase { + case .loggedIn: + PushNotificationManager.shared.requestAuthorizationAndRegister() + case .loggedOut: + accountContext.reset() + permissionContext.reset() + scenicSpotContext.reset() + appRouter.reset() + toastCenter.dismiss() + case .restoring: + break + } } } diff --git a/suixinkan/App/suixinkanApp.swift b/suixinkan/App/suixinkanApp.swift index a18e9a5..22dccaf 100644 --- a/suixinkan/App/suixinkanApp.swift +++ b/suixinkan/App/suixinkanApp.swift @@ -10,6 +10,8 @@ import SwiftUI /// 应用入口实体,负责把 SwiftUI 根视图挂载到主窗口。 @main struct suixinkanApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + var body: some Scene { WindowGroup { RootView() diff --git a/suixinkan/Core/Push/PushAPI.swift b/suixinkan/Core/Push/PushAPI.swift new file mode 100644 index 0000000..c8dcc69 --- /dev/null +++ b/suixinkan/Core/Push/PushAPI.swift @@ -0,0 +1,32 @@ +// +// PushAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 推送 API,负责把 iOS APNs token 上报到当前后端兼容接口。 +final class PushAPI { + @ObservationIgnored private let client: APIClient + + /// 初始化推送 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 上报 APNs device token。后端当前沿用 Android/JPush 字段名。 + func registerJPushId(_ registrationId: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/user/register-jpush-id", + queryItems: [URLQueryItem(name: "jpush_reg_id", value: registrationId)] + ) + ) + } +} diff --git a/suixinkan/Core/Push/PushNotificationManager.swift b/suixinkan/Core/Push/PushNotificationManager.swift new file mode 100644 index 0000000..946012a --- /dev/null +++ b/suixinkan/Core/Push/PushNotificationManager.swift @@ -0,0 +1,150 @@ +// +// PushNotificationManager.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit +import UserNotifications + +@MainActor +/// 推送生命周期管理器,负责权限、APNs 注册、token 上报和通知点击路由。 +final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate { + static let shared = PushNotificationManager() + + private var api: PushAPI? + private weak var session: AppSession? + private weak var router: AppRouter? + private var latestDeviceToken: String? + private var latestAuthorizationStatus: UNAuthorizationStatus = .notDetermined + private let tokenDefaultsKey = "apns_device_token" + private let uploadedTokenDefaultsKey = "apns_uploaded_token" + + override init() { + super.init() + } + + /// 绑定推送所需的网络、登录和导航依赖。 + func configure(api: PushAPI, session: AppSession, router: AppRouter) { + self.api = api + self.session = session + self.router = router + UNUserNotificationCenter.current().delegate = self + latestDeviceToken = UserDefaults.standard.string(forKey: tokenDefaultsKey) + } + + /// 请求通知权限并注册 APNs;已授权时会直接注册并尝试补传缓存 token。 + func requestAuthorizationAndRegister() { + Task { @MainActor in + let settings = await UNUserNotificationCenter.current().notificationSettings() + latestAuthorizationStatus = settings.authorizationStatus + switch settings.authorizationStatus { + case .notDetermined: + let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false + guard granted else { return } + UIApplication.shared.registerForRemoteNotifications() + case .authorized, .provisional, .ephemeral: + UIApplication.shared.registerForRemoteNotifications() + uploadPendingTokenIfPossible() + case .denied: + break + @unknown default: + break + } + } + } + + /// 处理系统返回的 APNs token。 + func handleDeviceToken(_ deviceToken: Data) { + let token = APNsDeviceToken.hexString(from: deviceToken) + latestDeviceToken = token + UserDefaults.standard.set(token, forKey: tokenDefaultsKey) + uploadPendingTokenIfPossible() + } + + /// 处理 APNs 注册失败。失败不阻断登录和主流程。 + func handleRegistrationError(_ error: Error) { + #if DEBUG + print("APNs registration failed: \(error.localizedDescription)") + #endif + } + + /// 登录后补传尚未成功上报的 token。 + func uploadPendingTokenIfPossible() { + guard let api, session?.isLoggedIn == true else { return } + guard let token = latestDeviceToken, !token.isEmpty else { return } + let uploaded = UserDefaults.standard.string(forKey: uploadedTokenDefaultsKey) + guard uploaded != token else { return } + + Task { + do { + try await api.registerJPushId(token) + UserDefaults.standard.set(token, forKey: uploadedTokenDefaultsKey) + } catch { + #if DEBUG + print("Push token upload failed: \(error.localizedDescription)") + #endif + } + } + } + + /// 处理静默或远程通知 userInfo。 + func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) { + route(from: PushPayload(userInfo: userInfo)) + } + + /// 处理已解析的推送 payload。供系统回调和单测复用。 + func handleRemoteNotification(_ payload: PushPayload) { + route(from: payload) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let payload = PushPayload(userInfo: notification.request.content.userInfo) + Task { @MainActor in + handleRemoteNotification(payload) + } + completionHandler([.banner, .list, .sound, .badge]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let payload = PushPayload(userInfo: response.notification.request.content.userInfo) + Task { @MainActor in + handleRemoteNotification(payload) + completionHandler() + } + } + + private func route(from payload: PushPayload) { + guard let router else { return } + + switch payload.route { + case .payment: + navigateHomeRoute(.paymentCollection) + case .order: + router.selectOrders(entry: .storeOrders) + case .verificationOrder: + router.selectOrders(entry: .verificationOrders) + case .task: + navigateHomeRoute(.taskManagement) + case .queue: + navigateHomeRoute(.queueManagement) + case .messageCenter: + navigateHomeRoute(.messageCenter) + } + } + + private func navigateHomeRoute(_ route: HomeRoute) { + guard let router else { return } + router.select(.home) + router.router(for: .home).navigate(to: .home(route)) + } +} diff --git a/suixinkan/Core/Push/PushPayload.swift b/suixinkan/Core/Push/PushPayload.swift new file mode 100644 index 0000000..64554e8 --- /dev/null +++ b/suixinkan/Core/Push/PushPayload.swift @@ -0,0 +1,99 @@ +// +// PushPayload.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import Foundation + +/// APNs token 工具,把系统返回的 Data 转成后端可接收的小写十六进制字符串。 +enum APNsDeviceToken { + static func hexString(from data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } +} + +/// 推送 payload 解析器,兼容顶层字段和第三方通道常见 extras/data 嵌套结构。 +struct PushPayload: Sendable { + enum Route: Sendable, Equatable { + case payment + case order + case verificationOrder + case task + case queue + case messageCenter + } + + private let values: [String: String] + + nonisolated init(userInfo: [AnyHashable: Any]) { + var result: [String: String] = [:] + for (key, value) in userInfo { + guard let key = key as? String else { continue } + result[key] = Self.stringValue(value) + if key == "extras" || key == "extra" || key == "data" || key == "JMessageExtra" { + Self.mergeJSON(value, into: &result) + } + } + values = result + } + + nonisolated var route: Route { + let typeText = values["type"] ?? "" + let routeText = values["route"] ?? "" + let uriText = values["uri"] ?? "" + let actionText = values["action"] ?? "" + let merged = [typeText, routeText, uriText, actionText].joined(separator: " ").lowercased() + + if typeText == "1" || merged.contains("payment") || merged.contains("pay") || merged.contains("收款") { + return .payment + } + if merged.contains("queue") || merged.contains("排队") || merged.contains("叫号") || uriText == "/scenic-queue" { + return .queue + } + if merged.contains("verification") || merged.contains("writeoff") || merged.contains("核销") { + return .verificationOrder + } + if merged.contains("order") || merged.contains("订单") { + return .order + } + if merged.contains("task") || merged.contains("任务") { + return .task + } + return .messageCenter + } + + nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) { + if let dict = value as? [String: Any] { + for (key, value) in dict { + result[key] = stringValue(value) + mergeJSON(value, into: &result) + } + return + } + + guard let text = value as? String, + let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return } + + for (key, value) in object { + result[key] = stringValue(value) + mergeJSON(value, into: &result) + } + } + + nonisolated private static func stringValue(_ value: Any?) -> String { + switch value { + case let string as String: + return string + case let number as NSNumber: + return number.stringValue + case .some(let value): + return "\(value)" + case nil: + return "" + } + } +} diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist new file mode 100644 index 0000000..6edd58f --- /dev/null +++ b/suixinkan/Info.plist @@ -0,0 +1,58 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSCameraUsageDescription + 用于扫码核销订单和直播推流预览 + NSLocationWhenInUseUsageDescription + 用于景区选择、打卡点选点和位置上报时获取当前位置 + NSMicrophoneUsageDescription + 用于直播推流时采集现场声音 + NSPhotoLibraryAddUsageDescription + 用于保存收款二维码到相册 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + remote-notification + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + + diff --git a/suixinkan/suixinkan.entitlements b/suixinkan/suixinkan.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/suixinkan/suixinkan.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/suixinkanTests/PushNotificationTests.swift b/suixinkanTests/PushNotificationTests.swift new file mode 100644 index 0000000..f699b2a --- /dev/null +++ b/suixinkanTests/PushNotificationTests.swift @@ -0,0 +1,133 @@ +// +// PushNotificationTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/26. +// + +import Foundation +import XCTest +@testable import suixinkan + +/// 推送链路测试,覆盖 APNs token、payload 解析、token 上报和通知路由。 +final class PushNotificationTests: XCTestCase { + func testAPNsDeviceTokenUsesLowercaseHexString() { + let token = APNsDeviceToken.hexString(from: Data([0x00, 0x0f, 0x10, 0xab, 0xff])) + + XCTAssertEqual(token, "000f10abff") + } + + func testPushPayloadRoutesTopLevelFields() { + XCTAssertEqual(PushPayload(userInfo: ["type": "1"]).route, .payment) + XCTAssertEqual(PushPayload(userInfo: ["route": "verification_order"]).route, .verificationOrder) + XCTAssertEqual(PushPayload(userInfo: ["uri": "/scenic-queue"]).route, .queue) + XCTAssertEqual(PushPayload(userInfo: ["action": "task_management"]).route, .task) + XCTAssertEqual(PushPayload(userInfo: ["route": "photographer_orders"]).route, .order) + } + + func testPushPayloadRoutesNestedJSONString() { + let payload = PushPayload(userInfo: [ + "extras": #"{"data":{"uri":"message_center","title":"系统消息"}}"# + ]) + + XCTAssertEqual(payload.route, .messageCenter) + } + + func testPushPayloadRoutesNestedDictionaryAndChineseKeywords() { + let payload = PushPayload(userInfo: [ + "data": [ + "extra": [ + "action": "叫号提醒" + ] + ] + ]) + + XCTAssertEqual(payload.route, .queue) + } + + func testPushPayloadFallsBackToMessageCenter() { + let payload = PushPayload(userInfo: ["title": "系统消息"]) + + XCTAssertEqual(payload.route, .messageCenter) + } + + func testRegisterPushTokenUsesCurrentBackendEndpointAndLegacyQueryName() async throws { + let session = PushRecordingURLSession() + let api = PushAPI(client: APIClient(session: session)) + + try await api.registerJPushId("000f10abff") + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/app/user/register-jpush-id") + XCTAssertEqual(queryItems(from: request)["jpush_reg_id"], "000f10abff") + } + + func testPushManagerRoutesHomeDestinationsThroughHomeNavigationStack() { + let router = AppRouter() + let manager = pushManager(router: router) + + manager.handleRemoteNotification(PushPayload(userInfo: ["route": "payment_qr"])) + XCTAssertEqual(router.selectedTab, .home) + XCTAssertEqual(router.router(for: .home).path, [.home(.paymentCollection)]) + + router.router(for: .home).reset() + manager.handleRemoteNotification(PushPayload(userInfo: ["route": "task_management"])) + XCTAssertEqual(router.router(for: .home).path, [.home(.taskManagement)]) + + router.router(for: .home).reset() + manager.handleRemoteNotification(PushPayload(userInfo: ["uri": "/scenic-queue"])) + XCTAssertEqual(router.router(for: .home).path, [.home(.queueManagement)]) + + router.router(for: .home).reset() + manager.handleRemoteNotification(PushPayload(userInfo: ["title": "未知通知"])) + XCTAssertEqual(router.router(for: .home).path, [.home(.messageCenter)]) + } + + func testPushManagerRoutesOrdersEntry() { + let router = AppRouter() + let manager = pushManager(router: router) + + manager.handleRemoteNotification(PushPayload(userInfo: ["route": "photographer_orders"])) + XCTAssertEqual(router.selectedTab, .orders) + XCTAssertEqual(router.selectedOrdersEntry, .storeOrders) + + manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"])) + XCTAssertEqual(router.selectedTab, .orders) + XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders) + } + + private func pushManager(router: AppRouter) -> PushNotificationManager { + let session = AppSession() + let manager = PushNotificationManager() + manager.configure( + api: PushAPI(client: APIClient(session: PushRecordingURLSession())), + session: session, + router: router + ) + return manager + } +} + +private final class PushRecordingURLSession: URLSessionProtocol { + private(set) var requests: [URLRequest] = [] + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)! + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response) + } +} + +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + guard let value = item.value else { return nil } + return (item.name, value) + }) +}