import UIKit import XCTest @testable import suixinkan /// 只读进入核验与受限错误隔离测试,不使用设备上的真实会话。 @MainActor final class StoreAccountDeregistrationAccessTests: XCTestCase { /// 已知冷静期后重查到旧null/草稿不能放行,完整撤销记录才解除限制。 func testObservedCoolingDoesNotRegressToOldUnsubmittedState() async throws { for stale in [StoreAccountDeregistrationStatus(deregister: .null), try StoreAccountDeregistrationFixtures.draftStatus()] { let service = DeregistrationStatusStub() service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus().deregister let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) service.result = stale.deregister await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .unresolved) service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true).deregister await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .allowed) } } /// 在 iPhone 上渲染不可返回的冷静期页面,只保留退出和撤销入口。 func testCoolingPageRendersCancellationEntryOnPhysicalDevice() async throws { let name = "DeregistrationCoolingPage.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! defer { defaults.removePersistentDomain(forName: name) } let session = AppSessionStore(defaults: defaults) session.accountType = .storeUser session.userId = "101" session.token = "isolated-token" session.accountDisplayName = "示例门店" let identity = try StoreAccountDeregistrationIdentity(session: session) let service = try DeregistrationCancellationStub() let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: service, session: session) { _ in XCTFail("冷静期不进入业务") } let window = UIWindow(frame: UIScreen.main.bounds) window.rootViewController = UINavigationController(rootViewController: controller) window.makeKeyAndVisible() defer { window.isHidden = true } controller.loadViewIfNeeded() let views = allSubviews(controller.view) let cancel = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.cancel" } as? UIButton) let message = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.message" } as? UILabel) for _ in 0..<100 { if !cancel.isHidden, cancel.isEnabled { break } try await Task.sleep(nanoseconds: 10_000_000) } window.layoutIfNeeded() XCTAssertFalse(cancel.isHidden) XCTAssertTrue(cancel.isEnabled) XCTAssertTrue(message.text?.contains("当前身份:") == true) let deadline = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.deadline" } as? UILabel) XCTAssertEqual(deadline.text, "2026-09-04 14:23:40") XCTAssertFalse(message.text?.contains("604776") == true) XCTAssertTrue(window.bounds.contains(cancel.convert(cancel.bounds, to: window))) XCTAssertTrue(controller.navigationItem.hidesBackButton) XCTAssertEqual((window.rootViewController as? UINavigationController)?.interactivePopGestureRecognizer?.isEnabled, false) let texts = views.compactMap { ($0 as? UILabel)?.text } XCTAssertTrue(texts.contains("重新登录该身份自动撤销申请")) XCTAssertNil(views.first { $0.accessibilityIdentifier == "deregister.access.conditions" }) let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { context in window.layer.render(in: context.cgContext) }) attachment.name = "store-deregistration-cooling" attachment.lifetime = .keepAlways add(attachment) XCTAssertEqual(service.calls, ["status"]) } private func allSubviews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews($0) } } /// 实测冷静期不进入业务;恰好到期仍待服务端复核,不能凭本机时间完成注销。 func testCoolingAndExactDeadlineRemainRestricted() async throws { for seconds in [604776, 0] { let service = DeregistrationStatusStub() service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus(overrides: ["remaining_seconds": seconds]).deregister let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .cooling) XCTAssertEqual(model.status?.remainingSeconds, Int64(seconds)) } } /// 撤销必须先核对申请,POST 后再次查询为已撤销才恢复;不会重复 POST。 func testCancelRechecksStatusAndRestoresBusinessOnlyAfterConfirmation() async throws { let service = try DeregistrationCancellationStub() let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) await model.cancel(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .allowed) XCTAssertEqual(service.calls, ["status", "status", "cancel", "status"]) await model.cancel(api: service, isCurrentIdentity: { true }) XCTAssertEqual(service.calls.filter { $0 == "cancel" }.count, 1) } /// 撤销响应丢失不自动重试,之后只读查到9即可恢复。 func testLostCancellationResponseDoesNotRetryMutation() async throws { let service = try DeregistrationCancellationStub() service.cancelError = APIError.networkFailed("lost response") let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) await model.cancel(api: service, isCurrentIdentity: { true }) guard case .failed = model.decision else { return XCTFail("结果未知不能直接放行") } await model.cancel(api: service, isCurrentIdentity: { true }) await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .allowed) XCTAssertEqual(service.calls.filter { $0 == "cancel" }.count, 1) } /// 后端未更新为撤销时继续受限,即使 POST 返回成功。 func testSuccessfulCancelWithoutConfirmedStatusDoesNotAllowBusiness() async throws { let service = try DeregistrationCancellationStub() service.afterCancel = service.current let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) await model.cancel(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .cooling) } /// 已看到冷静期后,取消前后的旧 null/草稿不能作为撤销成功的依据。 func testOldNullOrDraftDuringCancellationDoesNotAllowBusiness() async throws { for stale in [StoreAccountDeregistrationStatus(deregister: .null), try StoreAccountDeregistrationFixtures.draftStatus()] { for beforePost in [true, false] { let service = try DeregistrationCancellationStub() let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) if beforePost { service.current = stale } else { service.afterCancel = stale } await model.cancel(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .unresolved) XCTAssertEqual(service.calls.contains("cancel"), !beforePost) } } } /// 已被撤销或换成另一申请时,旧弹窗不能撤销新申请。 func testChangedApplicationBeforeCancelPreventsPost() async throws { for cancelled in [true, false] { let service = try DeregistrationCancellationStub() let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: cancelled, overrides: ["id": 302]) await model.cancel(api: service, isCurrentIdentity: { true }) XCTAssertFalse(service.calls.contains("cancel")) XCTAssertEqual(model.decision, cancelled ? .allowed : .cooling) } } /// 取消请求期间切换账号或收到更新的限制信号,旧结果不能解除新会话的限制。 func testCancellationDiscardsChangedIdentityAndNewRestriction() async throws { for changeIdentity in [true, false] { var current = true let service = try DeregistrationCancellationStub() let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { current }) service.onCancel = { if changeIdentity { current = false } else { model.recordRestriction() } } await model.cancel(api: service, isCurrentIdentity: { current }) XCTAssertEqual(model.decision, changeIdentity ? .obsolete : .unresolved) XCTAssertEqual(service.calls, ["status", "status", "cancel"]) } } /// 冷启动可用新撤销记录核销意图,但再次申请后相同的旧9状态不能放行。 func testCancelledStatusReconcilesOnlyNewCancellation() async throws { let name = "DeregistrationCancelledRecovery.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! defer { defaults.removePersistentDomain(forName: name) } let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults) let service = DeregistrationStatusStub() let cancelled = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true) service.result = cancelled.deregister try store.recordSubmissionIntent() let restored = StoreAccountDeregistrationAccessViewModel(submissionStore: store) await restored.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(restored.decision, .allowed) XCTAssertFalse(store.hasUnresolvedSubmission) try store.recordSubmissionIntent(previousStatus: cancelled) let reapplied = StoreAccountDeregistrationAccessViewModel(submissionStore: store) await reapplied.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(reapplied.decision, .unresolved) XCTAssertTrue(store.hasUnresolvedSubmission) service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true, overrides: ["cancel_time": "2026-08-28 15:00:00"]).deregister await reapplied.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(reapplied.decision, .allowed) XCTAssertFalse(store.hasUnresolvedSubmission) } func testRequiresSuccessfulNullStatusBeforeAllowingBusiness() async { let model = StoreAccountDeregistrationAccessViewModel() let service = DeregistrationStatusStub() XCTAssertEqual(model.decision, .notChecked) await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .allowed) XCTAssertEqual(service.queryCount, 1) } func testUnknownNonemptyRecordDoesNotAllowBusiness() async { let service = DeregistrationStatusStub() service.result = .object(["unknown_status": .number(17)]) let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .unresolved) } /// 资产确认创建的是未提交草稿,冷启动不能将其误当成冷静期限制。 func testObservedDraftAllowsBusinessWithoutMutation() async throws { let service = DeregistrationStatusStub() service.result = try StoreAccountDeregistrationFixtures.draftStatus().deregister let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .allowed) XCTAssertEqual(service.queryCount, 1) } /// 提交结果不明时,旧草稿响应不能清除待核实的提交意图。 func testLocalUnresolvedIntentStillBlocksOnDraftStatus() async throws { let service = DeregistrationStatusStub() service.result = try StoreAccountDeregistrationFixtures.draftStatus().deregister let model = StoreAccountDeregistrationAccessViewModel(requiresSubmissionReconciliation: true) await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .unresolved) } func testLocalUnresolvedIntentDoesNotAllowBusinessOnNullStatus() async { let model = StoreAccountDeregistrationAccessViewModel(requiresSubmissionReconciliation: true) await model.verify(api: DeregistrationStatusStub(), isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .unresolved) } func testFailureDoesNotMeanNoApplication() async { let service = DeregistrationStatusStub() service.error = APIError.networkFailed("offline") let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { true }) guard case .failed = model.decision else { return XCTFail("网络失败必须保持未核验") } service.error = APIError.serverCode(150015, "身份受限") await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .unresolved) } func testChangedIdentityPreventsQuery() async { let service = DeregistrationStatusStub() let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { false }) XCTAssertEqual(model.decision, .obsolete) XCTAssertEqual(service.queryCount, 0) } func testChangedIdentityDiscardsLateResponseAndError() async { for fails in [false, true] { var current = true let service = DeregistrationStatusStub() service.onQuery = { current = false } service.error = fails ? APIError.networkFailed("old error") : nil let model = StoreAccountDeregistrationAccessViewModel() await model.verify(api: service, isCurrentIdentity: { current }) XCTAssertEqual(model.decision, .obsolete) } } func testNewRestrictionWinsOverOldNormalResponse() async { let model = StoreAccountDeregistrationAccessViewModel() let service = DeregistrationStatusStub() service.onQuery = { model.recordRestriction() } await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .unresolved) service.onQuery = nil await model.verify(api: service, isCurrentIdentity: { true }) XCTAssertEqual(model.decision, .allowed) } func testRestrictionOnlyMatchesCurrentStoreToken() { let name = "DeregistrationAccessTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! defer { defaults.removePersistentDomain(forName: name) } let session = AppSessionStore(defaults: defaults) session.accountType = .storeUser session.token = "current-store-token" XCTAssertTrue(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: session.token, session: session)) XCTAssertFalse(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: "old-token", session: session)) XCTAssertFalse(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: nil, session: session)) session.accountType = .scenicUser XCTAssertFalse(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: session.token, session: session)) } func testRestrictionNotificationUsesOriginalRequestTokenWithoutExpiringSession() async throws { let token = "test-original-token" let center = NotificationCenter() let restricted = XCTNSNotificationExpectation(name: NotificationName.storeAccountDeregistrationRestricted, object: nil, notificationCenter: center) restricted.handler = { notification in let requestToken = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String return requestToken == token } let expired = XCTNSNotificationExpectation(name: NotificationName.sessionDidExpire, object: nil, notificationCenter: center) expired.isInverted = true let network = MockURLSession(responses: [StoreAccountDeregistrationFixtures.observedCoolingRestrictionEnvelope]) let client = APIClient(environment: .testing, session: network, notificationCenter: center) client.bindAuthTokenProvider { "new-current-token" } do { let _: EmptyPayload = try await client.send(APIRequest(method: .get, path: "/test/business"), tokenOverride: token) XCTFail("必须保留150015错误") } catch { XCTAssertFalse(APIError.isAuthenticationExpired(error)) guard case APIError.serverCode(150015, let message) = error else { return XCTFail("真实150015响应不能被误判为解码失败") } XCTAssertEqual(message, "账号处于注销冷静期,请先撤销注销后再继续使用") } await fulfillment(of: [restricted, expired], timeout: 0.1) } func testAccessPageQueriesOnlyStatusBeforeAllowingEntry() async throws { let name = "DeregistrationAccessPageTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! defer { defaults.removePersistentDomain(forName: name) } let session = AppSessionStore(defaults: defaults) session.accountType = .storeUser session.userId = "101" session.token = "isolated-token" let identity = try StoreAccountDeregistrationIdentity(session: session) let network = MockURLSession(responses: [Data(#"{"code":100000,"data":{"deregister":null}}"#.utf8)]) let api = StoreAccountDeregistrationAPI(client: APIClient(environment: .testing, session: network), identity: identity) { identity.matches(session: session) } let allowed = expectation(description: "状态查询后允许进入") let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: api, session: session) { _ in allowed.fulfill() } controller.loadViewIfNeeded() await fulfillment(of: [allowed], timeout: 2) XCTAssertEqual(network.requests.map(\.httpMethod), ["GET"]) XCTAssertEqual(network.requests.first?.url?.path, "/api/yf-handset-app/account-deregister/status") XCTAssertEqual(session.token, "isolated-token") } /// 防御性测试:HTTP 拒绝中明确携带150015时仍是注销限制;并非已实测到该HTTP组合。 func testExplicitDeregistrationCodeInHTTPFailureIsNotTokenExpiry() async throws { let network = MockURLSession(responses: [try TestJSON.errorEnvelope(code: 150015, msg: "注销限制")], statusCode: 403) let client = APIClient(environment: .testing, session: network) do { let _: EmptyPayload = try await client.send(APIRequest(method: .get, path: "/test/business"), tokenOverride: "isolated-token") XCTFail("应保留150015限制") } catch { XCTAssertFalse(APIError.isAuthenticationExpired(error)) guard case APIError.serverCode(150015, _) = error else { return XCTFail("应保留业务限制码") } } } } /// 撤销专用内存服务,可模拟响应丢失、申请变化及并发限制,不调用真实网络。 @MainActor private final class DeregistrationCancellationStub: StoreAccountDeregistrationServing { var current: StoreAccountDeregistrationStatus var afterCancel: StoreAccountDeregistrationStatus var cancelError: Error? var onCancel: (() -> Void)? var calls: [String] = [] init() throws { current = try StoreAccountDeregistrationFixtures.lifecycleStatus() afterCancel = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true) } func status() async throws -> StoreAccountDeregistrationStatus { calls.append("status"); return current } func cancel() async throws { calls.append("cancel") current = afterCancel onCancel?() if let cancelError { throw cancelError } } func eligibility() async throws -> StoreAccountDeregistrationEligibility { XCTFail("状态核验无需调用条件接口") return try StoreAccountDeregistrationFixtures.eligibility() } func waiveWallet() async throws { XCTFail("不应确认资产") } func waivePoints() async throws { XCTFail("不应确认资产") } func sendSMS() async throws { XCTFail("不应发送短信") } func apply(smsCode: String, reason: String) async throws { XCTFail("不应申请") } } /// 状态查询的可控替身,模拟服务端返回和查询途中发生的会话变化。 @MainActor private final class DeregistrationStatusStub: StoreAccountDeregistrationStatusServing { var result: StoreAccountDeregistrationJSON = .null var error: Error? var onQuery: (() -> Void)? private(set) var queryCount = 0 func status() async throws -> StoreAccountDeregistrationStatus { queryCount += 1 onQuery?() if let error { throw error } return StoreAccountDeregistrationStatus(deregister: result) } }