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) } }