661 lines
37 KiB
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)
|
|
}
|
|
}
|