feat: integrate JPush notifications

This commit is contained in:
2026-07-20 15:56:06 +08:00
parent 573c9a42ae
commit e7f1d777dd
50 changed files with 6552 additions and 2499 deletions

View File

@ -0,0 +1,306 @@
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 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
}