feat: 增加门店身份注销流程

This commit is contained in:
2026-08-31 09:43:14 +08:00
parent 396597a160
commit e529bb5942
35 changed files with 6062 additions and 45 deletions
+169
View File
@@ -3,12 +3,181 @@
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
@MainActor
/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和登录流程。
final class LoginViewModelTests: XCTestCase {
/// 之前按手机号保存的 Mock 完成记录不能阻断旧接口下仍可用的其他业务身份。
func testLegacyPhoneWideMockRecordDoesNotBlockServerLogin() async throws {
let username = "13900000001"
let key = "account_deletion_mock_v1_" + username
let defaults = UserDefaults.standard
let previous = defaults.object(forKey: key)
defer {
if let previous { defaults.set(previous, forKey: key) }
else { defaults.removeObject(forKey: key) }
}
let oldRecord: [String: Any] = [
"id": UUID().uuidString,
"clientRequestID": UUID().uuidString,
"username": username,
"submittedAt": 0,
"scheduledDeletionAt": 0,
"status": "completed",
"acknowledgedAssetKinds": [],
]
defaults.set(try JSONSerialization.data(withJSONObject: oldRecord), forKey: key)
let login = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[{"id":101,"store_user_id":101,"store_id":25,"store_name":"另一门店"}]}}"#.utf8)
let selected = Data(#"{"code":100000,"data":{"token":"business","scenic_users":[],"store_users":[]}}"#.utf8)
let session = MockURLSession(responses: [login, selected])
let viewModel = LoginViewModel()
viewModel.updateAccount(username)
viewModel.updatePassword("test-password")
let api = AuthAPI(client: APIClient(environment: .testing, session: session))
let result = try await viewModel.login(authAPI: api)
guard case let .completed(response, account) = result else {
return XCTFail("可用门店身份应正常登录")
}
XCTAssertEqual(response.token, "business")
XCTAssertEqual(account.businessUserId, 101)
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login", "/api/app/v9/set-user"])
}
/// 普通门店身份选择直接回调,不再无条件弹出注销提示。
func testSelectingStoreIdentityContinuesWithoutDeregistrationPrompt() throws {
let account = reentryAccount(isStore: true)
var confirmed: [AccountSwitchAccount] = []
let controller = AccountSelectionViewController(
payload: AccountSelectionPayload(tempToken: "temporary", accounts: [account]),
isLoading: false,
onCancel: {},
onConfirm: { confirmed.append($0) }
)
controller.loadViewIfNeeded()
let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton }
.first { $0.currentTitle == "进入系统" })
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmed, [account])
XCTAssertNil(controller.presentedViewController)
}
/// 登录按钮和键盘完成键均直接请求登录;空身份响应避免写入真实会话。
func testLoginPageRequestsLoginWithoutDeregistrationPrompt() async throws {
for useKeyboard in [false, true] {
let response = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[]}}"#.utf8)
let session = MockURLSession(responses: [response])
let controller = LoginViewController(authAPI: AuthAPI(client: APIClient(environment: .testing, session: session)))
controller.loadViewIfNeeded()
let views = allSubviews(in: controller.view)
let account = try XCTUnwrap(views.compactMap { $0 as? LoginTextField }.first)
let password = try XCTUnwrap(views.compactMap { $0 as? PasswordInputField }.first)
let agreement = try XCTUnwrap(views.compactMap { $0 as? AgreementRowView }.first)
account.text = "13900000001"
account.onTextChange?(account.text)
password.text = "mock-password"
password.onTextChange?(password.text)
if !agreement.isChecked { agreement.onCheckedChange?() }
let button = try XCTUnwrap(views.compactMap { $0 as? UIButton }.first { $0.currentTitle == "登录" })
if useKeyboard { password.onReturnKey?() }
else { button.sendActions(for: .touchUpInside) }
for _ in 0..<100 {
if !session.requests.isEmpty, button.isEnabled { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"])
XCTAssertNil(controller.presentedViewController)
}
}
/// 移除注销提示后仍必须勾选用户协议,不能绕过原有表单校验。
func testLoginPageStillRequiresPrivacyAgreement() async throws {
let session = MockURLSession(responses: [])
let controller = LoginViewController(authAPI: AuthAPI(client: APIClient(environment: .testing, session: session)))
controller.loadViewIfNeeded()
let views = allSubviews(in: controller.view)
let account = try XCTUnwrap(views.compactMap { $0 as? LoginTextField }.first)
let password = try XCTUnwrap(views.compactMap { $0 as? PasswordInputField }.first)
let agreement = try XCTUnwrap(views.compactMap { $0 as? AgreementRowView }.first)
account.text = "13900000001"
account.onTextChange?(account.text)
password.onTextChange?("mock-password")
if agreement.isChecked { agreement.onCheckedChange?() }
let button = try XCTUnwrap(views.compactMap { $0 as? UIButton }.first { $0.currentTitle == "登录" })
button.sendActions(for: .touchUpInside)
await Task.yield()
XCTAssertTrue(session.requests.isEmpty)
XCTAssertNil(controller.presentedViewController)
}
/// 身份选择处于加载状态时仍不允许再次确认。
func testSelectingStoreIdentityWhileLoadingDoesNotConfirm() throws {
var confirmed: [AccountSwitchAccount] = []
let controller = AccountSelectionViewController(
payload: AccountSelectionPayload(tempToken: "temporary", accounts: [reentryAccount(isStore: true)]),
isLoading: true, onCancel: {}, onConfirm: { confirmed.append($0) })
controller.loadViewIfNeeded()
let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton }
.first { $0.currentTitle == "进入系统" })
button.sendActions(for: .touchUpInside)
XCTAssertFalse(button.isEnabled)
XCTAssertTrue(confirmed.isEmpty)
XCTAssertNil(controller.presentedViewController)
}
/// 景区身份不受旧门店注销流程影响,不应被多余确认拦截。
func testSelectingScenicIdentityContinuesWithoutDeregistrationPrompt() throws {
let account = reentryAccount(isStore: false)
var confirmed: [AccountSwitchAccount] = []
let controller = AccountSelectionViewController(
payload: AccountSelectionPayload(tempToken: "temporary", accounts: [account]),
isLoading: false,
onCancel: {},
onConfirm: { confirmed.append($0) }
)
controller.loadViewIfNeeded()
let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton }
.first { $0.currentTitle == "进入系统" })
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmed, [account])
XCTAssertNil(controller.presentedViewController)
}
private func reentryAccount(isStore: Bool) -> AccountSwitchAccount {
AccountSwitchAccount(
accountType: isStore ? "store_user" : "scenic_user",
businessUserId: 101,
title: isStore ? "测试门店" : "测试景区",
subtitle: "摄影师",
phone: "13900000001",
realName: "测试用户",
avatar: "",
scenicName: "测试景区",
storeId: isStore ? 25 : nil,
storeName: isStore ? "测试门店" : "",
scenicId: 10,
isCurrent: false
)
}
private func allSubviews(in view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + allSubviews(in: $0) }
}
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
let viewModel = LoginViewModel()
viewModel.updateAccount("+86 186 5185 7230")