Files
suixinkan_uikit/suixinkanTests/StoreAccountDeregistrationAPITests.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)
}
}