feat: 增加门店身份注销流程
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user