Files
suixinkan_uikit/suixinkanTests/StoreAccountDeregistrationRootTests.swift
T

467 lines
23 KiB
Swift

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)
}
/// 新登录只以 v9 的门店 status 判定;旧注销状态接口和本地未决记录都不能再拦截 status 为正常的登录。
func testNormalLoginRestoresBusinessWithoutLegacyDeregistrationCheck() async {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.service.readError = APIError.networkFailed("旧注销查询不应影响 v9 正常登录")
context.startRestoringSession()
XCTAssertNil(context.coordinator.accessController)
XCTAssertFalse(context.coordinator.isChecking)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
XCTAssertFalse(context.bindingSuspended)
XCTAssertEqual(context.createdRoots, 1)
XCTAssertEqual(context.resumeCount, 1)
XCTAssertEqual(context.service.statusCount, 0)
}
/// 旧核验入口仅在业务限制等已知受限事件发生时使用,不是正常登录路径的一部分。
func testExplicitRestrictionCheckStillKeepsOriginalPageUntilResult() 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<StoreAccountDeregistrationStatus, Error>?
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") }
}