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) } }