Files
suixinkan_uikit/suixinkanTests/StoreAccountDeregistrationAccessTests.swift

400 lines
22 KiB
Swift

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"
session.accountDisplayName = "示例门店"
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)))
XCTAssertTrue(controller.navigationItem.hidesBackButton)
XCTAssertEqual((window.rootViewController as? UINavigationController)?.interactivePopGestureRecognizer?.isEnabled, false)
let texts = views.compactMap { ($0 as? UILabel)?.text }
XCTAssertTrue(texts.contains("重新登录该身份自动撤销申请"))
XCTAssertNil(views.first { $0.accessibilityIdentifier == "deregister.access.conditions" })
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)
}
}