feat: 增加门店身份注销流程

This commit is contained in:
2026-08-31 09:43:14 +08:00
parent 396597a160
commit e529bb5942
35 changed files with 6062 additions and 45 deletions
+169
View File
@@ -3,12 +3,181 @@
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
@MainActor
/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和登录流程。
final class LoginViewModelTests: XCTestCase {
/// 之前按手机号保存的 Mock 完成记录不能阻断旧接口下仍可用的其他业务身份。
func testLegacyPhoneWideMockRecordDoesNotBlockServerLogin() async throws {
let username = "13900000001"
let key = "account_deletion_mock_v1_" + username
let defaults = UserDefaults.standard
let previous = defaults.object(forKey: key)
defer {
if let previous { defaults.set(previous, forKey: key) }
else { defaults.removeObject(forKey: key) }
}
let oldRecord: [String: Any] = [
"id": UUID().uuidString,
"clientRequestID": UUID().uuidString,
"username": username,
"submittedAt": 0,
"scheduledDeletionAt": 0,
"status": "completed",
"acknowledgedAssetKinds": [],
]
defaults.set(try JSONSerialization.data(withJSONObject: oldRecord), forKey: key)
let login = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[{"id":101,"store_user_id":101,"store_id":25,"store_name":"另一门店"}]}}"#.utf8)
let selected = Data(#"{"code":100000,"data":{"token":"business","scenic_users":[],"store_users":[]}}"#.utf8)
let session = MockURLSession(responses: [login, selected])
let viewModel = LoginViewModel()
viewModel.updateAccount(username)
viewModel.updatePassword("test-password")
let api = AuthAPI(client: APIClient(environment: .testing, session: session))
let result = try await viewModel.login(authAPI: api)
guard case let .completed(response, account) = result else {
return XCTFail("可用门店身份应正常登录")
}
XCTAssertEqual(response.token, "business")
XCTAssertEqual(account.businessUserId, 101)
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login", "/api/app/v9/set-user"])
}
/// 普通门店身份选择直接回调,不再无条件弹出注销提示。
func testSelectingStoreIdentityContinuesWithoutDeregistrationPrompt() throws {
let account = reentryAccount(isStore: true)
var confirmed: [AccountSwitchAccount] = []
let controller = AccountSelectionViewController(
payload: AccountSelectionPayload(tempToken: "temporary", accounts: [account]),
isLoading: false,
onCancel: {},
onConfirm: { confirmed.append($0) }
)
controller.loadViewIfNeeded()
let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton }
.first { $0.currentTitle == "进入系统" })
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmed, [account])
XCTAssertNil(controller.presentedViewController)
}
/// 登录按钮和键盘完成键均直接请求登录;空身份响应避免写入真实会话。
func testLoginPageRequestsLoginWithoutDeregistrationPrompt() async throws {
for useKeyboard in [false, true] {
let response = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[]}}"#.utf8)
let session = MockURLSession(responses: [response])
let controller = LoginViewController(authAPI: AuthAPI(client: APIClient(environment: .testing, session: session)))
controller.loadViewIfNeeded()
let views = allSubviews(in: controller.view)
let account = try XCTUnwrap(views.compactMap { $0 as? LoginTextField }.first)
let password = try XCTUnwrap(views.compactMap { $0 as? PasswordInputField }.first)
let agreement = try XCTUnwrap(views.compactMap { $0 as? AgreementRowView }.first)
account.text = "13900000001"
account.onTextChange?(account.text)
password.text = "mock-password"
password.onTextChange?(password.text)
if !agreement.isChecked { agreement.onCheckedChange?() }
let button = try XCTUnwrap(views.compactMap { $0 as? UIButton }.first { $0.currentTitle == "登录" })
if useKeyboard { password.onReturnKey?() }
else { button.sendActions(for: .touchUpInside) }
for _ in 0..<100 {
if !session.requests.isEmpty, button.isEnabled { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"])
XCTAssertNil(controller.presentedViewController)
}
}
/// 移除注销提示后仍必须勾选用户协议,不能绕过原有表单校验。
func testLoginPageStillRequiresPrivacyAgreement() async throws {
let session = MockURLSession(responses: [])
let controller = LoginViewController(authAPI: AuthAPI(client: APIClient(environment: .testing, session: session)))
controller.loadViewIfNeeded()
let views = allSubviews(in: controller.view)
let account = try XCTUnwrap(views.compactMap { $0 as? LoginTextField }.first)
let password = try XCTUnwrap(views.compactMap { $0 as? PasswordInputField }.first)
let agreement = try XCTUnwrap(views.compactMap { $0 as? AgreementRowView }.first)
account.text = "13900000001"
account.onTextChange?(account.text)
password.onTextChange?("mock-password")
if agreement.isChecked { agreement.onCheckedChange?() }
let button = try XCTUnwrap(views.compactMap { $0 as? UIButton }.first { $0.currentTitle == "登录" })
button.sendActions(for: .touchUpInside)
await Task.yield()
XCTAssertTrue(session.requests.isEmpty)
XCTAssertNil(controller.presentedViewController)
}
/// 身份选择处于加载状态时仍不允许再次确认。
func testSelectingStoreIdentityWhileLoadingDoesNotConfirm() throws {
var confirmed: [AccountSwitchAccount] = []
let controller = AccountSelectionViewController(
payload: AccountSelectionPayload(tempToken: "temporary", accounts: [reentryAccount(isStore: true)]),
isLoading: true, onCancel: {}, onConfirm: { confirmed.append($0) })
controller.loadViewIfNeeded()
let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton }
.first { $0.currentTitle == "进入系统" })
button.sendActions(for: .touchUpInside)
XCTAssertFalse(button.isEnabled)
XCTAssertTrue(confirmed.isEmpty)
XCTAssertNil(controller.presentedViewController)
}
/// 景区身份不受旧门店注销流程影响,不应被多余确认拦截。
func testSelectingScenicIdentityContinuesWithoutDeregistrationPrompt() throws {
let account = reentryAccount(isStore: false)
var confirmed: [AccountSwitchAccount] = []
let controller = AccountSelectionViewController(
payload: AccountSelectionPayload(tempToken: "temporary", accounts: [account]),
isLoading: false,
onCancel: {},
onConfirm: { confirmed.append($0) }
)
controller.loadViewIfNeeded()
let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton }
.first { $0.currentTitle == "进入系统" })
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmed, [account])
XCTAssertNil(controller.presentedViewController)
}
private func reentryAccount(isStore: Bool) -> AccountSwitchAccount {
AccountSwitchAccount(
accountType: isStore ? "store_user" : "scenic_user",
businessUserId: 101,
title: isStore ? "测试门店" : "测试景区",
subtitle: "摄影师",
phone: "13900000001",
realName: "测试用户",
avatar: "",
scenicName: "测试景区",
storeId: isStore ? 25 : nil,
storeName: isStore ? "测试门店" : "",
scenicId: 10,
isCurrent: false
)
}
private func allSubviews(in view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + allSubviews(in: $0) }
}
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
let viewModel = LoginViewModel()
viewModel.updateAccount("+86 186 5185 7230")
@@ -130,6 +130,25 @@ final class PushNotificationTests: XCTestCase {
XCTAssertTrue(api.registrationIDs.isEmpty)
}
/// 注销状态核验期间,不因 SDK 回调或前台重试调用普通业务绑定接口。
func testDeregistrationCheckSuspendsBindingWithoutClearingSession() async {
authenticate(userID: "100")
let token = appStore.session.token
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: PushSDKMock(registrationID: "reg-id"), api: api)
manager.setAccountBindingSuspended(true)
manager.initializeIfPrivacyAccepted()
manager.handleLoginCompleted()
manager.retryPendingRegistrationUpload()
for _ in 0..<10 { await Task.yield() }
XCTAssertTrue(api.registrationIDs.isEmpty)
XCTAssertEqual(appStore.session.token, token)
manager.setAccountBindingSuspended(false)
manager.handleLoginCompleted()
await waitUntil { api.registrationIDs.count == 1 }
XCTAssertEqual(api.registrationIDs, ["reg-id"])
}
func testLoggedOutStateDoesNotUpload() async {
appStore.session.privacyAgreementAccepted = true
let sdk = PushSDKMock(registrationID: "reg-id")
@@ -3,11 +3,29 @@
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// 设置中心 ViewModel 测试。
final class SettingViewModelTests: XCTestCase {
/// 注销入口标题使用危险操作红色,普通菜单和右侧文字保持原色。
@MainActor
func testDeregistrationMenuTitleUsesRedWithoutChangingOtherMenuColors() throws {
let row = SettingMenuRow(title: "注销当前门店身份", titleColor: AppColor.danger, value: "查看", showsDivider: false)
let normalRow = SettingMenuRow(title: "关于我们")
let title = try XCTUnwrap(row.subviews.compactMap { $0 as? UILabel }.first)
let normalTitle = try XCTUnwrap(normalRow.subviews.compactMap { $0 as? UILabel }.first)
let rightStack = try XCTUnwrap(row.subviews.compactMap { $0 as? UIStackView }.first)
let value = try XCTUnwrap(rightStack.arrangedSubviews.compactMap { $0 as? UILabel }.first)
XCTAssertEqual(title.textColor, AppColor.danger)
XCTAssertEqual(normalTitle.textColor, UIColor(hex: 0x4B5563))
XCTAssertEqual(value.textColor, AppColor.textPrimary)
row.isHighlighted = true
XCTAssertEqual(title.textColor, AppColor.danger)
}
func testAppVersionUsesClientInfoFormatting() {
let viewModel = SettingViewModel(
infoDictionary: [
@@ -0,0 +1,201 @@
import XCTest
@testable import suixinkan
/// 验证旧文档已明确的请求契约和门店身份隔离;不替代尚缺响应样例的业务映射测试。
@MainActor
final class StoreAccountDeregistrationAPITests: XCTestCase {
private var suiteName = ""
private var defaults: UserDefaults!
private var store: AppSessionStore!
override func setUp() async throws {
try await super.setUp()
suiteName = "StoreAccountDeregistrationAPITests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
store = AppSessionStore(defaults: defaults)
store.accountType = .storeUser
store.userId = "101"
store.currentStoreId = 25
store.accountDisplayName = "测试门店·摄影师"
store.token = "store-101-token"
}
override func tearDown() async throws {
defaults.removePersistentDomain(forName: suiteName)
store = nil
defaults = nil
try await super.tearDown()
}
/// 门店身份 ID 与门店实体 ID 不可混用,景区身份及无凭证状态不可构造上下文。
func testIdentityUsesStoreUserIDAndRejectsUnsupportedSessions() throws {
let identity = try StoreAccountDeregistrationIdentity(session: store)
XCTAssertEqual(identity.userID, "101")
XCTAssertEqual(identity.displayName, "测试门店·摄影师")
XCTAssertTrue(identity.matches(session: store))
store.accountType = .scenicUser
XCTAssertFalse(identity.matches(session: store))
XCTAssertThrowsError(try StoreAccountDeregistrationIdentity(session: store)) { error in
XCTAssertEqual(error as? StoreAccountDeregistrationError, .unsupportedIdentity)
}
store.accountType = .storeUser
store.token = ""
XCTAssertThrowsError(try StoreAccountDeregistrationIdentity(session: store)) { error in
XCTAssertEqual(error as? StoreAccountDeregistrationError, .invalidSession)
}
}
/// 七个请求使用文档中的路径、方法和字段;禁止向接口传入任意手机号或其他身份 ID。
func testAllSevenEndpointsMatchLegacyDocument() async throws {
let success = Data(#"{"code":100000,"msg":"success","data":null}"#.utf8)
let eligibility = try StoreAccountDeregistrationFixtures.envelope(StoreAccountDeregistrationFixtures.eligibilityData())
let status = Data(#"{"code":100000,"msg":"success","data":{"deregister":null}}"#.utf8)
let session = MockURLSession(responses: [eligibility, success, success, success, success, status, success])
let api = try makeAPI(session: session)
let eligibilityPayload = try await api.eligibility()
try await api.waiveWallet()
try await api.waivePoints()
try await api.sendSMS()
try await api.apply(smsCode: "654321", reason: "不再使用")
let statusPayload = try await api.status()
try await api.cancel()
let paths = ["eligibility", "waivers/wallet", "waivers/points", "send-sms", "apply", "status", "cancel"]
XCTAssertEqual(session.requests.map { $0.url?.path }, paths.map { "/api/yf-handset-app/account-deregister/" + $0 })
XCTAssertEqual(session.requests.map(\.httpMethod), ["GET", "POST", "POST", "POST", "POST", "GET", "POST"])
for request in session.requests {
XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "store-101-token")
XCTAssertNil(request.url?.query)
}
for index in [1, 2] {
let data = try XCTUnwrap(session.requests[index].httpBody)
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Bool])
XCTAssertEqual(body, ["accepted": true])
}
let applyData = try XCTUnwrap(session.requests[4].httpBody)
let applyBody = try XCTUnwrap(JSONSerialization.jsonObject(with: applyData) as? [String: String])
XCTAssertEqual(applyBody, ["sms_code": "654321", "reason": "不再使用"])
for index in [0, 3, 5, 6] { XCTAssertNil(session.requests[index].httpBody) }
XCTAssertFalse(eligibilityPayload.canApply)
XCTAssertEqual(eligibilityPayload.blockers.count, 4)
XCTAssertEqual(statusPayload.deregister, .null)
}
/// 创建流程后切换到同手机号的另一身份,必须在请求发出前拒绝旧流程操作。
func testIdentitySwitchPreventsSendingRequest() async throws {
let session = MockURLSession(responses: [])
let api = try makeAPI(session: session)
store.userId = "102"
store.token = "store-102-token"
do {
try await api.waiveWallet()
XCTFail("切换身份后不能继续提交原确认页")
} catch {
XCTAssertEqual(error as? StoreAccountDeregistrationError, .sessionChanged)
}
XCTAssertTrue(session.requests.isEmpty)
}
/// 同一身份重新登录取得新凭证,也必须重新进入注销流程。
func testTokenChangePreventsSendingRequest() async throws {
let session = MockURLSession(responses: [])
let api = try makeAPI(session: session)
store.token = "new-token"
do {
try await api.apply(smsCode: "654321", reason: "不再使用")
XCTFail("凭证变化后不能继续旧流程")
} catch {
XCTAssertEqual(error as? StoreAccountDeregistrationError, .sessionChanged)
}
XCTAssertTrue(session.requests.isEmpty)
}
/// 请求等待期间切换身份,旧响应不得应用到新身份页面。
func testResponseFromPreviousIdentityIsDiscarded() async throws {
let session = DeregistrationCallbackSession { [store] in store?.userId = "102" }
let api = try makeAPI(session: session)
do {
_ = try await api.status()
XCTFail("旧身份响应不能应用到新身份")
} catch {
XCTAssertEqual(error as? StoreAccountDeregistrationError, .sessionChanged)
}
XCTAssertEqual(session.requests.first?.value(forHTTPHeaderField: "token"), "store-101-token")
}
/// 余额变化等后端错误保留业务码,不自动重试资产放弃或提交操作。
func testMutationPreservesServerFailureWithoutRetry() async throws {
let data = try TestJSON.errorEnvelope(code: 100001, msg: "余额已变化,请重新确认")
let session = MockURLSession(responses: [data])
let api = try makeAPI(session: session)
do {
try await api.waivePoints()
XCTFail("不能把服务端失败当作确认成功")
} catch let APIError.serverCode(code, message) {
XCTAssertEqual(code, 100001)
XCTAssertEqual(message, "余额已变化,请重新确认")
}
XCTAssertEqual(session.requests.count, 1)
}
/// 150015 表示当前身份受限,不是手机号主账号退出,也不能清掉查询与撤销所需凭证。
func testRestrictionCodeIsNotAuthenticationExpiry() async throws {
let data = try TestJSON.errorEnvelope(code: 150015, msg: "当前身份正在注销")
let session = MockURLSession(responses: [data])
let api = try makeAPI(session: session)
do {
try await api.sendSMS()
XCTFail("限制状态应交由业务层处理")
} catch {
XCTAssertFalse(APIError.isAuthenticationExpired(error))
guard case APIError.serverCode(150015, _) = error else {
return XCTFail("应保留旧接口明确约定的限制码")
}
}
XCTAssertEqual(store.token, "store-101-token")
}
/// 文档未定义的字段和状态值必须原样保留,不把未知值解释成可以注销。
func testRawResponsePreservesUnknownValuesAndDecimalPrecision() throws {
let data = Data(#"{"unknown_status":17,"value":123456789.12,"optional":null,"nested":[true,"17",{}]}"#.utf8)
let payload = try JSONDecoder().decode(StoreAccountDeregistrationJSON.self, from: data)
guard case let .object(fields) = payload else { return XCTFail("应保留对象") }
XCTAssertEqual(fields["unknown_status"], .number(17))
XCTAssertEqual(fields["value"], .number(Decimal(string: "123456789.12")!))
XCTAssertEqual(fields["optional"], .null)
XCTAssertEqual(fields["nested"], .array([.bool(true), .string("17"), .object([:])]))
}
private func makeAPI(session: URLSessionProtocol) throws -> StoreAccountDeregistrationAPI {
let identity = try StoreAccountDeregistrationIdentity(session: store)
let client = APIClient(environment: .testing, session: session)
client.bindAuthTokenProvider { "must-not-use-global-token" }
return StoreAccountDeregistrationAPI(client: client, identity: identity) { [store] in
guard let store else { return false }
return identity.matches(session: store)
}
}
}
/// 在返回数据前模拟会话变化,用于验证异步旧响应隔离。
private final class DeregistrationCallbackSession: URLSessionProtocol {
private let onRequest: @MainActor () -> Void
private(set) var requests: [URLRequest] = []
/// 注入等待期间发生的主线程会话操作。
init(onRequest: @escaping @MainActor () -> Void) {
self.onRequest = onRequest
}
/// 返回最小合法 Envelope,不假定业务状态字段。
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
await onRequest()
let data = Data(#"{"code":100000,"msg":"success","data":{"deregister":null}}"#.utf8)
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (data, response)
}
}
@@ -0,0 +1,393 @@
import UIKit
import XCTest
@testable import suixinkan
/// 只读进入核验与受限错误隔离测试,不使用设备上的真实会话。
@MainActor
final class StoreAccountDeregistrationAccessTests: XCTestCase {
/// 已知冷静期后重查到旧null/草稿不能放行,完整撤销记录才解除限制。
func testObservedCoolingDoesNotRegressToOldUnsubmittedState() async throws {
for stale in [StoreAccountDeregistrationStatus(deregister: .null), try StoreAccountDeregistrationFixtures.draftStatus()] {
let service = DeregistrationStatusStub()
service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus().deregister
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
service.result = stale.deregister
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true).deregister
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
}
}
/// 在 iPhone 上渲染冷静期页面,确认撤销入口可见;不调用真实网络或修改接口。
func testCoolingPageRendersCancellationEntryOnPhysicalDevice() async throws {
let name = "DeregistrationCoolingPage.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let session = AppSessionStore(defaults: defaults)
session.accountType = .storeUser
session.userId = "101"
session.token = "isolated-token"
let identity = try StoreAccountDeregistrationIdentity(session: session)
let service = try DeregistrationCancellationStub()
let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: service, session: session) { _ in
XCTFail("冷静期不进入业务")
}
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UINavigationController(rootViewController: controller)
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
let views = allSubviews(controller.view)
let cancel = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.cancel" } as? UIButton)
let message = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.message" } as? UILabel)
for _ in 0..<100 {
if !cancel.isHidden, cancel.isEnabled { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
window.layoutIfNeeded()
XCTAssertFalse(cancel.isHidden)
XCTAssertTrue(cancel.isEnabled)
XCTAssertTrue(message.text?.contains("处于冷静期") == true)
let deadline = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.deadline" } as? UILabel)
XCTAssertEqual(deadline.text, "2026-09-04 14:23:40")
XCTAssertFalse(message.text?.contains("604776") == true)
XCTAssertTrue(window.bounds.contains(cancel.convert(cancel.bounds, to: window)))
let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext)
})
attachment.name = "store-deregistration-cooling"
attachment.lifetime = .keepAlways
add(attachment)
XCTAssertEqual(service.calls, ["status"])
}
private func allSubviews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews($0) } }
/// 实测冷静期不进入业务;恰好到期仍待服务端复核,不能凭本机时间完成注销。
func testCoolingAndExactDeadlineRemainRestricted() async throws {
for seconds in [604776, 0] {
let service = DeregistrationStatusStub()
service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus(overrides: ["remaining_seconds": seconds]).deregister
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .cooling)
XCTAssertEqual(model.status?.remainingSeconds, Int64(seconds))
}
}
/// 撤销必须先核对申请,POST 后再次查询为已撤销才恢复;不会重复 POST。
func testCancelRechecksStatusAndRestoresBusinessOnlyAfterConfirmation() async throws {
let service = try DeregistrationCancellationStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
XCTAssertEqual(service.calls, ["status", "status", "cancel", "status"])
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertEqual(service.calls.filter { $0 == "cancel" }.count, 1)
}
/// 撤销响应丢失不自动重试,之后只读查到9即可恢复。
func testLostCancellationResponseDoesNotRetryMutation() async throws {
let service = try DeregistrationCancellationStub()
service.cancelError = APIError.networkFailed("lost response")
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
await model.cancel(api: service, isCurrentIdentity: { true })
guard case .failed = model.decision else { return XCTFail("结果未知不能直接放行") }
await model.cancel(api: service, isCurrentIdentity: { true })
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
XCTAssertEqual(service.calls.filter { $0 == "cancel" }.count, 1)
}
/// 后端未更新为撤销时继续受限,即使 POST 返回成功。
func testSuccessfulCancelWithoutConfirmedStatusDoesNotAllowBusiness() async throws {
let service = try DeregistrationCancellationStub()
service.afterCancel = service.current
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .cooling)
}
/// 已看到冷静期后,取消前后的旧 null/草稿不能作为撤销成功的依据。
func testOldNullOrDraftDuringCancellationDoesNotAllowBusiness() async throws {
for stale in [StoreAccountDeregistrationStatus(deregister: .null), try StoreAccountDeregistrationFixtures.draftStatus()] {
for beforePost in [true, false] {
let service = try DeregistrationCancellationStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
if beforePost { service.current = stale } else { service.afterCancel = stale }
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
XCTAssertEqual(service.calls.contains("cancel"), !beforePost)
}
}
}
/// 已被撤销或换成另一申请时,旧弹窗不能撤销新申请。
func testChangedApplicationBeforeCancelPreventsPost() async throws {
for cancelled in [true, false] {
let service = try DeregistrationCancellationStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: cancelled, overrides: ["id": 302])
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertFalse(service.calls.contains("cancel"))
XCTAssertEqual(model.decision, cancelled ? .allowed : .cooling)
}
}
/// 取消请求期间切换账号或收到更新的限制信号,旧结果不能解除新会话的限制。
func testCancellationDiscardsChangedIdentityAndNewRestriction() async throws {
for changeIdentity in [true, false] {
var current = true
let service = try DeregistrationCancellationStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { current })
service.onCancel = {
if changeIdentity { current = false } else { model.recordRestriction() }
}
await model.cancel(api: service, isCurrentIdentity: { current })
XCTAssertEqual(model.decision, changeIdentity ? .obsolete : .unresolved)
XCTAssertEqual(service.calls, ["status", "status", "cancel"])
}
}
/// 冷启动可用新撤销记录核销意图,但再次申请后相同的旧9状态不能放行。
func testCancelledStatusReconcilesOnlyNewCancellation() async throws {
let name = "DeregistrationCancelledRecovery.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let service = DeregistrationStatusStub()
let cancelled = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
service.result = cancelled.deregister
try store.recordSubmissionIntent()
let restored = StoreAccountDeregistrationAccessViewModel(submissionStore: store)
await restored.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(restored.decision, .allowed)
XCTAssertFalse(store.hasUnresolvedSubmission)
try store.recordSubmissionIntent(previousStatus: cancelled)
let reapplied = StoreAccountDeregistrationAccessViewModel(submissionStore: store)
await reapplied.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(reapplied.decision, .unresolved)
XCTAssertTrue(store.hasUnresolvedSubmission)
service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true,
overrides: ["cancel_time": "2026-08-28 15:00:00"]).deregister
await reapplied.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(reapplied.decision, .allowed)
XCTAssertFalse(store.hasUnresolvedSubmission)
}
func testRequiresSuccessfulNullStatusBeforeAllowingBusiness() async {
let model = StoreAccountDeregistrationAccessViewModel()
let service = DeregistrationStatusStub()
XCTAssertEqual(model.decision, .notChecked)
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
XCTAssertEqual(service.queryCount, 1)
}
func testUnknownNonemptyRecordDoesNotAllowBusiness() async {
let service = DeregistrationStatusStub()
service.result = .object(["unknown_status": .number(17)])
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
}
/// 资产确认创建的是未提交草稿,冷启动不能将其误当成冷静期限制。
func testObservedDraftAllowsBusinessWithoutMutation() async throws {
let service = DeregistrationStatusStub()
service.result = try StoreAccountDeregistrationFixtures.draftStatus().deregister
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
XCTAssertEqual(service.queryCount, 1)
}
/// 提交结果不明时,旧草稿响应不能清除待核实的提交意图。
func testLocalUnresolvedIntentStillBlocksOnDraftStatus() async throws {
let service = DeregistrationStatusStub()
service.result = try StoreAccountDeregistrationFixtures.draftStatus().deregister
let model = StoreAccountDeregistrationAccessViewModel(requiresSubmissionReconciliation: true)
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
}
func testLocalUnresolvedIntentDoesNotAllowBusinessOnNullStatus() async {
let model = StoreAccountDeregistrationAccessViewModel(requiresSubmissionReconciliation: true)
await model.verify(api: DeregistrationStatusStub(), isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
}
func testFailureDoesNotMeanNoApplication() async {
let service = DeregistrationStatusStub()
service.error = APIError.networkFailed("offline")
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
guard case .failed = model.decision else { return XCTFail("网络失败必须保持未核验") }
service.error = APIError.serverCode(150015, "身份受限")
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
}
func testChangedIdentityPreventsQuery() async {
let service = DeregistrationStatusStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { false })
XCTAssertEqual(model.decision, .obsolete)
XCTAssertEqual(service.queryCount, 0)
}
func testChangedIdentityDiscardsLateResponseAndError() async {
for fails in [false, true] {
var current = true
let service = DeregistrationStatusStub()
service.onQuery = { current = false }
service.error = fails ? APIError.networkFailed("old error") : nil
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { current })
XCTAssertEqual(model.decision, .obsolete)
}
}
func testNewRestrictionWinsOverOldNormalResponse() async {
let model = StoreAccountDeregistrationAccessViewModel()
let service = DeregistrationStatusStub()
service.onQuery = { model.recordRestriction() }
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
service.onQuery = nil
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
}
func testRestrictionOnlyMatchesCurrentStoreToken() {
let name = "DeregistrationAccessTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let session = AppSessionStore(defaults: defaults)
session.accountType = .storeUser
session.token = "current-store-token"
XCTAssertTrue(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: session.token, session: session))
XCTAssertFalse(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: "old-token", session: session))
XCTAssertFalse(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: nil, session: session))
session.accountType = .scenicUser
XCTAssertFalse(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: session.token, session: session))
}
func testRestrictionNotificationUsesOriginalRequestTokenWithoutExpiringSession() async throws {
let token = "test-original-token"
let center = NotificationCenter()
let restricted = XCTNSNotificationExpectation(name: NotificationName.storeAccountDeregistrationRestricted, object: nil, notificationCenter: center)
restricted.handler = { notification in
let requestToken = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String
return requestToken == token
}
let expired = XCTNSNotificationExpectation(name: NotificationName.sessionDidExpire, object: nil, notificationCenter: center)
expired.isInverted = true
let network = MockURLSession(responses: [StoreAccountDeregistrationFixtures.observedCoolingRestrictionEnvelope])
let client = APIClient(environment: .testing, session: network, notificationCenter: center)
client.bindAuthTokenProvider { "new-current-token" }
do {
let _: EmptyPayload = try await client.send(APIRequest(method: .get, path: "/test/business"), tokenOverride: token)
XCTFail("必须保留150015错误")
} catch {
XCTAssertFalse(APIError.isAuthenticationExpired(error))
guard case APIError.serverCode(150015, let message) = error else {
return XCTFail("真实150015响应不能被误判为解码失败")
}
XCTAssertEqual(message, "账号处于注销冷静期,请先撤销注销后再继续使用")
}
await fulfillment(of: [restricted, expired], timeout: 0.1)
}
func testAccessPageQueriesOnlyStatusBeforeAllowingEntry() async throws {
let name = "DeregistrationAccessPageTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let session = AppSessionStore(defaults: defaults)
session.accountType = .storeUser
session.userId = "101"
session.token = "isolated-token"
let identity = try StoreAccountDeregistrationIdentity(session: session)
let network = MockURLSession(responses: [Data(#"{"code":100000,"data":{"deregister":null}}"#.utf8)])
let api = StoreAccountDeregistrationAPI(client: APIClient(environment: .testing, session: network), identity: identity) {
identity.matches(session: session)
}
let allowed = expectation(description: "状态查询后允许进入")
let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: api, session: session) { _ in
allowed.fulfill()
}
controller.loadViewIfNeeded()
await fulfillment(of: [allowed], timeout: 2)
XCTAssertEqual(network.requests.map(\.httpMethod), ["GET"])
XCTAssertEqual(network.requests.first?.url?.path, "/api/yf-handset-app/account-deregister/status")
XCTAssertEqual(session.token, "isolated-token")
}
/// 防御性测试:HTTP 拒绝中明确携带150015时仍是注销限制;并非已实测到该HTTP组合。
func testExplicitDeregistrationCodeInHTTPFailureIsNotTokenExpiry() async throws {
let network = MockURLSession(responses: [try TestJSON.errorEnvelope(code: 150015, msg: "注销限制")], statusCode: 403)
let client = APIClient(environment: .testing, session: network)
do {
let _: EmptyPayload = try await client.send(APIRequest(method: .get, path: "/test/business"), tokenOverride: "isolated-token")
XCTFail("应保留150015限制")
} catch {
XCTAssertFalse(APIError.isAuthenticationExpired(error))
guard case APIError.serverCode(150015, _) = error else { return XCTFail("应保留业务限制码") }
}
}
}
/// 撤销专用内存服务,可模拟响应丢失、申请变化及并发限制,不调用真实网络。
@MainActor
private final class DeregistrationCancellationStub: StoreAccountDeregistrationServing {
var current: StoreAccountDeregistrationStatus
var afterCancel: StoreAccountDeregistrationStatus
var cancelError: Error?
var onCancel: (() -> Void)?
var calls: [String] = []
init() throws {
current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
afterCancel = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
}
func status() async throws -> StoreAccountDeregistrationStatus { calls.append("status"); return current }
func cancel() async throws {
calls.append("cancel")
current = afterCancel
onCancel?()
if let cancelError { throw cancelError }
}
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
XCTFail("状态核验无需调用条件接口")
return try StoreAccountDeregistrationFixtures.eligibility()
}
func waiveWallet() async throws { XCTFail("不应确认资产") }
func waivePoints() async throws { XCTFail("不应确认资产") }
func sendSMS() async throws { XCTFail("不应发送短信") }
func apply(smsCode: String, reason: String) async throws { XCTFail("不应申请") }
}
/// 状态查询的可控替身,模拟服务端返回和查询途中发生的会话变化。
@MainActor
private final class DeregistrationStatusStub: StoreAccountDeregistrationStatusServing {
var result: StoreAccountDeregistrationJSON = .null
var error: Error?
var onQuery: (() -> Void)?
private(set) var queryCount = 0
func status() async throws -> StoreAccountDeregistrationStatus {
queryCount += 1
onQuery?()
if let error { throw error }
return StoreAccountDeregistrationStatus(deregister: result)
}
}
@@ -0,0 +1,618 @@
import UIKit
import XCTest
@testable import suixinkan
/// 合并入口仍调用两条旧接口,覆盖部分成功、快照变化与重复操作;所有请求只在内存中执行。
@MainActor
final class StoreAccountDeregistrationCombinedTests: XCTestCase {
/// 零资产也按现金、积分顺序分别确认,全部核实后才进入验证码页。
func testConfirmsBothZeroAssetsInOrder() async throws {
let service = CombinedDeregistrationService()
let model = try await readyModel(service)
XCTAssertTrue(model.requiresAssetConfirmation)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertEqual(model.step, .verification)
XCTAssertTrue(model.canContinue)
XCTAssertFalse(model.requiresAssetConfirmation)
}
/// 第二项失败保留现金确认结果,用户重试时不重复确认第一项。
func testPartialFailureOnlyRetriesUnconfirmedAsset() async throws {
let service = CombinedDeregistrationService()
service.pointsError = APIError.networkFailed("积分确认失败")
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(model.step, .conditions)
XCTAssertEqual(model.eligibility?.walletWaived, true)
XCTAssertEqual(model.eligibility?.pointsWaived, false)
XCTAssertTrue(model.errorMessage?.contains("现金余额已确认,积分尚未确认") == true)
XCTAssertEqual(service.mutations, ["wallet", "points"])
service.pointsError = nil
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points", "points"])
XCTAssertEqual(model.step, .verification)
}
/// 已确认资产不重复提交,两项均已确认时仅查询并进入下一步。
func testSkipsPreviouslyConfirmedAssets() async throws {
for both in [false, true] {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = both
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, both ? [] : ["points"])
XCTAssertEqual(model.step, .verification)
}
}
/// 用户看到的金额与提交前快照不同,不提交任何资产确认。
func testChangedBalanceBeforeConfirmationRequiresNewConsent() async throws {
let service = CombinedDeregistrationService()
let model = try await readyModel(service)
let snapshot = try XCTUnwrap(model.eligibility)
service.points = 10
await model.confirmAssetsAndContinue(snapshot: snapshot, api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(model.eligibility?.pointsBalance, 10)
XCTAssertTrue(model.requiresAssetConfirmation)
XCTAssertEqual(model.step, .conditions)
XCTAssertNotNil(model.errorMessage)
}
/// 两条接口之间余额变化时停止,不自动同意新余额。
func testBalanceChangeBetweenRequestsStopsSecondMutation() async throws {
let service = CombinedDeregistrationService()
service.onWallet = { service.points = 10 }
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertEqual(model.eligibility?.pointsBalance, 10)
XCTAssertEqual(model.step, .conditions)
XCTAssertTrue(model.requiresAssetConfirmation)
}
/// 财务归属变化即使金额相同也必须重新核对。
func testFinanceIdentityChangeStopsConfirmation() async throws {
let service = CombinedDeregistrationService()
let model = try await readyModel(service)
let snapshot = try XCTUnwrap(model.eligibility)
service.financeID = 202
await model.confirmAssetsAndContinue(snapshot: snapshot, api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(model.step, .conditions)
XCTAssertTrue(model.requiresAssetConfirmation)
}
/// 请求期间身份变化,丢弃当前资料且不继续第二项。
func testIdentityChangeBetweenRequestsStopsFlow() async throws {
let service = CombinedDeregistrationService()
service.onWallet = { service.userID = 102 }
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertNil(model.eligibility)
XCTAssertFalse(model.canContinue)
XCTAssertFalse(model.canConfirmAssetsAndContinue)
}
/// 连点不能启动第二组请求。
func testRepeatedTapWhileBusyDoesNotDuplicateMutations() async throws {
let service = CombinedDeregistrationService()
service.holdWallet = true
let model = try await readyModel(service)
let snapshot = try XCTUnwrap(model.eligibility)
let first = Task { await model.confirmAssetsAndContinue(snapshot: snapshot, api: service) }
for _ in 0..<100 {
if service.walletContinuation != nil { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertNotNil(service.walletContinuation)
await model.confirmAssetsAndContinue(snapshot: snapshot, api: service)
XCTAssertEqual(service.mutations, ["wallet"])
service.walletContinuation?.resume()
service.walletContinuation = nil
await first.value
XCTAssertEqual(service.mutations, ["wallet", "points"])
}
/// 非资产阻断不能被合并按钮绕过,未知代码同样阻止继续。
func testBusinessBlockersPreventAnyConfirmation() async throws {
for code in ["ORDER_UNFULFILLED", "RISK_WINDOW_NOT_EXPIRED", "UNKNOWN_NEW_RULE"] {
let service = CombinedDeregistrationService()
service.extraBlockers = [["code": code, "message": "需处理", "action": "contact_support"]]
let model = try await readyModel(service)
XCTAssertFalse(model.canConfirmAssetsAndContinue)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertTrue(service.mutations.isEmpty)
}
}
/// POST成功但GET未确认时停止,不乐观置为完成。
func testUnconfirmedWalletResponseDoesNotProceedToPoints() async throws {
let service = CombinedDeregistrationService()
service.confirmWalletOnServer = false
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertEqual(model.eligibility?.walletWaived, false)
XCTAssertFalse(model.canContinue)
XCTAssertEqual(model.step, .conditions)
}
/// 响应丢失后只读发现两项成功,不自动重发;下一次主动继续无需再确认。
func testLostPointsResponseReconcilesWithoutResubmitting() async throws {
let service = CombinedDeregistrationService()
service.pointsError = APIError.networkFailed("响应丢失")
service.commitPointsBeforeError = true
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertEqual(model.step, .conditions)
XCTAssertFalse(model.requiresAssetConfirmation)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertEqual(model.step, .verification)
}
/// 已确认项在点击下一步期间失效时必须重新确认,不能无弹窗再次提交。
func testExpiredConfirmationRequiresFreshConsent() async throws {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
let model = try await readyModel(service)
let snapshot = try XCTUnwrap(model.eligibility)
service.walletWaived = false
await model.confirmAssetsAndContinue(snapshot: snapshot, api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertTrue(model.requiresAssetConfirmation)
XCTAssertEqual(model.step, .conditions)
}
/// 确认后的查询失败时清空操作权限,避免使用旧金额继续。
func testReadFailureAfterFirstMutationDisablesContinue() async throws {
let service = CombinedDeregistrationService()
service.onWallet = { service.readError = APIError.networkFailed("offline") }
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertNil(model.eligibility)
XCTAssertFalse(model.canConfirmAssetsAndContinue)
XCTAssertFalse(model.canContinue)
}
private func readyModel(_ service: CombinedDeregistrationService) async throws -> StoreAccountDeregistrationViewModel {
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
_ = try XCTUnwrap(model.eligibility)
return model
}
}
/// 两步页面的实际UIKit布局和交互测试;独立窗口不触碰用户真实会话。
@MainActor
final class StoreAccountDeregistrationRedesignTests: XCTestCase {
/// 明确受理后立即调用一次退出,不等待后续GET;在途连点及成功后再次点击均不重发。
func testAcceptedSubmissionExitsOnceWithoutPostSubmissionQuery() async throws {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
service.holdApply = true
service.onApply = { service.readError = APIError.networkFailed("成功后不应再查询") }
let suite = "DeregistrationLogout.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
defer { defaults.removePersistentDomain(forName: suite) }
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
var logoutCount = 0
var unresolvedCount = 0
let controller = StoreAccountDeregistrationViewController(
identityName: "测试门店", viewModel: model, api: service,
onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { logoutCount += 1 })
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { service.finishApply(); close(window) }
await waitUntil { model.canContinue }
model.beginVerification()
let initialStatusCount = service.statusCount
let initialEligibilityCount = service.eligibilityCount
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
await waitUntil { service.applyContinuation != nil }
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
XCTAssertEqual(logoutCount, 0)
XCTAssertTrue(model.isBusy)
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
XCTAssertNotNil(allViews(window).first { $0 is GlobalLoadingOverlayView })
capture(window, name: "deregister-submitting-global-loading")
service.finishApply()
await waitUntil { logoutCount == 1 }
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
XCTAssertEqual(logoutCount, 1)
XCTAssertEqual(unresolvedCount, 0)
XCTAssertEqual(service.mutations, ["apply"])
XCTAssertEqual(service.statusCount, initialStatusCount + 1, "只保留提交前校验")
XCTAssertEqual(service.eligibilityCount, initialEligibilityCount + 1)
XCTAssertTrue(model.submissionAccepted)
XCTAssertNil(model.errorMessage)
XCTAssertTrue(store.hasUnresolvedSubmission, "保留意图供下次登录核实")
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
/// 业务拒绝不退出;网络响应丢失只转入状态核验,不当作成功或自动重试。
func testRejectedAndUncertainSubmissionDoNotLogout() async throws {
for uncertain in [false, true] {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
service.applyError = uncertain ? APIError.networkFailed("响应丢失") : APIError.serverCode(100001, "验证码错误")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
var logoutCount = 0
var unresolvedCount = 0
let controller = StoreAccountDeregistrationViewController(
identityName: "测试门店", viewModel: model, api: service,
onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { logoutCount += 1 })
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
await waitUntil { model.canContinue }
model.beginVerification()
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
await waitUntil { model.errorMessage != nil && !model.isBusy }
XCTAssertEqual(logoutCount, 0)
XCTAssertEqual(unresolvedCount, uncertain ? 1 : 0)
XCTAssertEqual(model.submissionAttempted, uncertain)
XCTAssertFalse(model.submissionAccepted)
XCTAssertEqual(service.mutations, ["apply"])
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
}
/// 两步页面下拉只查询;使用统一加载,成功或失败都结束刷新且保留用户输入。
func testPullRefreshUsesGlobalLoadingAndPreservesVerificationInput() async throws {
for failed in [false, true] {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service)
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { service.finishStatus(); close(window) }
await waitUntil { model.canContinue }
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
next.sendActions(for: .touchUpInside)
await waitUntil { model.step == .verification }
let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField)
code.text = "654321"
reason.text = "不再使用"
let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView)
let refresh = try XCTUnwrap(scroll.refreshControl)
XCTAssertNil(controller.navigationItem.rightBarButtonItem)
XCTAssertTrue(scroll.alwaysBounceVertical)
let count = service.statusCount
service.holdNextStatus = true
refresh.beginRefreshing()
refresh.sendActions(for: .valueChanged)
await waitUntil { service.statusContinuation != nil }
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
XCTAssertFalse(next.isEnabled)
refresh.sendActions(for: .valueChanged)
XCTAssertEqual(service.statusCount, count + 1)
if failed { service.readError = APIError.networkFailed("offline") }
service.finishStatus()
await waitUntil { !model.isBusy && !GlobalLoadingManager.shared.isShowing }
XCTAssertFalse(refresh.isRefreshing)
XCTAssertEqual(code.text, "654321")
XCTAssertEqual(reason.text, "不再使用")
XCTAssertEqual(model.errorMessage != nil, failed)
XCTAssertTrue(service.mutations.isEmpty)
}
}
/// 只读条件页同样支持下拉,不含旧导航刷新或其他身份分类提示。
func testReadOnlyConditionsHavePullRefreshAndSimplifiedCopy() async throws {
let service = CombinedDeregistrationService()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service, readOnly: true)
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { close(window) }
await waitUntil { model.eligibility != nil }
XCTAssertNil(controller.navigationItem.rightBarButtonItem)
let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView)
let refresh = try XCTUnwrap(scroll.refreshControl)
let count = service.statusCount
refresh.sendActions(for: .valueChanged)
await waitUntil { service.statusCount == count + 1 && !model.isBusy }
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertFalse(texts.contains("景区"))
XCTAssertTrue(texts.contains("同手机号的其他身份不受影响"))
XCTAssertFalse(StoreAccountDeregistrationError.unsupportedIdentity.localizedDescription.contains("景区"))
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertFalse(refresh.isRefreshing)
}
/// 点击提交仅显示最终确认,明确退出登录和冷静期,不自动申请或发送短信。
func testSubmitRequiresFinalConfirmationWithLogoutNotice() async throws {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
var logoutCount = 0
let controller = StoreAccountDeregistrationViewController(
identityName: "当前测试身份", viewModel: model, api: service, onSubmissionAccepted: { logoutCount += 1 })
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
await waitUntil { model.canContinue }
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
next.sendActions(for: .touchUpInside)
await waitUntil { model.step == .verification }
let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField)
code.text = "654321"
reason.text = "不再使用"
reason.sendActions(for: .editingChanged)
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
submit.sendActions(for: .touchUpInside)
await waitUntil { controller.presentedViewController is UIAlertController }
let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController)
XCTAssertTrue(alert.message?.contains("提交成功后将退出登录") == true)
XCTAssertTrue(alert.message?.contains("当前测试身份") == true)
XCTAssertTrue(alert.message?.contains("7 天冷静期") == true)
XCTAssertTrue(alert.message?.contains("正式完成不可恢复") == true)
XCTAssertEqual(alert.actions.map(\.title), ["暂不提交", "确认提交申请"])
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(logoutCount, 0)
}
/// 375pt小屏上仅一个主按钮,零资产确认也先弹窗,没有自动提交。
func testSingleAssetButtonShowsOneExplicitConfirmation() async throws {
let service = CombinedDeregistrationService()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "北大科技园·测试身份", viewModel: model, api: service)
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { close(window) }
await waitUntil { model.canConfirmAssetsAndContinue }
window.layoutIfNeeded()
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
XCTAssertEqual(next.configuration?.title, "确认资产并继续")
XCTAssertNil(controller.navigationItem.rightBarButtonItem)
XCTAssertNotNil(find(controller.view, "deregister.refresh") as? UIRefreshControl)
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertFalse(texts.contains("景区"))
XCTAssertNil(find(controller.view, "deregister.wallet"))
XCTAssertNil(find(controller.view, "deregister.points"))
XCTAssertTrue(window.bounds.contains(next.convert(next.bounds, to: window)))
capture(window, name: "redesign-assets-375")
next.sendActions(for: .touchUpInside)
await waitUntil { controller.presentedViewController is UIAlertController }
let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController)
XCTAssertEqual(alert.actions.map(\.title), ["暂不确认", "确认并继续"])
XCTAssertTrue(alert.message?.contains("现金余额:¥0.00") == true)
XCTAssertTrue(alert.message?.contains("积分:0") == true)
XCTAssertTrue(alert.message?.contains("确认自愿放弃以上现金余额,并确认自愿放弃以上积分") == true)
XCTAssertTrue(service.mutations.isEmpty)
}
/// 已确认资产不再弹窗;验证码与获取按钮同行,输入完整前禁用提交,键盘不遮挡操作区。
func testVerificationLayoutInputAndKeyboard() async throws {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "北大科技园·测试身份", viewModel: model, api: service)
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
await waitUntil { model.canContinue }
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
next.sendActions(for: .touchUpInside)
await waitUntil { model.step == .verification }
window.layoutIfNeeded()
let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField)
let sms = try XCTUnwrap(find(controller.view, "deregister.sms") as? UIButton)
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
XCTAssertEqual(code.textContentType, .oneTimeCode)
XCTAssertEqual(code.keyboardType, .numberPad)
XCTAssertEqual(code.superview, sms.superview)
XCTAssertFalse(submit.isEnabled)
capture(window, name: "redesign-verification")
code.text = "123456"
code.sendActions(for: .editingChanged)
XCTAssertFalse(submit.isEnabled)
reason.text = "测试"
reason.sendActions(for: .editingChanged)
XCTAssertTrue(submit.isEnabled)
let navigationFrame = controller.navigationController?.view.frame
code.becomeFirstResponder()
await waitUntil { controller.view.keyboardLayoutGuide.layoutFrame.height > controller.view.safeAreaInsets.bottom + 80 }
window.layoutIfNeeded()
let frame = submit.convert(submit.bounds, to: controller.view)
XCTAssertLessThanOrEqual(frame.maxY, controller.view.keyboardLayoutGuide.layoutFrame.minY + 1)
XCTAssertGreaterThan(frame.minY, 0)
capture(window, name: "redesign-verification-keyboard")
reason.becomeFirstResponder()
let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView)
await waitUntil {
window.layoutIfNeeded()
return scroll.bounds.contains(reason.convert(reason.bounds, to: scroll))
}
XCTAssertEqual(controller.navigationController?.view.frame, navigationFrame)
capture(window, name: "redesign-reason-keyboard")
reason.resignFirstResponder()
XCTAssertTrue(service.mutations.isEmpty)
}
/// 非零资产与长阻断说明仍可滚动,业务未满足时不能继续。
func testNonzeroAssetsAndBusinessBlockers() async throws {
let service = CombinedDeregistrationService()
service.walletFen = 129900
service.points = 1280
service.extraBlockers = [["code": "ORDER_UNFULFILLED", "message": "账户仍有未履约订单或带单,请处理完成后再申请注销。", "action": "complete_orders"]]
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "较长的门店身份名称用于验证自动换行展示", viewModel: model, api: service)
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { close(window) }
await waitUntil { model.eligibility != nil }
window.layoutIfNeeded()
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
XCTAssertFalse(next.isEnabled)
let blockers = try XCTUnwrap(find(controller.view, "deregister.blockers") as? UILabel)
XCTAssertTrue(blockers.text?.contains("未履约订单") == true)
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertTrue(texts.contains("¥1299.00"))
XCTAssertTrue(texts.contains("1280"))
XCTAssertTrue(window.bounds.contains(next.convert(next.bounds, to: window)))
capture(window, name: "redesign-business-blockers")
XCTAssertTrue(service.mutations.isEmpty)
}
/// 失败与未知状态给出重试入口,不展示调试字段或伪造注销完成。
func testUnknownAndFailedStatusPages() async throws {
for failed in [false, true] {
let service = CombinedDeregistrationService()
service.statusOverride = .init(deregister: .object(["status": .number(123)]))
service.readError = failed ? APIError.networkFailed("debug-internal-error") : nil
let suite = "RedesignStatus.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let session = AppSessionStore(defaults: defaults)
session.accountType = .storeUser; session.userId = "101"; session.token = "mock-token"
session.accountDisplayName = "测试门店"
let controller = StoreAccountDeregistrationAccessViewController(identity: try .init(session: session), api: service, session: session) { _ in XCTFail("不能进入业务") }
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { close(window); defaults.removePersistentDomain(forName: suite) }
let title = try XCTUnwrap(find(controller.view, "deregister.access.title") as? UILabel)
await waitUntil { title.text == (failed ? "暂时无法查询" : "注销状态待确认") }
let retry = try XCTUnwrap(find(controller.view, "deregister.access.retry") as? UIButton)
let cancel = try XCTUnwrap(find(controller.view, "deregister.access.cancel") as? UIButton)
XCTAssertTrue(retry.isEnabled)
XCTAssertFalse(retry.isHidden)
XCTAssertTrue(cancel.isHidden)
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertFalse(texts.contains("debug-internal-error"))
XCTAssertFalse(texts.contains("本机提交记录"))
window.layoutIfNeeded()
capture(window, name: failed ? "redesign-query-failed" : "redesign-status-unknown")
}
}
private weak var previousKeyWindow: UIWindow?
private func makeWindow(_ controller: UIViewController, size: CGSize) -> UIWindow {
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
previousKeyWindow = scene?.windows.first(where: \.isKeyWindow)
let window = scene.map { UIWindow(windowScene: $0) } ?? UIWindow(frame: CGRect(origin: .zero, size: size))
window.rootViewController = UINavigationController(rootViewController: controller)
window.makeKeyAndVisible()
window.frame = CGRect(origin: .zero, size: size)
window.layoutIfNeeded()
controller.loadViewIfNeeded()
return window
}
private func close(_ window: UIWindow) {
window.endEditing(true)
window.rootViewController?.dismiss(animated: false)
window.isHidden = true
window.rootViewController = nil
previousKeyWindow?.makeKeyAndVisible()
}
private func allViews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allViews($0) } }
private func find(_ view: UIView, _ id: String) -> UIView? { allViews(view).first { $0.accessibilityIdentifier == id } }
private func waitUntil(_ condition: () -> Bool) async {
for _ in 0..<150 {
if condition() { return }
try? await Task.sleep(nanoseconds: 10_000_000)
}
XCTFail("等待页面状态超时")
}
private func capture(_ window: UIWindow, name: String) {
let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
})
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
/// 可控制部分成功与异步停顿的Mock;不会读写AppStore或调用真实接口。
@MainActor
private final class CombinedDeregistrationService: StoreAccountDeregistrationServing {
var userID = 101
var financeID = 201
var walletFen: Int64 = 0
var points: Int64 = 0
var walletWaived = false
var pointsWaived = false
var extraBlockers: [[String: String]] = []
var mutations: [String] = []
var pointsError: Error?
var readError: Error?
var onWallet: (() -> Void)?
var confirmWalletOnServer = true
var commitPointsBeforeError = false
var holdWallet = false
var walletContinuation: CheckedContinuation<Void, Never>?
var statusOverride: StoreAccountDeregistrationStatus?
var statusCount = 0
var eligibilityCount = 0
var applyError: Error?
var onApply: (() -> Void)?
var holdApply = false
var applyContinuation: CheckedContinuation<Void, Never>?
var holdNextStatus = false
var statusContinuation: CheckedContinuation<Void, Never>?
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
eligibilityCount += 1
if let readError { throw readError }
var blockers = extraBlockers
if !walletWaived { blockers.append(["code": "WALLET_WAIVER_MISSING", "message": "请确认现金余额", "action": "confirm_wallet_waiver"]) }
if !pointsWaived { blockers.append(["code": "POINTS_WAIVER_MISSING", "message": "请确认积分", "action": "confirm_points_waiver"]) }
return try StoreAccountDeregistrationFixtures.eligibility(overrides: [
"store_user_id": userID, "finance_identity_id": financeID,
"can_apply": blockers.isEmpty, "wallet_balance_fen": walletFen,
"wallet_balance": String(format: "%.2f", Double(walletFen) / 100),
"points_balance": points, "wallet_waived": walletWaived, "points_waived": pointsWaived,
"unfulfilled_count": 0, "fulfillment_in_progress_count": 0,
"risk_end_at": NSNull(), "eligible_at": NSNull(),
"blockers": blockers, "deregister": StoreAccountDeregistrationFixtures.draftRecord()
])
}
func status() async throws -> StoreAccountDeregistrationStatus {
statusCount += 1
if holdNextStatus {
holdNextStatus = false
await withCheckedContinuation { statusContinuation = $0 }
}
if let readError { throw readError }
return try statusOverride ?? StoreAccountDeregistrationFixtures.draftStatus()
}
func waiveWallet() async throws {
mutations.append("wallet")
if holdWallet { await withCheckedContinuation { walletContinuation = $0 } }
walletWaived = confirmWalletOnServer
onWallet?()
}
func waivePoints() async throws {
mutations.append("points")
if commitPointsBeforeError { pointsWaived = true }
if let pointsError { throw pointsError }
pointsWaived = true
}
func sendSMS() async throws { mutations.append("sms") }
func apply(smsCode: String, reason: String) async throws {
mutations.append("apply")
if holdApply { await withCheckedContinuation { applyContinuation = $0 } }
if let applyError { throw applyError }
onApply?()
}
func finishApply() {
let continuation = applyContinuation
applyContinuation = nil
continuation?.resume()
}
func finishStatus() {
let continuation = statusContinuation
statusContinuation = nil
continuation?.resume()
}
func cancel() async throws { mutations.append("cancel") }
}
@@ -0,0 +1,139 @@
import Foundation
@testable import suixinkan
/// 从已实测响应整理的脱敏测试夹具;覆盖变化场景时显式覆盖字段,不宣称为真实服务端返回。
enum StoreAccountDeregistrationFixtures {
/// 2026-08-28 15:29 实测 userinfo 的150015响应;无time字段,data.status为字符串。
static let observedCoolingRestrictionEnvelope = Data(#"""
{
"code": 150015,
"msg": "账号处于注销冷静期,请先撤销注销后再继续使用",
"data": {
"status": "cooling",
"cooling_until": "2026-09-04 15:27:24",
"remaining_seconds": 604681
}
}
"""#.utf8)
/// 同次150015请求后的真实status响应,仅将注销记录ID替换为测试值301。
static let observedCoolingStatusEnvelope = Data(#"""
{
"code": 100000,
"msg": "success",
"data": {
"deregister": {
"id": 301,
"status": 1,
"status_label": "冷静期中",
"reason": "Test account deregistration",
"apply_time": "2026-08-28 15:27:24",
"cooling_until": "2026-09-04 15:27:24",
"remaining_seconds": 604681,
"cancel_time": null,
"blocked_code": "",
"blocked_reason": "",
"completed_at": null
}
},
"time": "2026-08-28 15:29:22"
}
"""#.utf8)
/// 真实样例的结构,身份 ID 替换为测试用的 101/201。
static func eligibilityData(overrides: [String: Any] = [:]) throws -> Data {
var data: [String: Any] = [
"can_apply": false, "store_user_id": 101, "finance_identity_id": 201,
"wallet_balance_fen": 0, "wallet_balance": "0.00", "points_balance": 0,
"wallet_waived": false, "points_waived": false,
"unfulfilled_count": 37, "fulfillment_in_progress_count": 0,
"risk_end_at": "2026-08-27 15:47:32", "eligible_at": "2026-09-03 15:47:32", "risk_window_hours": 168,
"blockers": [
["code": "ORDER_UNFULFILLED", "message": "账户仍有未履约订单或带单", "action": "complete_orders"],
["code": "RISK_WINDOW_NOT_EXPIRED", "message": "最后一笔业务的风险结束时间尚未超过7天", "action": "wait_risk_window"],
["code": "WALLET_WAIVER_MISSING", "message": "请先确认放弃现金余额", "action": "confirm_wallet_waiver"],
["code": "POINTS_WAIVER_MISSING", "message": "请先确认放弃积分", "action": "confirm_points_waiver"],
],
"deregister": NSNull(),
]
data.merge(overrides) { _, new in new }
return try JSONSerialization.data(withJSONObject: data)
}
/// 解码已实测结构,供协议替身返回值使用。
static func eligibility(overrides: [String: Any] = [:]) throws -> StoreAccountDeregistrationEligibility {
try JSONDecoder().decode(StoreAccountDeregistrationEligibility.self, from: eligibilityData(overrides: overrides))
}
/// 构造所有条件满足的规则测试输入;不是通过真实资产放弃或申请获得的响应。
static func ready(overrides: [String: Any] = [:]) throws -> StoreAccountDeregistrationEligibility {
var fields: [String: Any] = ["can_apply": true, "wallet_waived": true, "points_waived": true,
"unfulfilled_count": 0, "blockers": []]
fields.merge(overrides) { _, new in new }
return try eligibility(overrides: fields)
}
/// 2026-08-28 零资产确认后的真实草稿结构,仅将记录 ID 替换为测试值 301。
static func draftRecord(overrides: [String: Any] = [:]) -> [String: Any] {
var record: [String: Any] = [
"id": 301, "status": 0, "status_label": "待确认", "reason": "",
"apply_time": NSNull(), "cooling_until": NSNull(), "remaining_seconds": 0,
"cancel_time": NSNull(), "blocked_code": "", "blocked_reason": "", "completed_at": NSNull(),
]
record.merge(overrides) { _, new in new }
return record
}
/// 解码实测草稿状态;覆盖字段时用于验证未知或矛盾记录不会放行。
static func draftStatus(overrides: [String: Any] = [:]) throws -> StoreAccountDeregistrationStatus {
try JSONDecoder().decode(StoreAccountDeregistrationStatus.self,
from: JSONSerialization.data(withJSONObject: ["deregister": draftRecord(overrides: overrides)]))
}
/// 两次真实确认之间及之后的条件结构,没有历史业务时风险时间为 null。
static func draftEligibility(pointsWaived: Bool) throws -> StoreAccountDeregistrationEligibility {
try eligibility(overrides: [
"can_apply": pointsWaived, "wallet_waived": true, "points_waived": pointsWaived,
"unfulfilled_count": 0, "risk_end_at": NSNull(), "eligible_at": NSNull(),
"blockers": pointsWaived ? [] : [
["code": "POINTS_WAIVER_MISSING", "message": "请先确认放弃积分", "action": "confirm_points_waiver"],
],
"deregister": draftRecord(),
])
}
/// 实测的冷静期和主动撤销记录,ID 脱敏;覆盖字段只用于防御性测试。
static func lifecycleRecord(cancelled: Bool = false, overrides: [String: Any] = [:]) -> [String: Any] {
var record = draftRecord(overrides: [
"status": cancelled ? 9 : 1, "status_label": cancelled ? "已撤销" : "冷静期中",
"reason": "API test; cancel immediately", "apply_time": "2026-08-28 14:23:40",
"cooling_until": "2026-09-04 14:23:40", "remaining_seconds": cancelled ? 0 : 604776,
"cancel_time": cancelled ? "2026-08-28 14:24:04" : NSNull(),
"blocked_code": cancelled ? "CANCELLED_BY_USER" : "",
"blocked_reason": cancelled ? "用户主动撤销注销" : "",
])
record.merge(overrides) { _, new in new }
return record
}
/// 解码冷静期/已撤销实测样例。
static func lifecycleStatus(cancelled: Bool = false, overrides: [String: Any] = [:]) throws -> StoreAccountDeregistrationStatus {
try JSONDecoder().decode(StoreAccountDeregistrationStatus.self,
from: JSONSerialization.data(withJSONObject: ["deregister": lifecycleRecord(cancelled: cancelled, overrides: overrides)]))
}
/// 冷静期及撤销后真实查询均返回两项确认失效,不能在冷静期重新确认资产。
static func lifecycleEligibility(cancelled: Bool = false) throws -> StoreAccountDeregistrationEligibility {
try eligibility(overrides: ["unfulfilled_count": 0, "risk_end_at": NSNull(), "eligible_at": NSNull(),
"blockers": [
["code": "WALLET_WAIVER_MISSING", "message": "请先确认放弃现金余额", "action": "confirm_wallet_waiver"],
["code": "POINTS_WAIVER_MISSING", "message": "请先确认放弃积分", "action": "confirm_points_waiver"],
], "deregister": lifecycleRecord(cancelled: cancelled)])
}
/// 统一成功 Envelope,仅用于 MockURLSession。
static func envelope(_ data: Data) throws -> Data {
try JSONSerialization.data(withJSONObject: ["code": 100000, "msg": "success",
"data": JSONSerialization.jsonObject(with: data)])
}
}
@@ -0,0 +1,450 @@
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<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") }
}
@@ -0,0 +1,622 @@
import UIKit
import XCTest
@testable import suixinkan
/// 已确认的旧接口条件与交互规则测试;不通过真实网络修改账号或资产。
@MainActor
final class StoreAccountDeregistrationViewModelTests: XCTestCase {
/// 冷静期虽然返回缺少资产确认的阻断项,也不能重新确认资产或提交。
func testCoolingPreventsWaiversAndDuplicateApplication() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.lifecycleEligibility()
service.currentStatus = try StoreAccountDeregistrationFixtures.lifecycleStatus()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertFalse(model.canConfirm(.wallet))
XCTAssertFalse(model.canConfirm(.points))
XCTAssertFalse(model.canContinue)
await model.confirm(.wallet, snapshot: service.current, api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "测试", api: service)
XCTAssertTrue(service.mutations.isEmpty)
}
/// 新撤销记录解除本机提交保护,但两种资产仍须分别重新确认。
func testCancelledApplicationRestoresPreparationWithFreshWaivers() async throws {
let name = "DeregistrationCancelledFlow.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
try store.recordSubmissionIntent()
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.lifecycleEligibility(cancelled: true)
service.currentStatus = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await model.refresh(api: service)
XCTAssertFalse(model.submissionAttempted)
XCTAssertFalse(store.hasUnresolvedSubmission)
XCTAssertEqual(model.step, .conditions)
XCTAssertTrue(model.canConfirm(.wallet))
XCTAssertTrue(model.canConfirm(.points))
XCTAssertFalse(model.canContinue)
XCTAssertTrue(service.mutations.isEmpty)
}
/// 重新申请响应丢失时,旧撤销记录不能让当前或重建页面重复申请。
func testReapplicationDoesNotReconcileAgainstPreviousCancelledStatus() async throws {
let name = "DeregistrationReapplication.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let service = try DeregistrationServiceStub()
service.currentStatus = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
service.current = try StoreAccountDeregistrationFixtures.ready(overrides: [
"deregister": StoreAccountDeregistrationFixtures.lifecycleRecord(cancelled: true)])
service.applyError = APIError.networkFailed("lost response")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "测试", api: service)
await model.refresh(api: service)
XCTAssertTrue(model.submissionAttempted)
let restored = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await restored.refresh(api: service)
restored.beginVerification()
await restored.submit(smsCode: "907182", reason: "测试", api: service)
XCTAssertTrue(restored.submissionAttempted)
XCTAssertTrue(store.hasUnresolvedSubmission)
XCTAssertEqual(service.mutations, ["apply"])
}
/// 零余额真实样例仍有两项确认阻断,不允许继续。
func testObservedEligibilityRequiresSeparateZeroAssetWaivers() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertEqual(model.eligibility?.walletBalanceFen, 0)
XCTAssertEqual(model.eligibility?.pointsBalance, 0)
XCTAssertEqual(model.eligibility?.blockers.count, 4)
XCTAssertTrue(model.canConfirm(.wallet))
XCTAssertTrue(model.canConfirm(.points))
XCTAssertFalse(model.canContinue)
XCTAssertEqual(service.mutations, [])
}
/// 确认两种资产必须分别调用接口,且 UI 状态只跟随重新查询结果。
func testWalletAndPointsConfirmationsRemainIndependent() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
let snapshot = try XCTUnwrap(model.eligibility)
service.afterWaiver = try StoreAccountDeregistrationFixtures.eligibility(overrides: ["wallet_waived": true])
await model.confirm(.wallet, snapshot: snapshot, api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertFalse(model.canConfirm(.wallet))
XCTAssertTrue(model.canConfirm(.points))
service.afterWaiver = try StoreAccountDeregistrationFixtures.ready()
await model.confirm(.points, snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertTrue(model.canContinue)
}
/// 真实后端第一次确认即创建草稿;第二次确认必须仍可执行,之后才允许验证码步骤。
func testObservedDraftCreatedByFirstWaiverCanContinueSecondWaiver() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
service.afterWaiver = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: false)
service.statusAfterWaiver = try StoreAccountDeregistrationFixtures.draftStatus()
await model.confirm(.wallet, snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(model.step, .conditions)
XCTAssertTrue(model.status?.isUnsubmittedDraft == true)
XCTAssertFalse(model.canConfirm(.wallet))
XCTAssertTrue(model.canConfirm(.points))
XCTAssertFalse(model.canContinue)
service.afterWaiver = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
await model.confirm(.points, snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertTrue(model.canContinue)
model.beginVerification()
XCTAssertEqual(model.step, .verification)
XCTAssertFalse(model.submissionAttempted)
}
/// 重建页面可继续已确认的草稿,短信和申请动作仅发送给内存 Mock。
func testReopenedDraftCanVerifyAndSubmitWithoutRepeatingWaivers() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
service.currentStatus = try StoreAccountDeregistrationFixtures.draftStatus()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertTrue(model.canContinue)
XCTAssertFalse(model.canConfirm(.wallet))
XCTAssertFalse(model.canConfirm(.points))
model.beginVerification()
let sent = await model.sendSMS(api: service)
XCTAssertTrue(sent)
await model.submit(smsCode: "907182", reason: "测试", api: service)
XCTAssertEqual(service.mutations, ["sms", "apply"])
XCTAssertTrue(model.submissionAccepted)
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertFalse(model.canContinue)
}
/// 状态查询与条件查询任一出现未知记录,都不能以另一份草稿结果绕过限制。
func testDraftDoesNotOverrideUnknownRecordFromOtherQuery() async throws {
for unknownStatus in [false, true] {
let service = try DeregistrationServiceStub()
let draft = try StoreAccountDeregistrationFixtures.draftStatus()
let unknown = StoreAccountDeregistrationStatus(deregister: .object(["status": .number(987)]))
service.currentStatus = unknownStatus ? unknown : draft
service.current = unknownStatus ? try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
: try StoreAccountDeregistrationFixtures.ready(overrides: ["deregister": ["status": 987]])
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertFalse(model.canContinue)
XCTAssertFalse(model.canConfirm(.points))
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertTrue(service.mutations.isEmpty)
}
}
/// 弹窗显示后余额变化时不能提交旧快照对应的放弃确认。
func testBalanceChangeDuringConfirmationPreventsWaiver() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
let snapshot = try XCTUnwrap(model.eligibility)
service.current = try StoreAccountDeregistrationFixtures.eligibility(overrides: ["points_balance": 1])
await model.confirm(.wallet, snapshot: snapshot, api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertTrue(model.errorMessage?.contains("余额已变化") == true)
XCTAssertNil(model.eligibility)
}
/// 服务端确认请求成功但查询仍为 false 时不能乐观勾选。
func testWaiverDoesNotOptimisticallyUpdateConfirmation() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
await model.confirm(.wallet, snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(model.eligibility?.walletWaived, false)
XCTAssertFalse(model.canContinue)
}
/// 查询返回另一门店用户时不可显示其资产或继续操作。
func testWrongIdentityResponseIsDiscarded() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready(overrides: ["store_user_id": 102])
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertNil(model.eligibility)
XCTAssertFalse(model.canContinue)
XCTAssertNotNil(model.errorMessage)
}
/// 即使历史快照可提交,任何一次刷新失败后都必须重新核验。
func testRefreshFailureInvalidatesPreviouslyReadySnapshot() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertTrue(model.canContinue)
service.readError = APIError.networkFailed("offline")
await model.refresh(api: service)
XCTAssertFalse(model.canContinue)
XCTAssertNil(model.eligibility)
}
/// 任意未知记录都保持状态待核实;不推断完成、恢复或允许重新申请。
func testUnknownRequestDisablesAllNewApplicationActions() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.currentStatus = StoreAccountDeregistrationStatus(deregister: .object(["unknown_status": .number(17)]))
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertFalse(model.canContinue)
XCTAssertFalse(model.canConfirm(.wallet))
let sent = await model.sendSMS(api: service)
XCTAssertFalse(sent)
await model.submit(smsCode: "654321", reason: "测试", api: service)
XCTAssertTrue(service.mutations.isEmpty)
}
/// 发送短信前重新检查全部条件,不能使用进入验证码页时的旧快照。
func testSMSRechecksEligibilityBeforeSending() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
service.current = try StoreAccountDeregistrationFixtures.eligibility()
let sent = await model.sendSMS(api: service)
XCTAssertFalse(sent)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(model.step, .conditions)
}
/// 提交前仍须重新核验,不因先前已发送短信而绕过余额变化。
func testSubmitRechecksWaiversBeforeSending() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
service.current = try StoreAccountDeregistrationFixtures.ready(overrides: ["points_waived": false])
await model.submit(smsCode: "654321", reason: "不再使用", api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertFalse(model.submissionAttempted)
}
/// 实际输入交给后端校验,不使用固定验证码;成功仅记录申请已提交。
func testSubmitPassesUserInputAndPreventsRepeatSubmission() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: " 907182 ", reason: " 不再使用 ", api: service)
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertEqual(service.mutations, ["apply"])
XCTAssertEqual(service.lastCode, "907182")
XCTAssertEqual(service.lastReason, "不再使用")
XCTAssertTrue(model.submissionAccepted)
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertFalse(model.canContinue)
}
/// 申请响应丢失可能已被服务端受理,不能直接重试或显示失败后可重新申请。
func testLostApplyResponsePreventsAutomaticRetry() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.applyError = APIError.networkFailed("response lost")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertEqual(service.mutations, ["apply"])
XCTAssertTrue(model.submissionAttempted)
XCTAssertFalse(model.submissionAccepted)
XCTAssertEqual(model.step, .unresolvedRequest)
}
/// 服务端明确拒绝验证码时允许重新核验,但不能保留之前的提交权限。
func testServerRejectionRequiresFreshEligibility() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.applyError = APIError.serverCode(100001, "验证码错误")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertFalse(model.submissionAttempted)
XCTAssertFalse(model.canContinue)
XCTAssertEqual(model.errorMessage, "验证码错误")
await model.refresh(api: service)
XCTAssertTrue(model.canContinue)
}
/// 展示全部真实阻断项,初始加载不会调用任何修改接口。
func testConditionsPageDisplaysAllBlockersWithoutMutation() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service)
controller.loadViewIfNeeded()
let views = allSubviews(controller.view)
let label = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.blockers" } as? UILabel)
for _ in 0..<100 {
if label.text?.contains("账户仍有未履约订单或带单") == true { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
for blocker in service.current.businessBlockers { XCTAssertTrue(label.text?.contains(blocker.message) == true) }
let wallet = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.wallet.state" } as? UILabel)
let points = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.points.state" } as? UILabel)
XCTAssertEqual(wallet.text, "待确认放弃")
XCTAssertEqual(points.text, "待确认放弃")
XCTAssertTrue(service.mutations.isEmpty)
let next = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.continue" } as? UIButton)
XCTAssertFalse(next.isEnabled)
}
/// 验证页使用可注入服务并在真机渲染条件页面截图,供布局检查;不操作真实资产。
func testConditionsPageRendersOnPhysicalDevice() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店·摄影师", viewModel: model, api: service)
let navigation = UINavigationController(rootViewController: controller)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = navigation
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
let label = try XCTUnwrap(allSubviews(controller.view).first { $0.accessibilityIdentifier == "deregister.blockers" } as? UILabel)
for _ in 0..<100 {
if label.text?.contains("账户仍有未履约订单或带单") == true { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
window.layoutIfNeeded()
XCTAssertTrue(label.text?.contains("账户仍有未履约订单或带单") == true)
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext)
}
let attachment = XCTAttachment(image: image)
attachment.name = "store-deregistration-conditions"
attachment.lifetime = .keepAlways
add(attachment)
XCTAssertTrue(service.mutations.isEmpty)
}
/// 在真机渲染已确认草稿和验证码页;仅点击本地下一步,不触发任何真实或 Mock 修改请求。
func testDraftPageContinuesToVerificationAndRendersWithoutMutation() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
service.currentStatus = try StoreAccountDeregistrationFixtures.draftStatus()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UINavigationController(rootViewController: controller)
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
let views = allSubviews(controller.view)
let next = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.continue" } as? UIButton)
let status = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.status" } as? UILabel)
for _ in 0..<100 {
if next.isEnabled { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertTrue(next.isEnabled)
XCTAssertTrue(status.isHidden)
XCTAssertEqual(next.configuration?.title, "下一步")
XCTAssertFalse(views.contains { ($0 as? UILabel)?.text?.contains("待确认草稿") == true })
XCTAssertFalse(controller.navigationItem.hidesBackButton)
for stage in ["draft", "verification"] {
if stage == "verification" {
next.sendActions(for: .touchUpInside)
for _ in 0..<100 {
if model.step == .verification { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
}
window.layoutIfNeeded()
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext)
}
let attachment = XCTAttachment(image: image)
attachment.name = "store-deregistration-\(stage)"
attachment.lifetime = .keepAlways
add(attachment)
}
XCTAssertEqual(model.step, .verification)
let code = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.code" } as? UITextField)
XCTAssertEqual(code.superview?.isHidden, false)
XCTAssertTrue(status.isHidden)
XCTAssertEqual(code.textContentType, .oneTimeCode)
XCTAssertTrue(service.mutations.isEmpty)
}
private func allSubviews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews($0) } }
/// 本机提交意图跨实例保存,且不能传播到其他门店或正式环境。
func testSubmissionIntentIsPersistentAndIsolatedByIdentityAndEnvironment() throws {
let name = "DeregistrationSubmissionStore.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let first = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let second = StoreAccountDeregistrationSubmissionStore(storeUserID: "102", environment: .testing, defaults: defaults)
let production = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .production, defaults: defaults)
try first.recordSubmissionIntent()
XCTAssertTrue(StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults).hasUnresolvedSubmission)
XCTAssertFalse(second.hasUnresolvedSubmission)
XCTAssertFalse(production.hasUnresolvedSubmission)
try second.recordSubmissionIntent()
first.clearRejectedSubmission()
XCTAssertFalse(first.hasUnresolvedSubmission)
XCTAssertTrue(second.hasUnresolvedSubmission)
}
/// 申请响应丢失后创建新的 ViewModel,也不能因为暂时返回 null 再次申请。
func testLostApplyResponsePreventsRetryAcrossViewModelRecreation() async throws {
let name = "DeregistrationSubmissionRecovery.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.applyError = APIError.networkFailed("lost response")
let original = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await original.refresh(api: service)
original.beginVerification()
await original.submit(smsCode: "907182", reason: "不再使用", api: service)
let restored = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await restored.refresh(api: service)
restored.beginVerification()
await restored.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertTrue(store.hasUnresolvedSubmission)
XCTAssertEqual(restored.step, .unresolvedRequest)
XCTAssertFalse(restored.canContinue)
XCTAssertEqual(service.mutations, ["apply"])
}
/// 只有明确的服务端拒绝才能清理本次提交意图。
func testExplicitApplyRejectionClearsSubmissionIntent() async throws {
let name = "DeregistrationSubmissionRejection.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.applyError = APIError.serverCode(100001, "验证码无效")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertFalse(store.hasUnresolvedSubmission)
XCTAssertFalse(model.submissionAttempted)
}
}
/// 仅内存中的服务替身,独立于 AppStore 和真实网络,不清理设备现有登录态。
@MainActor
private final class DeregistrationServiceStub: StoreAccountDeregistrationServing {
var current: StoreAccountDeregistrationEligibility
var currentStatus = StoreAccountDeregistrationStatus(deregister: .null)
var afterWaiver: StoreAccountDeregistrationEligibility?
var statusAfterWaiver: StoreAccountDeregistrationStatus?
var readError: Error?
var applyError: Error?
var mutations: [String] = []
var lastCode: String?
var lastReason: String?
init() throws { current = try StoreAccountDeregistrationFixtures.eligibility() }
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
if let readError { throw readError }
return current
}
func status() async throws -> StoreAccountDeregistrationStatus {
if let readError { throw readError }
return currentStatus
}
func waiveWallet() async throws {
mutations.append("wallet")
if let afterWaiver { current = afterWaiver }
if let statusAfterWaiver { currentStatus = statusAfterWaiver }
}
func waivePoints() async throws {
mutations.append("points")
if let afterWaiver { current = afterWaiver }
if let statusAfterWaiver { currentStatus = statusAfterWaiver }
}
func sendSMS() async throws { mutations.append("sms") }
func apply(smsCode: String, reason: String) async throws {
mutations.append("apply")
lastCode = smsCode
lastReason = reason
if let applyError { throw applyError }
}
func cancel() async throws { mutations.append("cancel") }
}
/// 真实响应模型严格解码测试,不把未知或缺失的权限字段默认成允许注销。
final class StoreAccountDeregistrationResponseTests: XCTestCase {
/// 1/9 的区别以完整服务端状态为准,撤销记录仍保留冷静期时间。
func testObservedCoolingAndCancelledRecordsAreDistinct() throws {
let cooling = try StoreAccountDeregistrationFixtures.lifecycleStatus()
let cancelled = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
XCTAssertTrue(cooling.isCooling)
XCTAssertFalse(cooling.permitsPreparation)
XCTAssertTrue(cancelled.isCancelled)
XCTAssertTrue(cancelled.permitsPreparation)
XCTAssertFalse(cancelled.isCooling)
XCTAssertEqual(cooling.coolingUntil, cancelled.coolingUntil)
XCTAssertEqual(cancelled.remainingSeconds, 0)
XCTAssertNotNil(cancelled.cancellationFingerprint)
}
/// 缺字段、终态矛盾和未知数字均保持受限,不推断尚未实测的完成/阻断枚举。
func testIncompleteOrContradictoryLifecycleDoesNotAllowBusiness() throws {
for cancelled in [true, false] {
for key in StoreAccountDeregistrationFixtures.lifecycleRecord(cancelled: cancelled).keys {
var record = StoreAccountDeregistrationFixtures.lifecycleRecord(cancelled: cancelled)
record.removeValue(forKey: key)
let status = try JSONDecoder().decode(StoreAccountDeregistrationStatus.self,
from: JSONSerialization.data(withJSONObject: ["deregister": record]))
XCTAssertFalse(status.isCooling, key)
XCTAssertFalse(status.permitsPreparation, key)
}
}
for fields: [String: Any] in [["status": 2], ["status": 3], ["status": "9"], ["id": 0],
["cancel_time": NSNull()], ["completed_at": "2026-09-04 14:23:40"], ["remaining_seconds": 1]] {
XCTAssertFalse(try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true, overrides: fields).permitsPreparation)
}
}
/// 新结构、旧版本布尔值与损坏的本地记录分别处理,不能静默清掉未知提交意图。
func testCancellationOnlyClearsSupportedSubmissionIntentFormats() throws {
let name = "DeregistrationIntentCompatibility.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let key = "store_deregister_submission_intent_v1_\(APIEnvironment.testing.baseURL.host!)_101"
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let cancelled = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
defaults.set(true, forKey: key)
XCTAssertTrue(store.clearCancelledSubmission(matching: cancelled))
XCTAssertFalse(store.hasUnresolvedSubmission)
for invalid: Any in [false, "unknown", ["unexpected": "value"], 2] {
defaults.set(invalid, forKey: key)
XCTAssertFalse(store.clearCancelledSubmission(matching: cancelled))
XCTAssertTrue(store.hasUnresolvedSubmission)
}
}
/// 实测草稿及无历史业务的 null 风险时间均可正常解析,不制造额外风险等待期。
func testObservedDraftAndNullRiskTimesAreSupported() throws {
let status = try StoreAccountDeregistrationFixtures.draftStatus()
let eligibility = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
XCTAssertTrue(status.isUnsubmittedDraft)
XCTAssertTrue(status.permitsPreparation)
XCTAssertTrue(eligibility.permitsApplication)
XCTAssertNil(eligibility.riskEndAt)
XCTAssertNil(eligibility.eligibleAt)
XCTAssertFalse(try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: false).permitsApplication)
}
/// 未实测数字、字符串或缺失状态都不能根据“待确认”文案猜测为草稿。
func testUnknownOrContradictoryDraftFieldsDoNotPermitPreparation() throws {
let cases: [[String: Any]] = [
["status": 987], ["status": "0"], ["status": false], ["status": NSNull()],
["id": 0], ["id": 1.5], ["status_label": ""], ["reason": NSNull()],
["apply_time": "2026-08-28 14:00:00"], ["cooling_until": "2026-09-04 14:00:00"],
["remaining_seconds": 1], ["cancel_time": "2026-08-28 14:00:00"],
["completed_at": "2026-08-28 14:00:00"], ["blocked_code": "UNKNOWN"],
["blocked_reason": "条件改变"],
]
for fields in cases {
XCTAssertFalse(try StoreAccountDeregistrationFixtures.draftStatus(overrides: fields).permitsPreparation,
"不能放行矛盾字段:\(fields)")
}
for key in StoreAccountDeregistrationFixtures.draftRecord().keys {
var record = StoreAccountDeregistrationFixtures.draftRecord()
record.removeValue(forKey: key)
let data = try JSONSerialization.data(withJSONObject: ["deregister": record])
let status = try JSONDecoder().decode(StoreAccountDeregistrationStatus.self, from: data)
XCTAssertFalse(status.permitsPreparation, "缺少 \(key) 不能当作完整草稿")
}
}
func testMissingCriticalFieldsFailDecoding() throws {
let data = try StoreAccountDeregistrationFixtures.eligibilityData()
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
for field in ["can_apply", "wallet_balance_fen", "points_balance", "wallet_waived", "points_waived", "blockers", "deregister"] {
var incomplete = object
incomplete.removeValue(forKey: field)
XCTAssertThrowsError(try JSONDecoder().decode(StoreAccountDeregistrationEligibility.self,
from: JSONSerialization.data(withJSONObject: incomplete)), field)
}
XCTAssertThrowsError(try JSONDecoder().decode(StoreAccountDeregistrationStatus.self, from: Data("{}".utf8)))
}
func testCanApplyAloneDoesNotBypassOtherConditions() throws {
XCTAssertFalse(try StoreAccountDeregistrationFixtures.eligibility(overrides: ["can_apply": true]).permitsApplication)
for fields: [String: Any] in [["wallet_balance_fen": -1], ["points_balance": -1], ["wallet_waived": false],
["unfulfilled_count": 1], ["fulfillment_in_progress_count": 1],
["deregister": ["unknown": true]]] {
XCTAssertFalse(try StoreAccountDeregistrationFixtures.ready(overrides: fields).permitsApplication)
}
}
func testUnknownBlockerIsPreservedAndStillBlocksSubmission() throws {
let model = try StoreAccountDeregistrationFixtures.ready(overrides: ["blockers": [
["code": "FUTURE_RULE", "message": "新的限制", "action": "future_action"],
]])
XCTAssertEqual(model.blockers.first?.message, "新的限制")
XCTAssertFalse(model.permitsApplication)
XCTAssertTrue(model.blockers.first?.guidance.contains("客服") == true)
}
}