feat: 增加门店身份注销流程
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 验证旧文档已明确的请求契约和门店身份隔离;不替代尚缺响应样例的业务映射测试。
|
||||
@MainActor
|
||||
final class StoreAccountDeregistrationAPITests: XCTestCase {
|
||||
private var suiteName = ""
|
||||
private var defaults: UserDefaults!
|
||||
private var store: AppSessionStore!
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
suiteName = "StoreAccountDeregistrationAPITests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
store = AppSessionStore(defaults: defaults)
|
||||
store.accountType = .storeUser
|
||||
store.userId = "101"
|
||||
store.currentStoreId = 25
|
||||
store.accountDisplayName = "测试门店·摄影师"
|
||||
store.token = "store-101-token"
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
store = nil
|
||||
defaults = nil
|
||||
try await super.tearDown()
|
||||
}
|
||||
|
||||
/// 门店身份 ID 与门店实体 ID 不可混用,景区身份及无凭证状态不可构造上下文。
|
||||
func testIdentityUsesStoreUserIDAndRejectsUnsupportedSessions() throws {
|
||||
let identity = try StoreAccountDeregistrationIdentity(session: store)
|
||||
XCTAssertEqual(identity.userID, "101")
|
||||
XCTAssertEqual(identity.displayName, "测试门店·摄影师")
|
||||
XCTAssertTrue(identity.matches(session: store))
|
||||
|
||||
store.accountType = .scenicUser
|
||||
XCTAssertFalse(identity.matches(session: store))
|
||||
XCTAssertThrowsError(try StoreAccountDeregistrationIdentity(session: store)) { error in
|
||||
XCTAssertEqual(error as? StoreAccountDeregistrationError, .unsupportedIdentity)
|
||||
}
|
||||
store.accountType = .storeUser
|
||||
store.token = ""
|
||||
XCTAssertThrowsError(try StoreAccountDeregistrationIdentity(session: store)) { error in
|
||||
XCTAssertEqual(error as? StoreAccountDeregistrationError, .invalidSession)
|
||||
}
|
||||
}
|
||||
|
||||
/// 七个请求使用文档中的路径、方法和字段;禁止向接口传入任意手机号或其他身份 ID。
|
||||
func testAllSevenEndpointsMatchLegacyDocument() async throws {
|
||||
let success = Data(#"{"code":100000,"msg":"success","data":null}"#.utf8)
|
||||
let eligibility = try StoreAccountDeregistrationFixtures.envelope(StoreAccountDeregistrationFixtures.eligibilityData())
|
||||
let status = Data(#"{"code":100000,"msg":"success","data":{"deregister":null}}"#.utf8)
|
||||
let session = MockURLSession(responses: [eligibility, success, success, success, success, status, success])
|
||||
let api = try makeAPI(session: session)
|
||||
|
||||
let eligibilityPayload = try await api.eligibility()
|
||||
try await api.waiveWallet()
|
||||
try await api.waivePoints()
|
||||
try await api.sendSMS()
|
||||
try await api.apply(smsCode: "654321", reason: "不再使用")
|
||||
let statusPayload = try await api.status()
|
||||
try await api.cancel()
|
||||
|
||||
let paths = ["eligibility", "waivers/wallet", "waivers/points", "send-sms", "apply", "status", "cancel"]
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, paths.map { "/api/yf-handset-app/account-deregister/" + $0 })
|
||||
XCTAssertEqual(session.requests.map(\.httpMethod), ["GET", "POST", "POST", "POST", "POST", "GET", "POST"])
|
||||
for request in session.requests {
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "store-101-token")
|
||||
XCTAssertNil(request.url?.query)
|
||||
}
|
||||
for index in [1, 2] {
|
||||
let data = try XCTUnwrap(session.requests[index].httpBody)
|
||||
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Bool])
|
||||
XCTAssertEqual(body, ["accepted": true])
|
||||
}
|
||||
let applyData = try XCTUnwrap(session.requests[4].httpBody)
|
||||
let applyBody = try XCTUnwrap(JSONSerialization.jsonObject(with: applyData) as? [String: String])
|
||||
XCTAssertEqual(applyBody, ["sms_code": "654321", "reason": "不再使用"])
|
||||
for index in [0, 3, 5, 6] { XCTAssertNil(session.requests[index].httpBody) }
|
||||
|
||||
XCTAssertFalse(eligibilityPayload.canApply)
|
||||
XCTAssertEqual(eligibilityPayload.blockers.count, 4)
|
||||
XCTAssertEqual(statusPayload.deregister, .null)
|
||||
}
|
||||
|
||||
/// 创建流程后切换到同手机号的另一身份,必须在请求发出前拒绝旧流程操作。
|
||||
func testIdentitySwitchPreventsSendingRequest() async throws {
|
||||
let session = MockURLSession(responses: [])
|
||||
let api = try makeAPI(session: session)
|
||||
store.userId = "102"
|
||||
store.token = "store-102-token"
|
||||
do {
|
||||
try await api.waiveWallet()
|
||||
XCTFail("切换身份后不能继续提交原确认页")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? StoreAccountDeregistrationError, .sessionChanged)
|
||||
}
|
||||
XCTAssertTrue(session.requests.isEmpty)
|
||||
}
|
||||
|
||||
/// 同一身份重新登录取得新凭证,也必须重新进入注销流程。
|
||||
func testTokenChangePreventsSendingRequest() async throws {
|
||||
let session = MockURLSession(responses: [])
|
||||
let api = try makeAPI(session: session)
|
||||
store.token = "new-token"
|
||||
do {
|
||||
try await api.apply(smsCode: "654321", reason: "不再使用")
|
||||
XCTFail("凭证变化后不能继续旧流程")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? StoreAccountDeregistrationError, .sessionChanged)
|
||||
}
|
||||
XCTAssertTrue(session.requests.isEmpty)
|
||||
}
|
||||
|
||||
/// 请求等待期间切换身份,旧响应不得应用到新身份页面。
|
||||
func testResponseFromPreviousIdentityIsDiscarded() async throws {
|
||||
let session = DeregistrationCallbackSession { [store] in store?.userId = "102" }
|
||||
let api = try makeAPI(session: session)
|
||||
do {
|
||||
_ = try await api.status()
|
||||
XCTFail("旧身份响应不能应用到新身份")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? StoreAccountDeregistrationError, .sessionChanged)
|
||||
}
|
||||
XCTAssertEqual(session.requests.first?.value(forHTTPHeaderField: "token"), "store-101-token")
|
||||
}
|
||||
|
||||
/// 余额变化等后端错误保留业务码,不自动重试资产放弃或提交操作。
|
||||
func testMutationPreservesServerFailureWithoutRetry() async throws {
|
||||
let data = try TestJSON.errorEnvelope(code: 100001, msg: "余额已变化,请重新确认")
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = try makeAPI(session: session)
|
||||
do {
|
||||
try await api.waivePoints()
|
||||
XCTFail("不能把服务端失败当作确认成功")
|
||||
} catch let APIError.serverCode(code, message) {
|
||||
XCTAssertEqual(code, 100001)
|
||||
XCTAssertEqual(message, "余额已变化,请重新确认")
|
||||
}
|
||||
XCTAssertEqual(session.requests.count, 1)
|
||||
}
|
||||
|
||||
/// 150015 表示当前身份受限,不是手机号主账号退出,也不能清掉查询与撤销所需凭证。
|
||||
func testRestrictionCodeIsNotAuthenticationExpiry() async throws {
|
||||
let data = try TestJSON.errorEnvelope(code: 150015, msg: "当前身份正在注销")
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = try makeAPI(session: session)
|
||||
do {
|
||||
try await api.sendSMS()
|
||||
XCTFail("限制状态应交由业务层处理")
|
||||
} catch {
|
||||
XCTAssertFalse(APIError.isAuthenticationExpired(error))
|
||||
guard case APIError.serverCode(150015, _) = error else {
|
||||
return XCTFail("应保留旧接口明确约定的限制码")
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(store.token, "store-101-token")
|
||||
}
|
||||
|
||||
/// 文档未定义的字段和状态值必须原样保留,不把未知值解释成可以注销。
|
||||
func testRawResponsePreservesUnknownValuesAndDecimalPrecision() throws {
|
||||
let data = Data(#"{"unknown_status":17,"value":123456789.12,"optional":null,"nested":[true,"17",{}]}"#.utf8)
|
||||
let payload = try JSONDecoder().decode(StoreAccountDeregistrationJSON.self, from: data)
|
||||
guard case let .object(fields) = payload else { return XCTFail("应保留对象") }
|
||||
XCTAssertEqual(fields["unknown_status"], .number(17))
|
||||
XCTAssertEqual(fields["value"], .number(Decimal(string: "123456789.12")!))
|
||||
XCTAssertEqual(fields["optional"], .null)
|
||||
XCTAssertEqual(fields["nested"], .array([.bool(true), .string("17"), .object([:])]))
|
||||
}
|
||||
|
||||
private func makeAPI(session: URLSessionProtocol) throws -> StoreAccountDeregistrationAPI {
|
||||
let identity = try StoreAccountDeregistrationIdentity(session: store)
|
||||
let client = APIClient(environment: .testing, session: session)
|
||||
client.bindAuthTokenProvider { "must-not-use-global-token" }
|
||||
return StoreAccountDeregistrationAPI(client: client, identity: identity) { [store] in
|
||||
guard let store else { return false }
|
||||
return identity.matches(session: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 在返回数据前模拟会话变化,用于验证异步旧响应隔离。
|
||||
private final class DeregistrationCallbackSession: URLSessionProtocol {
|
||||
private let onRequest: @MainActor () -> Void
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 注入等待期间发生的主线程会话操作。
|
||||
init(onRequest: @escaping @MainActor () -> Void) {
|
||||
self.onRequest = onRequest
|
||||
}
|
||||
|
||||
/// 返回最小合法 Envelope,不假定业务状态字段。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
await onRequest()
|
||||
let data = Data(#"{"code":100000,"msg":"success","data":{"deregister":null}}"#.utf8)
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user