import UIKit import XCTest @testable import suixinkan /// 用独立窗口和真实核验控制器验证前台、多端及过期响应;不访问设备真实会话。 @MainActor final class StoreAccountDeregistrationRootTests: XCTestCase { /// 重放真实userinfo限制及status响应,贯通业务API、错误通知、状态核验和根页面,不重新登录或撤销。 func testObservedBusinessRestrictionRoutesToCoolingWithoutLosingSession() async throws { let network = MockURLSession(responses: [ StoreAccountDeregistrationFixtures.observedCoolingRestrictionEnvelope, StoreAccountDeregistrationFixtures.observedCoolingStatusEnvelope, ]) let center = NotificationCenter() let client = APIClient(environment: .testing, session: network, notificationCenter: center) let context = DeregistrationRootContext(networkClient: client) defer { context.cleanUp() } client.bindAuthTokenProvider { [weak context] in context?.session.token } let originalToken = context.session.token let originalUserID = context.session.userId let expired = XCTNSNotificationExpectation(name: NotificationName.sessionDidExpire, object: nil, notificationCenter: center) expired.isInverted = true let observer = center.addObserver(forName: NotificationName.storeAccountDeregistrationRestricted, object: nil, queue: .main) { [weak coordinator = context.coordinator] notification in let token = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String Task { @MainActor [weak coordinator] in coordinator?.recordRestriction(requestToken: token) } } defer { center.removeObserver(observer) } context.startRestoringSession() let businessRoot = context.window.rootViewController do { _ = try await ProfileAPI(client: client).userInfo() XCTFail("冷静期不能取得普通业务数据") } catch { guard case APIError.serverCode(150015, let message) = error else { return XCTFail("真实错误data结构不能被误判为UserInfo解码失败:\(error)") } XCTAssertEqual(message, "账号处于注销冷静期,请先撤销注销后再继续使用") XCTAssertFalse(APIError.isAuthenticationExpired(error)) } await waitUntil { context.message.contains("2026-09-04 15:27:24") } XCTAssertNotNil(context.coordinator.accessController) XCTAssertFalse(context.window.rootViewController === businessRoot) XCTAssertTrue(context.message.contains("处于冷静期")) XCTAssertFalse(context.message.contains("604681")) XCTAssertTrue(context.bindingSuspended) XCTAssertEqual(context.resumeCount, 1) XCTAssertEqual(context.createdRoots, 1) XCTAssertEqual(context.session.token, originalToken) XCTAssertEqual(context.session.userId, originalUserID) XCTAssertTrue(context.session.isLoggedIn) XCTAssertEqual(network.requests.map { $0.url?.path }, [ "/api/yf-handset-app/userinfo", "/api/yf-handset-app/account-deregister/status", ]) XCTAssertEqual(network.requests.map(\.httpMethod), ["GET", "GET"]) XCTAssertTrue(network.requests.allSatisfy { $0.value(forHTTPHeaderField: "token") == originalToken }) XCTAssertTrue(context.service.mutations.isEmpty) await fulfillment(of: [expired], timeout: 0.1) } /// 已登录冷启动和普通前台恢复均不查注销状态,直接保留正常导航与推送。 func testStartupAndForegroundDoNotQueryAndPreserveNavigation() throws { let context = DeregistrationRootContext() defer { context.cleanUp() } context.service.readError = APIError.networkFailed("注销查询离线不应影响普通启动") context.startRestoringSession() let original = try XCTUnwrap(context.window.rootViewController as? UINavigationController) let details = UIViewController() original.pushViewController(details, animated: false) context.coordinator.resumeFromBackground() context.coordinator.resumeFromBackground() XCTAssertNil(context.coordinator.accessController) XCTAssertTrue(context.window.rootViewController === original) XCTAssertTrue(original.topViewController === details) XCTAssertEqual(context.createdRoots, 1) XCTAssertFalse(context.bindingSuspended) XCTAssertEqual(context.service.statusCount, 0) XCTAssertEqual(context.createdAPIs, 0) XCTAssertEqual(context.resumeCount, 1) } /// 新登录得到身份凭证后仍先核验,普通页面前台恢复不追加查询。 func testLoginChecksStatusBeforeEnteringBusiness() async { let context = DeregistrationRootContext() defer { context.cleanUp() } context.service.holdNextRead = true await context.startChecking() let loginRoot = context.window.rootViewController await waitUntil { context.service.pendingRead != nil } XCTAssertNil(context.coordinator.accessController) XCTAssertTrue(context.window.rootViewController === loginRoot) XCTAssertTrue(context.coordinator.isChecking) XCTAssertTrue(GlobalLoadingManager.shared.isShowing) XCTAssertFalse(context.message.contains("正在查询注销状态")) XCTAssertTrue(context.bindingSuspended) XCTAssertEqual(context.createdRoots, 0) context.service.finishRead() await waitUntil { context.resumeCount == 1 } context.coordinator.resumeFromBackground() XCTAssertNil(context.coordinator.accessController) XCTAssertFalse(context.bindingSuspended) XCTAssertEqual(context.service.statusCount, 1) XCTAssertFalse(GlobalLoadingManager.shared.isShowing) } /// 登录查询失败后才显示错误结果,不重复查询;下拉刷新恢复后结束全局加载。 func testLoginFailureAndPullToRefreshUseGlobalLoading() async throws { let context = DeregistrationRootContext() defer { context.cleanUp() } context.service.readError = APIError.networkFailed("offline") await context.startChecking() await waitUntil { context.retryEnabled } let gate = try XCTUnwrap(context.coordinator.accessController) XCTAssertTrue(context.message.contains("暂时无法查询")) XCTAssertNil(gate.navigationItem.rightBarButtonItem) XCTAssertEqual(context.service.statusCount, 1) XCTAssertFalse(GlobalLoadingManager.shared.isShowing) let scroll = try XCTUnwrap(gate.view.subviews.first { $0 is UIScrollView } as? UIScrollView) let refresh = try XCTUnwrap(scroll.refreshControl) context.service.readError = nil context.service.holdNextRead = true refresh.beginRefreshing() refresh.sendActions(for: .valueChanged) await waitUntil { context.service.pendingRead != nil } XCTAssertTrue(GlobalLoadingManager.shared.isShowing) refresh.sendActions(for: .valueChanged) XCTAssertEqual(context.service.statusCount, 2) context.service.finishRead() await waitUntil { context.resumeCount == 1 } XCTAssertFalse(refresh.isRefreshing) XCTAssertFalse(GlobalLoadingManager.shared.isShowing) XCTAssertTrue(context.service.mutations.isEmpty) } /// 离开登录核验立即释放其加载,旧响应不能关闭其他请求的加载或恢复首页。 func testCancelledLoginCheckDoesNotHideNewLoadingOrReplaceRoot() async throws { let context = DeregistrationRootContext() defer { context.cleanUp() } context.service.holdNextRead = true await context.startChecking() await waitUntil { context.service.pendingRead != nil } context.coordinator.check() XCTAssertEqual(context.service.statusCount, 1, "同一登录核验不能重复发起") context.coordinator.cancelPendingCheck() XCTAssertFalse(GlobalLoadingManager.shared.isShowing) let newRoot = UIViewController() context.window.rootViewController = newRoot GlobalLoadingManager.shared.show() defer { GlobalLoadingManager.shared.hide() } context.service.finishRead() try await Task.sleep(nanoseconds: 30_000_000) XCTAssertTrue(GlobalLoadingManager.shared.isShowing) XCTAssertTrue(context.window.rootViewController === newRoot) XCTAssertEqual(context.resumeCount, 0) } /// 业务限制才触发冷静期查询;受限页前台刷新可确认其他设备撤销,不发送修改请求。 func testRemoteApplicationAndCancellation() async throws { let context = DeregistrationRootContext() defer { context.cleanUp() } await context.startChecking() await waitUntil { context.resumeCount == 1 } let original = context.window.rootViewController context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus() context.coordinator.recordRestriction(requestToken: context.session.token) await waitUntil { context.message.contains("处于冷静期") } XCTAssertTrue(context.bindingSuspended) XCTAssertEqual(context.resumeCount, 1) let gate = context.coordinator.accessController context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true) context.coordinator.resumeFromBackground() XCTAssertTrue(context.coordinator.accessController === gate) await waitUntil { context.resumeCount == 2 } XCTAssertFalse(context.window.rootViewController === original) XCTAssertEqual(context.createdRoots, 2) XCTAssertFalse(context.bindingSuspended) XCTAssertTrue(context.service.mutations.isEmpty) } /// 业务限制后离线保持核验页,受限页返回前台成功重查后才恢复。 func testRestrictionQueryFailureRemainsRestricted() async { let context = DeregistrationRootContext() defer { context.cleanUp() } await context.startChecking() await waitUntil { context.resumeCount == 1 } context.service.readError = APIError.networkFailed("offline") context.coordinator.recordRestriction(requestToken: context.session.token) await waitUntil { context.message.contains("暂时无法查询") } XCTAssertNotNil(context.coordinator.accessController) XCTAssertTrue(context.bindingSuspended) XCTAssertEqual(context.resumeCount, 1) context.service.readError = nil context.coordinator.resumeFromBackground() await waitUntil { context.resumeCount == 2 } XCTAssertNil(context.coordinator.accessController) } /// 新150015优先于旧正常响应,其他身份Token的通知不应影响当前页面。 func testRestrictionOverridesOldResponseAndIgnoresOtherToken() async { let context = DeregistrationRootContext() defer { context.cleanUp() } await context.startChecking() await waitUntil { context.resumeCount == 1 } let original = context.window.rootViewController context.coordinator.recordRestriction(requestToken: "other-token") XCTAssertTrue(context.window.rootViewController === original) context.service.holdNextRead = true context.coordinator.check() await waitUntil { context.service.pendingRead != nil } context.coordinator.recordRestriction(requestToken: context.session.token) context.service.finishRead() await waitUntil { context.retryEnabled } XCTAssertNotNil(context.coordinator.accessController) XCTAssertTrue(context.bindingSuspended) XCTAssertEqual(context.resumeCount, 1) } /// 在途查询回到前台后作废,完成后只再查一次最新状态,不重发修改操作。 func testForegroundDiscardsBackgroundResponse() async throws { let context = DeregistrationRootContext() defer { context.cleanUp() } context.service.holdNextRead = true await context.startChecking() await waitUntil { context.service.pendingRead != nil } let loginRoot = context.window.rootViewController context.coordinator.resumeFromBackground() XCTAssertTrue(context.window.rootViewController === loginRoot) context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus() context.service.finishRead() await waitUntil { context.message.contains("处于冷静期") } XCTAssertNotNil(context.coordinator.accessController) XCTAssertEqual(context.service.statusCount, 2) XCTAssertEqual(context.resumeCount, 0) XCTAssertTrue(context.service.mutations.isEmpty) } /// 窗口根或会话被替换后,旧查询完成不能恢复旧账号。 func testLateQueryDoesNotReplaceNewSessionOrRoot() async throws { for changeSession in [true, false] { let context = DeregistrationRootContext() defer { context.cleanUp() } context.service.holdNextRead = true await context.startChecking() await waitUntil { context.service.pendingRead != nil } if changeSession { context.session.token = "new-token"; context.session.userId = "102" } let replacement = UIViewController() context.window.rootViewController = replacement context.service.finishRead() try await Task.sleep(nanoseconds: 30_000_000) XCTAssertTrue(context.window.rootViewController === replacement) XCTAssertEqual(context.resumeCount, 0) } } /// 切换身份恢复新首页,不沿用前一身份的导航栈,也不额外查询注销状态。 func testSwitchIdentityRestoresNewRootWithoutQuery() { let context = DeregistrationRootContext() defer { context.cleanUp() } context.startRestoringSession() let original = context.window.rootViewController context.session.userId = "102" context.session.token = "new-token" context.coordinator.restoreSession() XCTAssertFalse(context.window.rootViewController === original) XCTAssertEqual(context.createdRoots, 2) XCTAssertEqual(context.resumeCount, 2) XCTAssertEqual(context.service.statusCount, 0) } /// 景区和未登录会话不调用门店注销接口。 func testNonStoreAndLoggedOutForegroundDoesNotQuery() { let context = DeregistrationRootContext() defer { context.cleanUp() } context.session.accountType = .scenicUser context.coordinator.resumeFromBackground() context.session.accountType = .storeUser context.session.token = "" context.coordinator.resumeFromBackground() context.coordinator.restoreSession() XCTAssertEqual(context.service.statusCount, 0) XCTAssertEqual(context.createdRoots, 0) XCTAssertFalse(context.bindingSuspended) } /// 点击撤销先显示针对当前身份的确认;后台恢复撤掉旧弹窗,不发送撤销请求。 func testCancellationRequiresConfirmationAndForegroundDismissesStaleAlert() async throws { let context = DeregistrationRootContext() defer { context.cleanUp() } context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus() await context.startChecking() await waitUntil { context.message.contains("处于冷静期") } let gate = try XCTUnwrap(context.coordinator.accessController) let cancel = try XCTUnwrap(context.button("deregister.access.cancel")) cancel.sendActions(for: .touchUpInside) await waitUntil { gate.presentedViewController is UIAlertController } let alert = try XCTUnwrap(gate.presentedViewController as? UIAlertController) XCTAssertEqual(alert.title, "撤销当前身份的注销申请?") XCTAssertTrue(alert.message?.contains(gate.identity?.displayName ?? "missing") == true) XCTAssertEqual(alert.actions.map(\.title), ["保留注销申请", "确认撤销"]) XCTAssertTrue(context.service.mutations.isEmpty) // 等原生呈现动画结束,再模拟一次后台返回。 try await Task.sleep(nanoseconds: 400_000_000) context.coordinator.resumeFromBackground() await waitUntil { gate.presentedViewController == nil && context.retryEnabled } XCTAssertTrue(context.service.mutations.isEmpty) XCTAssertEqual(context.service.statusCount, 2) } private func waitUntil(_ condition: () -> Bool, file: StaticString = #filePath, line: UInt = #line) async { for _ in 0..<150 { if condition() { return } try? await Task.sleep(nanoseconds: 10_000_000) } XCTFail("等待异步核验超时", file: file, line: line) } } /// 每例使用独立会话、提交意图和窗口;业务恢复动作以计数替代推送及首页网络请求。 @MainActor private final class DeregistrationRootContext { let name = "DeregistrationRoot.\(UUID().uuidString)" let defaults: UserDefaults let session: AppSessionStore let window: UIWindow private weak var previousKeyWindow: UIWindow? let service = DeregistrationRootService() let networkClient: APIClient? var createdRoots = 0 var createdAPIs = 0 var resumeCount = 0 var bindingSuspended = false lazy var coordinator = StoreAccountDeregistrationRootCoordinator( window: window, session: session, makeAPI: { [unowned self] identity in self.createdAPIs += 1 if let client = self.networkClient { return StoreAccountDeregistrationAPI(client: client, identity: identity) { [weak self] in guard let self else { return false } return identity.matches(session: self.session) } } return self.service }, makeSubmissionStore: { [unowned self] in StoreAccountDeregistrationSubmissionStore(storeUserID: $0, environment: .testing, defaults: self.defaults) }, makeBusinessRoot: { [unowned self] in self.createdRoots += 1 return UINavigationController(rootViewController: UIViewController()) }, setBindingSuspended: { [unowned self] in self.bindingSuspended = $0 }, onBusinessResumed: { [unowned self] in self.resumeCount += 1 } ) init(networkClient: APIClient? = nil) { self.networkClient = networkClient defaults = UserDefaults(suiteName: name)! session = AppSessionStore(defaults: defaults) session.accountType = .storeUser session.userId = "101" session.token = "isolated-store-token" if let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first { previousKeyWindow = scene.windows.first(where: \.isKeyWindow) window = UIWindow(windowScene: scene) } else { window = UIWindow(frame: UIScreen.main.bounds) } } func startChecking() async { // 模拟已有登录页,核验期间不替换它,只在此窗口显示全局加载。 if window.rootViewController == nil { window.rootViewController = UINavigationController(rootViewController: UIViewController()) } window.makeKeyAndVisible() window.layoutIfNeeded() // 模拟用户在已显示的登录页点击登录,先等待前一个用例的键盘/窗口动画结束。 try? await Task.sleep(nanoseconds: 500_000_000) coordinator.check() } func startRestoringSession() { coordinator.restoreSession() window.makeKeyAndVisible() window.layoutIfNeeded() } var message: String { allSubviews(window).compactMap { view -> String? in guard ["deregister.access.message", "deregister.access.title", "deregister.access.deadline"] .contains(view.accessibilityIdentifier ?? "") else { return nil } return (view as? UILabel)?.text }.joined(separator: "\n") } var retryEnabled: Bool { button("deregister.access.retry")?.isEnabled == true } func button(_ identifier: String) -> UIButton? { allSubviews(window).first { $0.accessibilityIdentifier == identifier } as? UIButton } func cleanUp() { coordinator.cancelPendingCheck() service.finishRead() window.isHidden = true window.rootViewController = nil previousKeyWindow?.makeKeyAndVisible() defaults.removePersistentDomain(forName: name) } private func allSubviews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews($0) } } } /// 能暂停单次 GET 并返回其旧快照,便于验证后台前在途响应不能放行。 @MainActor private final class DeregistrationRootService: StoreAccountDeregistrationServing { var current = StoreAccountDeregistrationStatus(deregister: .null) var readError: Error? var holdNextRead = false var pendingRead: CheckedContinuation? var pendingSnapshot: StoreAccountDeregistrationStatus? var statusCount = 0 var mutations: [String] = [] func status() async throws -> StoreAccountDeregistrationStatus { statusCount += 1 if holdNextRead { holdNextRead = false pendingSnapshot = current return try await withCheckedThrowingContinuation { pendingRead = $0 } } if let readError { throw readError } return current } func finishRead() { let continuation = pendingRead pendingRead = nil if let pendingSnapshot { continuation?.resume(returning: pendingSnapshot) } pendingSnapshot = nil } func eligibility() async throws -> StoreAccountDeregistrationEligibility { try StoreAccountDeregistrationFixtures.lifecycleEligibility() } func cancel() async throws { mutations.append("cancel") } func waiveWallet() async throws { mutations.append("wallet") } func waivePoints() async throws { mutations.append("points") } func sendSMS() async throws { mutations.append("sms") } func apply(smsCode: String, reason: String) async throws { mutations.append("apply") } }