Files
suixinkan_uikit/suixinkanTests/StoreAccountDeregistrationViewModelTests.swift

661 lines
37 KiB
Swift

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 testSubmitRejectsMissingOrOversizedRequiredInputWithoutSideEffects() async throws {
let cases = [
(code: "", reason: "正常原因", message: "请输入收到的短信验证码"),
(code: "654321", reason: "", message: "请输入注销原因"),
(code: "654321", reason: " \n", message: "请输入注销原因"),
(code: "654321", reason: String(repeating: "原", count: 51), message: "注销原因不能超过 50 个字")
]
for item in cases {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let suite = "DeregistrationRequiredReason.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let store = StoreAccountDeregistrationSubmissionStore(
storeUserID: "101", environment: .testing, defaults: defaults
)
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await model.refresh(api: service)
model.beginVerification()
let statusCount = service.statusCount
let eligibilityCount = service.eligibilityCount
await model.submit(smsCode: item.code, reason: item.reason, api: service)
XCTAssertEqual(model.errorMessage, item.message)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(service.statusCount, statusCount)
XCTAssertEqual(service.eligibilityCount, eligibilityCount)
XCTAssertFalse(model.submissionAttempted)
XCTAssertFalse(store.hasUnresolvedSubmission)
}
}
/// 申请响应丢失可能已被服务端受理,不能直接重试或显示失败后可重新申请。
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 testVerificationCodeRejectionStaysInVerification() 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)
XCTAssertTrue(model.canContinue)
XCTAssertEqual(model.step, .verification)
XCTAssertNotNil(model.eligibility)
XCTAssertEqual(model.errorMessage, "验证码错误")
}
/// 展示全部真实阻断项,初始加载不会调用任何修改接口。
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 eligibilityCount = 0
var statusCount = 0
var lastCode: String?
var lastReason: String?
init() throws { current = try StoreAccountDeregistrationFixtures.eligibility() }
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
eligibilityCount += 1
if let readError { throw readError }
return current
}
func status() async throws -> StoreAccountDeregistrationStatus {
statusCount += 1
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)
}
}