467 lines
23 KiB
Swift
467 lines
23 KiB
Swift
import UIKit
|
|
import XCTest
|
|
@testable import suixinkan
|
|
|
|
/// 用独立窗口和真实核验控制器验证前台、多端及过期响应;不访问设备真实会话。
|
|
@MainActor
|
|
final class StoreAccountDeregistrationRootTests: XCTestCase {
|
|
/// 重放真实userinfo限制及status响应,贯通业务API、错误通知、状态核验和根页面,不重新登录或撤销。
|
|
func testObservedBusinessRestrictionRoutesToCoolingWithoutLosingSession() async throws {
|
|
let network = MockURLSession(responses: [
|
|
StoreAccountDeregistrationFixtures.observedCoolingRestrictionEnvelope,
|
|
StoreAccountDeregistrationFixtures.observedCoolingStatusEnvelope,
|
|
])
|
|
let center = NotificationCenter()
|
|
let client = APIClient(environment: .testing, session: network, notificationCenter: center)
|
|
let context = DeregistrationRootContext(networkClient: client)
|
|
defer { context.cleanUp() }
|
|
client.bindAuthTokenProvider { [weak context] in context?.session.token }
|
|
let originalToken = context.session.token
|
|
let originalUserID = context.session.userId
|
|
let expired = XCTNSNotificationExpectation(name: NotificationName.sessionDidExpire,
|
|
object: nil, notificationCenter: center)
|
|
expired.isInverted = true
|
|
let observer = center.addObserver(forName: NotificationName.storeAccountDeregistrationRestricted,
|
|
object: nil, queue: .main) { [weak coordinator = context.coordinator] notification in
|
|
let token = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String
|
|
Task { @MainActor [weak coordinator] in coordinator?.recordRestriction(requestToken: token) }
|
|
}
|
|
defer { center.removeObserver(observer) }
|
|
context.startRestoringSession()
|
|
let businessRoot = context.window.rootViewController
|
|
|
|
do {
|
|
_ = try await ProfileAPI(client: client).userInfo()
|
|
XCTFail("冷静期不能取得普通业务数据")
|
|
} catch {
|
|
guard case APIError.serverCode(150015, let message) = error else {
|
|
return XCTFail("真实错误data结构不能被误判为UserInfo解码失败:\(error)")
|
|
}
|
|
XCTAssertEqual(message, "账号处于注销冷静期,请先撤销注销后再继续使用")
|
|
XCTAssertFalse(APIError.isAuthenticationExpired(error))
|
|
}
|
|
|
|
await waitUntil { context.message.contains("2026-09-04 15:27:24") }
|
|
XCTAssertNotNil(context.coordinator.accessController)
|
|
XCTAssertFalse(context.window.rootViewController === businessRoot)
|
|
XCTAssertTrue(context.message.contains("注销申请已提交"))
|
|
XCTAssertFalse(context.message.contains("604681"))
|
|
XCTAssertTrue(context.bindingSuspended)
|
|
XCTAssertEqual(context.resumeCount, 1)
|
|
XCTAssertEqual(context.createdRoots, 1)
|
|
XCTAssertEqual(context.session.token, originalToken)
|
|
XCTAssertEqual(context.session.userId, originalUserID)
|
|
XCTAssertTrue(context.session.isLoggedIn)
|
|
XCTAssertEqual(network.requests.map { $0.url?.path }, [
|
|
"/api/yf-handset-app/userinfo",
|
|
"/api/yf-handset-app/account-deregister/status",
|
|
])
|
|
XCTAssertEqual(network.requests.map(\.httpMethod), ["GET", "GET"])
|
|
XCTAssertTrue(network.requests.allSatisfy { $0.value(forHTTPHeaderField: "token") == originalToken })
|
|
XCTAssertTrue(context.service.mutations.isEmpty)
|
|
await fulfillment(of: [expired], timeout: 0.1)
|
|
}
|
|
|
|
/// 已登录冷启动和普通前台恢复均不查注销状态,直接保留正常导航与推送。
|
|
func testStartupAndForegroundDoNotQueryAndPreserveNavigation() throws {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.service.readError = APIError.networkFailed("注销查询离线不应影响普通启动")
|
|
context.startRestoringSession()
|
|
let original = try XCTUnwrap(context.window.rootViewController as? UINavigationController)
|
|
let details = UIViewController()
|
|
original.pushViewController(details, animated: false)
|
|
context.coordinator.resumeFromBackground()
|
|
context.coordinator.resumeFromBackground()
|
|
XCTAssertNil(context.coordinator.accessController)
|
|
XCTAssertTrue(context.window.rootViewController === original)
|
|
XCTAssertTrue(original.topViewController === details)
|
|
XCTAssertEqual(context.createdRoots, 1)
|
|
XCTAssertFalse(context.bindingSuspended)
|
|
XCTAssertEqual(context.service.statusCount, 0)
|
|
XCTAssertEqual(context.createdAPIs, 0)
|
|
XCTAssertEqual(context.resumeCount, 1)
|
|
}
|
|
|
|
/// 新登录只以 v9 的门店 status 判定;旧注销状态接口和本地未决记录都不能再拦截 status 为正常的登录。
|
|
func testNormalLoginRestoresBusinessWithoutLegacyDeregistrationCheck() async {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.service.readError = APIError.networkFailed("旧注销查询不应影响 v9 正常登录")
|
|
context.startRestoringSession()
|
|
|
|
XCTAssertNil(context.coordinator.accessController)
|
|
XCTAssertFalse(context.coordinator.isChecking)
|
|
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
|
XCTAssertFalse(context.bindingSuspended)
|
|
XCTAssertEqual(context.createdRoots, 1)
|
|
XCTAssertEqual(context.resumeCount, 1)
|
|
XCTAssertEqual(context.service.statusCount, 0)
|
|
}
|
|
|
|
/// 旧核验入口仅在业务限制等已知受限事件发生时使用,不是正常登录路径的一部分。
|
|
func testExplicitRestrictionCheckStillKeepsOriginalPageUntilResult() async {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.service.holdNextRead = true
|
|
await context.startChecking()
|
|
let loginRoot = context.window.rootViewController
|
|
await waitUntil { context.service.pendingRead != nil }
|
|
XCTAssertNil(context.coordinator.accessController)
|
|
XCTAssertTrue(context.window.rootViewController === loginRoot)
|
|
XCTAssertTrue(context.coordinator.isChecking)
|
|
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
|
|
XCTAssertFalse(context.message.contains("正在查询注销状态"))
|
|
XCTAssertTrue(context.bindingSuspended)
|
|
XCTAssertEqual(context.createdRoots, 0)
|
|
context.service.finishRead()
|
|
await waitUntil { context.resumeCount == 1 }
|
|
context.coordinator.resumeFromBackground()
|
|
XCTAssertNil(context.coordinator.accessController)
|
|
XCTAssertFalse(context.bindingSuspended)
|
|
XCTAssertEqual(context.service.statusCount, 1)
|
|
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
|
}
|
|
|
|
/// 登录查询失败后才显示错误结果,不重复查询;下拉刷新恢复后结束全局加载。
|
|
func testLoginFailureAndPullToRefreshUseGlobalLoading() async throws {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.service.readError = APIError.networkFailed("offline")
|
|
await context.startChecking()
|
|
await waitUntil { context.retryEnabled }
|
|
let gate = try XCTUnwrap(context.coordinator.accessController)
|
|
XCTAssertTrue(context.message.contains("暂时无法查询"))
|
|
XCTAssertNil(gate.navigationItem.rightBarButtonItem)
|
|
XCTAssertEqual(context.service.statusCount, 1)
|
|
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
|
let scroll = try XCTUnwrap(gate.view.subviews.first { $0 is UIScrollView } as? UIScrollView)
|
|
let refresh = try XCTUnwrap(scroll.refreshControl)
|
|
context.service.readError = nil
|
|
context.service.holdNextRead = true
|
|
refresh.beginRefreshing()
|
|
refresh.sendActions(for: .valueChanged)
|
|
await waitUntil { context.service.pendingRead != nil }
|
|
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
|
|
refresh.sendActions(for: .valueChanged)
|
|
XCTAssertEqual(context.service.statusCount, 2)
|
|
context.service.finishRead()
|
|
await waitUntil { context.resumeCount == 1 }
|
|
XCTAssertFalse(refresh.isRefreshing)
|
|
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
|
XCTAssertTrue(context.service.mutations.isEmpty)
|
|
}
|
|
|
|
/// 离开登录核验立即释放其加载,旧响应不能关闭其他请求的加载或恢复首页。
|
|
func testCancelledLoginCheckDoesNotHideNewLoadingOrReplaceRoot() async throws {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.service.holdNextRead = true
|
|
await context.startChecking()
|
|
await waitUntil { context.service.pendingRead != nil }
|
|
context.coordinator.check()
|
|
XCTAssertEqual(context.service.statusCount, 1, "同一登录核验不能重复发起")
|
|
context.coordinator.cancelPendingCheck()
|
|
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
|
let newRoot = UIViewController()
|
|
context.window.rootViewController = newRoot
|
|
GlobalLoadingManager.shared.show()
|
|
defer { GlobalLoadingManager.shared.hide() }
|
|
context.service.finishRead()
|
|
try await Task.sleep(nanoseconds: 30_000_000)
|
|
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
|
|
XCTAssertTrue(context.window.rootViewController === newRoot)
|
|
XCTAssertEqual(context.resumeCount, 0)
|
|
}
|
|
|
|
/// 业务限制才触发冷静期查询;受限页前台刷新可确认其他设备撤销,不发送修改请求。
|
|
func testRemoteApplicationAndCancellation() async throws {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
await context.startChecking()
|
|
await waitUntil { context.resumeCount == 1 }
|
|
let original = context.window.rootViewController
|
|
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
|
|
context.coordinator.recordRestriction(requestToken: context.session.token)
|
|
await waitUntil { context.message.contains("注销申请已提交") }
|
|
XCTAssertTrue(context.bindingSuspended)
|
|
XCTAssertEqual(context.resumeCount, 1)
|
|
let gate = context.coordinator.accessController
|
|
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
|
|
context.coordinator.resumeFromBackground()
|
|
XCTAssertTrue(context.coordinator.accessController === gate)
|
|
await waitUntil { context.resumeCount == 2 }
|
|
XCTAssertFalse(context.window.rootViewController === original)
|
|
XCTAssertEqual(context.createdRoots, 2)
|
|
XCTAssertFalse(context.bindingSuspended)
|
|
XCTAssertTrue(context.service.mutations.isEmpty)
|
|
}
|
|
|
|
/// 业务限制后离线保持核验页,受限页返回前台成功重查后才恢复。
|
|
func testRestrictionQueryFailureRemainsRestricted() async {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
await context.startChecking()
|
|
await waitUntil { context.resumeCount == 1 }
|
|
context.service.readError = APIError.networkFailed("offline")
|
|
context.coordinator.recordRestriction(requestToken: context.session.token)
|
|
await waitUntil { context.message.contains("暂时无法查询") }
|
|
XCTAssertNotNil(context.coordinator.accessController)
|
|
XCTAssertTrue(context.bindingSuspended)
|
|
XCTAssertEqual(context.resumeCount, 1)
|
|
context.service.readError = nil
|
|
context.coordinator.resumeFromBackground()
|
|
await waitUntil { context.resumeCount == 2 }
|
|
XCTAssertNil(context.coordinator.accessController)
|
|
}
|
|
|
|
/// 新150015优先于旧正常响应,其他身份Token的通知不应影响当前页面。
|
|
func testRestrictionOverridesOldResponseAndIgnoresOtherToken() async {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
await context.startChecking()
|
|
await waitUntil { context.resumeCount == 1 }
|
|
let original = context.window.rootViewController
|
|
context.coordinator.recordRestriction(requestToken: "other-token")
|
|
XCTAssertTrue(context.window.rootViewController === original)
|
|
context.service.holdNextRead = true
|
|
context.coordinator.check()
|
|
await waitUntil { context.service.pendingRead != nil }
|
|
context.coordinator.recordRestriction(requestToken: context.session.token)
|
|
context.service.finishRead()
|
|
await waitUntil { context.retryEnabled }
|
|
XCTAssertNotNil(context.coordinator.accessController)
|
|
XCTAssertTrue(context.bindingSuspended)
|
|
XCTAssertEqual(context.resumeCount, 1)
|
|
}
|
|
|
|
/// 在途查询回到前台后作废,完成后只再查一次最新状态,不重发修改操作。
|
|
func testForegroundDiscardsBackgroundResponse() async throws {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.service.holdNextRead = true
|
|
await context.startChecking()
|
|
await waitUntil { context.service.pendingRead != nil }
|
|
let loginRoot = context.window.rootViewController
|
|
context.coordinator.resumeFromBackground()
|
|
XCTAssertTrue(context.window.rootViewController === loginRoot)
|
|
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
|
|
context.service.finishRead()
|
|
await waitUntil { context.message.contains("注销申请已提交") }
|
|
XCTAssertNotNil(context.coordinator.accessController)
|
|
XCTAssertEqual(context.service.statusCount, 2)
|
|
XCTAssertEqual(context.resumeCount, 0)
|
|
XCTAssertTrue(context.service.mutations.isEmpty)
|
|
}
|
|
|
|
/// 窗口根或会话被替换后,旧查询完成不能恢复旧账号。
|
|
func testLateQueryDoesNotReplaceNewSessionOrRoot() async throws {
|
|
for changeSession in [true, false] {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.service.holdNextRead = true
|
|
await context.startChecking()
|
|
await waitUntil { context.service.pendingRead != nil }
|
|
if changeSession { context.session.token = "new-token"; context.session.userId = "102" }
|
|
let replacement = UIViewController()
|
|
context.window.rootViewController = replacement
|
|
context.service.finishRead()
|
|
try await Task.sleep(nanoseconds: 30_000_000)
|
|
XCTAssertTrue(context.window.rootViewController === replacement)
|
|
XCTAssertEqual(context.resumeCount, 0)
|
|
}
|
|
}
|
|
|
|
/// 切换身份恢复新首页,不沿用前一身份的导航栈,也不额外查询注销状态。
|
|
func testSwitchIdentityRestoresNewRootWithoutQuery() {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.startRestoringSession()
|
|
let original = context.window.rootViewController
|
|
context.session.userId = "102"
|
|
context.session.token = "new-token"
|
|
context.coordinator.restoreSession()
|
|
XCTAssertFalse(context.window.rootViewController === original)
|
|
XCTAssertEqual(context.createdRoots, 2)
|
|
XCTAssertEqual(context.resumeCount, 2)
|
|
XCTAssertEqual(context.service.statusCount, 0)
|
|
}
|
|
|
|
/// 景区和未登录会话不调用门店注销接口。
|
|
func testNonStoreAndLoggedOutForegroundDoesNotQuery() {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.session.accountType = .scenicUser
|
|
context.coordinator.resumeFromBackground()
|
|
context.session.accountType = .storeUser
|
|
context.session.token = ""
|
|
context.coordinator.resumeFromBackground()
|
|
context.coordinator.restoreSession()
|
|
XCTAssertEqual(context.service.statusCount, 0)
|
|
XCTAssertEqual(context.createdRoots, 0)
|
|
XCTAssertFalse(context.bindingSuspended)
|
|
}
|
|
|
|
/// 点击撤销先显示针对当前身份的确认;后台恢复撤掉旧弹窗,不发送撤销请求。
|
|
func testCancellationRequiresConfirmationAndForegroundDismissesStaleAlert() async throws {
|
|
let context = DeregistrationRootContext()
|
|
defer { context.cleanUp() }
|
|
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
|
|
await context.startChecking()
|
|
await waitUntil { context.message.contains("注销申请已提交") }
|
|
let gate = try XCTUnwrap(context.coordinator.accessController)
|
|
let cancel = try XCTUnwrap(context.button("deregister.access.cancel"))
|
|
cancel.sendActions(for: .touchUpInside)
|
|
await waitUntil { gate.presentedViewController is UIAlertController }
|
|
let alert = try XCTUnwrap(gate.presentedViewController as? UIAlertController)
|
|
XCTAssertEqual(alert.title, "撤销当前身份的注销申请?")
|
|
XCTAssertTrue(alert.message?.contains(gate.identity?.displayName ?? "missing") == true)
|
|
XCTAssertEqual(alert.actions.map(\.title), ["保留注销申请", "确认撤销"])
|
|
XCTAssertTrue(context.service.mutations.isEmpty)
|
|
// 等原生呈现动画结束,再模拟一次后台返回。
|
|
try await Task.sleep(nanoseconds: 400_000_000)
|
|
context.coordinator.resumeFromBackground()
|
|
await waitUntil { gate.presentedViewController == nil && context.retryEnabled }
|
|
XCTAssertTrue(context.service.mutations.isEmpty)
|
|
XCTAssertEqual(context.service.statusCount, 2)
|
|
}
|
|
|
|
private func waitUntil(_ condition: () -> Bool, file: StaticString = #filePath, line: UInt = #line) async {
|
|
for _ in 0..<150 {
|
|
if condition() { return }
|
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
|
}
|
|
XCTFail("等待异步核验超时", file: file, line: line)
|
|
}
|
|
}
|
|
|
|
/// 每例使用独立会话、提交意图和窗口;业务恢复动作以计数替代推送及首页网络请求。
|
|
@MainActor
|
|
private final class DeregistrationRootContext {
|
|
let name = "DeregistrationRoot.\(UUID().uuidString)"
|
|
let defaults: UserDefaults
|
|
let session: AppSessionStore
|
|
let window: UIWindow
|
|
private weak var previousKeyWindow: UIWindow?
|
|
let service = DeregistrationRootService()
|
|
let networkClient: APIClient?
|
|
var createdRoots = 0
|
|
var createdAPIs = 0
|
|
var resumeCount = 0
|
|
var bindingSuspended = false
|
|
lazy var coordinator = StoreAccountDeregistrationRootCoordinator(
|
|
window: window, session: session, makeAPI: { [unowned self] identity in
|
|
self.createdAPIs += 1
|
|
if let client = self.networkClient {
|
|
return StoreAccountDeregistrationAPI(client: client, identity: identity) { [weak self] in
|
|
guard let self else { return false }
|
|
return identity.matches(session: self.session)
|
|
}
|
|
}
|
|
return self.service
|
|
},
|
|
makeSubmissionStore: { [unowned self] in
|
|
StoreAccountDeregistrationSubmissionStore(storeUserID: $0, environment: .testing, defaults: self.defaults)
|
|
},
|
|
makeBusinessRoot: { [unowned self] in
|
|
self.createdRoots += 1
|
|
return UINavigationController(rootViewController: UIViewController())
|
|
},
|
|
setBindingSuspended: { [unowned self] in self.bindingSuspended = $0 },
|
|
onBusinessResumed: { [unowned self] in self.resumeCount += 1 }
|
|
)
|
|
|
|
init(networkClient: APIClient? = nil) {
|
|
self.networkClient = networkClient
|
|
defaults = UserDefaults(suiteName: name)!
|
|
session = AppSessionStore(defaults: defaults)
|
|
session.accountType = .storeUser
|
|
session.userId = "101"
|
|
session.token = "isolated-store-token"
|
|
if let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first {
|
|
previousKeyWindow = scene.windows.first(where: \.isKeyWindow)
|
|
window = UIWindow(windowScene: scene)
|
|
} else {
|
|
window = UIWindow(frame: UIScreen.main.bounds)
|
|
}
|
|
}
|
|
|
|
func startChecking() async {
|
|
// 模拟已有登录页,核验期间不替换它,只在此窗口显示全局加载。
|
|
if window.rootViewController == nil {
|
|
window.rootViewController = UINavigationController(rootViewController: UIViewController())
|
|
}
|
|
window.makeKeyAndVisible()
|
|
window.layoutIfNeeded()
|
|
// 模拟用户在已显示的登录页点击登录,先等待前一个用例的键盘/窗口动画结束。
|
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
coordinator.check()
|
|
}
|
|
|
|
func startRestoringSession() {
|
|
coordinator.restoreSession()
|
|
window.makeKeyAndVisible()
|
|
window.layoutIfNeeded()
|
|
}
|
|
|
|
var message: String {
|
|
allSubviews(window).compactMap { view -> String? in
|
|
guard ["deregister.access.message", "deregister.access.title", "deregister.access.deadline"]
|
|
.contains(view.accessibilityIdentifier ?? "") else { return nil }
|
|
return (view as? UILabel)?.text
|
|
}.joined(separator: "\n")
|
|
}
|
|
var retryEnabled: Bool {
|
|
button("deregister.access.retry")?.isEnabled == true
|
|
}
|
|
func button(_ identifier: String) -> UIButton? {
|
|
allSubviews(window).first { $0.accessibilityIdentifier == identifier } as? UIButton
|
|
}
|
|
func cleanUp() {
|
|
coordinator.cancelPendingCheck()
|
|
service.finishRead()
|
|
window.isHidden = true
|
|
window.rootViewController = nil
|
|
previousKeyWindow?.makeKeyAndVisible()
|
|
defaults.removePersistentDomain(forName: name)
|
|
}
|
|
private func allSubviews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews($0) } }
|
|
}
|
|
|
|
/// 能暂停单次 GET 并返回其旧快照,便于验证后台前在途响应不能放行。
|
|
@MainActor
|
|
private final class DeregistrationRootService: StoreAccountDeregistrationServing {
|
|
var current = StoreAccountDeregistrationStatus(deregister: .null)
|
|
var readError: Error?
|
|
var holdNextRead = false
|
|
var pendingRead: CheckedContinuation<StoreAccountDeregistrationStatus, Error>?
|
|
var pendingSnapshot: StoreAccountDeregistrationStatus?
|
|
var statusCount = 0
|
|
var mutations: [String] = []
|
|
|
|
func status() async throws -> StoreAccountDeregistrationStatus {
|
|
statusCount += 1
|
|
if holdNextRead {
|
|
holdNextRead = false
|
|
pendingSnapshot = current
|
|
return try await withCheckedThrowingContinuation { pendingRead = $0 }
|
|
}
|
|
if let readError { throw readError }
|
|
return current
|
|
}
|
|
func finishRead() {
|
|
let continuation = pendingRead
|
|
pendingRead = nil
|
|
if let pendingSnapshot { continuation?.resume(returning: pendingSnapshot) }
|
|
pendingSnapshot = nil
|
|
}
|
|
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
|
|
try StoreAccountDeregistrationFixtures.lifecycleEligibility()
|
|
}
|
|
func cancel() async throws { mutations.append("cancel") }
|
|
func waiveWallet() async throws { mutations.append("wallet") }
|
|
func waivePoints() async throws { mutations.append("points") }
|
|
func sendSMS() async throws { mutations.append("sms") }
|
|
func apply(smsCode: String, reason: String) async throws { mutations.append("apply") }
|
|
}
|