Add CYLTabBarController with a global scan entry, promote MainTabBarController to the window root after login, replace RouterPath-based navigation with AppNavigator, and fix Profile diffable cell reconfiguration crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
6.9 KiB
Swift
182 lines
6.9 KiB
Swift
//
|
||
// PushNotificationTests.swift
|
||
// suixinkanTests
|
||
//
|
||
// Created by Codex on 2026/6/26.
|
||
//
|
||
|
||
import Foundation
|
||
import XCTest
|
||
@testable import suixinkan_ios
|
||
|
||
/// 推送链路测试,覆盖 APNs token、payload 解析、token 上报和通知路由。
|
||
@MainActor
|
||
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 services = AppServices(apiClient: APIClient(session: PushRecordingURLSession()))
|
||
let host = PushNavigationHost(services: services)
|
||
let manager = pushManager(navigator: services.appNavigator)
|
||
services.appNavigator.attach(host: host)
|
||
|
||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "payment_qr"]))
|
||
XCTAssertEqual(host.selectedTab, .home)
|
||
XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is PaymentCollectionViewController)
|
||
|
||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "task_management"]))
|
||
XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is TaskManagementViewController)
|
||
XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 2)
|
||
|
||
manager.handleRemoteNotification(PushPayload(userInfo: ["uri": "/scenic-queue"]))
|
||
XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is QueueManagementViewController)
|
||
XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 2)
|
||
|
||
manager.handleRemoteNotification(PushPayload(userInfo: ["title": "未知通知"]))
|
||
XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is MessageCenterViewController)
|
||
XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 2)
|
||
}
|
||
|
||
func testPushManagerRoutesOrdersEntry() {
|
||
let services = AppServices(apiClient: APIClient(session: PushRecordingURLSession()))
|
||
let host = PushNavigationHost(services: services)
|
||
let manager = pushManager(navigator: services.appNavigator)
|
||
services.appNavigator.attach(host: host)
|
||
|
||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "photographer_orders"]))
|
||
XCTAssertEqual(host.selectedTab, .orders)
|
||
XCTAssertEqual(host.appliedOrdersEntry, .storeOrders)
|
||
|
||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
|
||
XCTAssertEqual(host.selectedTab, .orders)
|
||
XCTAssertEqual(host.appliedOrdersEntry, .verificationOrders)
|
||
}
|
||
|
||
private func pushManager(navigator: AppNavigator) -> PushNotificationManager {
|
||
let session = AppSession()
|
||
let manager = PushNotificationManager()
|
||
manager.configure(
|
||
api: PushAPI(client: APIClient(session: PushRecordingURLSession())),
|
||
session: session,
|
||
navigator: navigator
|
||
)
|
||
return manager
|
||
}
|
||
}
|
||
|
||
/// 推送路由单测专用导航宿主。
|
||
@MainActor
|
||
private final class PushNavigationHost: AppNavigationHost {
|
||
private var controllers: [AppTab: TabNavigationController] = [:]
|
||
var selectedTab: AppTab = .home
|
||
var appliedOrdersEntry: OrdersEntry?
|
||
|
||
/// 初始化所有 Tab 导航栈。
|
||
init(services: AppServices) {
|
||
for tab in AppTab.allCases {
|
||
controllers[tab] = TabNavigationController(tab: tab, services: services)
|
||
}
|
||
}
|
||
|
||
/// 选中指定主 Tab。
|
||
func selectTab(_ tab: AppTab) {
|
||
selectedTab = tab
|
||
}
|
||
|
||
/// 返回指定 Tab 的导航控制器。
|
||
func navigationController(for tab: AppTab) -> TabNavigationController? {
|
||
controllers[tab]
|
||
}
|
||
|
||
/// 返回当前选中 Tab 的导航控制器。
|
||
func selectedNavigationController() -> TabNavigationController? {
|
||
controllers[selectedTab]
|
||
}
|
||
|
||
/// 重置全部 Tab 导航栈。
|
||
func resetAllStacks() {
|
||
for controller in controllers.values {
|
||
controller.popToRootViewController(animated: false)
|
||
}
|
||
selectedTab = .home
|
||
}
|
||
|
||
/// 记录订单入口状态。
|
||
func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) {
|
||
appliedOrdersEntry = entry
|
||
}
|
||
}
|
||
|
||
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)
|
||
})
|
||
}
|