202 lines
9.7 KiB
Swift
202 lines
9.7 KiB
Swift
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)
|
|
}
|
|
}
|