Files
suixinkan_uikit/suixinkanTests/PushNotificationTests.swift
汉秋 caeeb9a1cf feat: 接入消息未读数、全部已读与首页红点角标同步。
固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 18:02:43 +08:00

341 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import UIKit
import XCTest
@testable import suixinkan
/// payloadRegistration 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..<timeoutIterations {
if condition() { return }
await Task.yield()
}
XCTFail("等待异步条件超时")
}
}
/// App
@MainActor
private final class ApplicationIconBadgeSetterMock: ApplicationIconBadgeSetting {
private(set) var counts: [Int] = []
func setBadgeCount(_ count: Int) async {
counts.append(count)
}
}
/// 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
}