diff --git a/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift b/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift deleted file mode 100644 index 3e70a2e1..00000000 --- a/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// SocketEngine.swift -// AIProject -// -// Created by kangho lee on 8/17/25. -// - -import Foundation -import AsyncAlgorithms - -public protocol SocketEngine { - var stateChannel: AsyncChannel { get set } - var incomingChannel: AsyncChannel> { get set } - func connect() async - func send(_ data: Data) async throws - func close() async -} - -public enum WebSocket { - public enum State: Sendable { - case connecting, connected - case failed - case closed - case reconnecting(nextAttempsIn: Duration) - } - - public enum Failure: Error { - - /// 네트워크 끊김, timeout - case retryable(underlying: Error?) - - /// 인증/정책/프로토콜 위반 - case nonRetryable(underlying: Error?) - case closed(code: URLSessionWebSocketTask.CloseCode, reason: Data?) - } - - public enum MessageFailure: Error { - case frameCorrupted - case failed(Error) - } - - public enum RetryFailure: Error { - case exceedAttemps - } -} - -extension WebSocket.State: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.connecting, .connecting): - return true - case (.connected, .connected): - return true - case (.reconnecting(let lhsDelay), .reconnecting(let rhsDelay)): - return lhsDelay == rhsDelay - default: - return false - } - } -} diff --git a/AIProject/iCo/Core/Util/Async+BroadCaster.swift b/AIProject/iCo/Core/Remote/WebSocket/Util/Async+BroadCaster.swift similarity index 98% rename from AIProject/iCo/Core/Util/Async+BroadCaster.swift rename to AIProject/iCo/Core/Remote/WebSocket/Util/Async+BroadCaster.swift index 7aa22a09..0e991766 100644 --- a/AIProject/iCo/Core/Util/Async+BroadCaster.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/Util/Async+BroadCaster.swift @@ -20,6 +20,8 @@ public class AsyncStreamBroadcaster { /// 구독할 continuation 값들 private var continuations: [UUID: AsyncStream.Continuation] = [:] + public init() {} + /// 구독 메서드로 stream을 반환 /// - Returns: stream 반환 public func stream() -> AsyncStream { diff --git a/AIProject/iCo/Core/Util/Async+Timeout.swift b/AIProject/iCo/Core/Remote/WebSocket/Util/Async+Timeout.swift similarity index 100% rename from AIProject/iCo/Core/Util/Async+Timeout.swift rename to AIProject/iCo/Core/Remote/WebSocket/Util/Async+Timeout.swift diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index 3721d207..6fda2a08 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -1,29 +1,35 @@ import Foundation import AsyncAlgorithms -public final class WebSocketClient: NSObject { +public class WebSocketClient: NSObject, WebSocketProvider { /// 소켓 상태 채널 private var stateStream: AsyncStream /// WebSocket의 상태 변화를 여러 Consumer에게 동시에 전달하는 브로드캐스터 - public var stateBroadCaster: AsyncStreamBroadcaster = .init() + public var stateBroadCaster: AsyncStreamBroadcaster /// 메세지 채널 public var incomingChannel: AsyncChannel - private let url: URL - private let session: URLSession - private var task: URLSessionWebSocketTask? + private(set) var url: URL + private(set) var session: URLSessionType + private(set) var task: WebSocketType? - private var stateTask: Task? - private var receiveTask: Task? + private(set) var stateTask: Task? + private(set) var receiveTask: Task? /// 핑 전송 task - private var healthCheck: Task? + private(set) var healthCheck: Task? private var pingInterval: Duration = .seconds(30) private var pingTimeout: Duration = .seconds(10) + private var attempts: Int = 0 - public init(url: URL, session: URLSession = .shared) { + public init( + url: URL, + session: URLSessionType = URLSession.shared, + stateBroadCaster: AsyncStreamBroadcaster = .init() + ) { self.url = url self.session = session + self.stateBroadCaster = stateBroadCaster stateStream = stateBroadCaster.stream() incomingChannel = AsyncChannel() @@ -35,12 +41,9 @@ public final class WebSocketClient: NSObject { /// 웹소켓 세션을 연결하고 작업을 생성합니다. public func connect() async { await stateBroadCaster.send(.connecting) - self.task = session.webSocketTask(with: url) + self.task = session.makeWebSocketTask(with: url) task?.delegate = self task?.resume() - - // 핑 응답은 연결 후에 오기 때문에 connected 시점을 캐치할 수 있음 - try? await performWithTimeout(sendPing, at: pingTimeout) } /// 명시적으로 현재 WebSocket 연결을 정상적으로 종료합니다. @@ -52,73 +55,33 @@ public final class WebSocketClient: NSObject { /// 텍스트 형태의 메시지를 WebSocket 서버로 전송합니다. public func send(text: String) async throws { - try await task?.send(.string(text)) + guard let task else { throw URLError(.notConnectedToInternet) } + try await task.send(.string(text)) } /// 바이너리(Data) 형태의 메시지를 WebSocket 서버로 전송합니다. public func send(data: Data) async throws { - try await task?.send(.data(data)) - } - - deinit { - debugPrint(String(describing: Self.self), #function) - task?.cancel() - task = nil - stateBroadCaster.finish() - incomingChannel.finish() - } -} - -// MARK: - Test용 메소드 -// TODO: Deprecated 예정입니다. -extension WebSocketClient { - public func sendState(with state: WebSocket.State) async { - await stateBroadCaster.send(state) - } - - public func cancel(with code: URLSessionWebSocketTask.CloseCode) { - task?.cancel(with: code, reason: nil) - task = nil - } - - public func cancel() { - task?.cancel() - task = nil + guard let task else { throw URLError(.notConnectedToInternet) } + try await task.send(.data(data)) } } // MARK: - Private extension WebSocketClient { - /// 서버로 Ping 프레임을 전송하여 연결 상태를 확인합니다. - private func sendPing() async throws { - return try await withCheckedThrowingContinuation { continuation in - task?.sendPing { error in - Task { - if let error { - debugPrint("Ping Failed: \(error)") - continuation.resume(throwing: error) - return - } - - continuation.resume() - } - } - } - } - /// WebSocket의 상태 변화를 관찰하고 각 상태에 맞는 동작을 수행합니다. private func observeState() { stateTask = Task { - for await state in stateStream { + for await state in stateStream.removeDuplicates() { switch state { case .connecting: debugPrint("Connecting") continue case .connected: debugPrint("Connected") + clearAttempts() receive() checkingAlive() - case .failed, .closed: + case .closed: debugPrint("Closed") release() case .reconnecting: @@ -129,37 +92,73 @@ extension WebSocketClient { } } - // FIXME: 개선이 필요한지 한 번 더 생각해보기 /// 서버로부터 WebSocket 메시지를 지속적으로 수신합니다. private func receive() { - receiveTask?.cancel() - receiveTask = Task { - do { - guard let task else { return } + while true { + guard let task else { throw CancellationError() } let message = try await task.receive() await incomingChannel.send(message) - receive() - } catch { - print("종료되어 더 이상 웹소켓 데이터를 받지 않습니다.") } } } /// 주기적으로 Ping을 전송하여 WebSocket 연결 상태를 점검합니다. private func checkingAlive() { - healthCheck?.cancel() - healthCheck = Task { do { while true { + try await performWithTimeout(sendPing, at: pingTimeout) try await Task.sleep(until: .now + pingInterval) - try await performWithTimeout(sendPing, at: .seconds(10)) } } catch is CancellationError { debugPrint("작업이 취소되었습니다.") } catch { - await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) + if handlePingError(error) { + if task?.state == .running { + task?.cancel() + } + } + } + } + } + + /// sendPing(:) 으로부터 받은 에러를 핸들링하는 메소드입니다. + /// - Parameter error: 에러를 전달받습니다. + /// - Returns: 재연결해야 한다면 true를 반환합니다. + private func handlePingError(_ error: Error) -> Bool { + if let urlError = error as? URLError { + switch urlError.code { // URLError (네트워크 단절) + case .notConnectedToInternet, .networkConnectionLost: + return true + default: + return false + } + } else if let posixError = error as? POSIXError { + switch posixError.code { // POSIXError (소켓이 죽음) + case .EPIPE, .ECONNRESET: + return true + default: + return false + } + } else { // 소켓이 정상상태가 아님. + return true + } + } + + /// 서버로 Ping 프레임을 전송하여 연결 상태를 확인합니다. + private func sendPing() async throws { + return try await withCheckedThrowingContinuation { continuation in + task?.sendPing { error in + Task { + if let error { + debugPrint("Ping Failed: \(error)") + continuation.resume(throwing: error) + return + } + + continuation.resume() + } } } } @@ -169,17 +168,28 @@ extension WebSocketClient { if userClose { await stateBroadCaster.send(.closed) } else { - await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) + await stateBroadCaster.send(.reconnecting) } } + private func clearAttempts() { + attempts = 0 + } + + /// WebSocket 재연결시에 백오프를 적용합니다. + /// - Returns: 백오프하는 시간을 Int 타입으로 반환합니다. + private func backoff() -> Int { + attempts += 1 + let base = min(pow(2.0, Double(attempts)) * 100.0, 10000) + let jitter = Double.random(in: 0.5...1.0) + + return Int(base * jitter) + } + /// WebSocket 재연결을 시도합니다. private func reconnect() async { - guard task?.state != .running else { - return - } - - try? await Task.sleep(for: .seconds(2)) + if task?.state == .running || attempts > 10 { return } + try? await Task.sleep(for: .milliseconds(backoff())) await connect() } @@ -189,11 +199,6 @@ extension WebSocketClient { receiveTask = nil healthCheck?.cancel() healthCheck = nil - - if task?.state == .running { - task?.cancel(with: .goingAway, reason: nil) - } - task = nil } } @@ -213,7 +218,7 @@ extension WebSocketClient: URLSessionWebSocketDelegate { // 1. 네트워크 닫힘, 2. 에러로 종료, 3. 정상적으로 완료 public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { if let _ = error { - Task { await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) } + Task { await stateBroadCaster.send(.reconnecting) } } } } diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketProvider.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketProvider.swift new file mode 100644 index 00000000..0e8feb7d --- /dev/null +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketProvider.swift @@ -0,0 +1,77 @@ +// +// SocketEngine.swift +// AIProject +// +// Created by kangho lee on 8/17/25. +// + +import Foundation +import AsyncAlgorithms + +public protocol WebSocketProvider { + var stateBroadCaster: AsyncStreamBroadcaster { get } + var incomingChannel: AsyncChannel { get } + + /// 웹소켓 세션을 연결하고 작업을 생성합니다. + func connect() async + + /// 명시적으로 현재 WebSocket 연결을 정상적으로 종료합니다. + /// + /// 이 메서드는 서버와의 WebSocket 연결을 `normalClosure` 코드로 닫습니다. + func disconnect() async + + /// 텍스트 형태의 메시지를 WebSocket 서버로 전송합니다. + func send(text: String) async throws + + /// 바이너리(Data) 형태의 메시지를 WebSocket 서버로 전송합니다. + func send(data: Data) async throws +} + +public protocol URLSessionType { + func makeWebSocketTask(with url: URL) -> WebSocketType +} + +extension URLSession: URLSessionType { + public func makeWebSocketTask(with url: URL) -> any WebSocketType { + return webSocketTask(with: url) + } +} + +public protocol WebSocketType { + var delegate: (any URLSessionTaskDelegate)? { get set } + var state: URLSessionTask.State { get } + + func resume() + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) + func send(_ message: URLSessionWebSocketTask.Message) async throws + func cancel() + func sendPing(pongReceiveHandler: @escaping @Sendable((any Error)?) -> Void) + func receive() async throws -> URLSessionWebSocketTask.Message +} + +extension URLSessionWebSocketTask: WebSocketType {} + +public enum WebSocket { + public enum State: Sendable { + case connecting, connected + case closed + case reconnecting + } +} + +extension WebSocket.State: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.connecting, .connecting): + return true + case (.connected, .connected): + return true + case (.closed, .closed): + return true + case (.reconnecting, .reconnecting): + return true + default: + return false + } + } +} diff --git a/AIProject/iCo/Data/API/Upbit/UpbitTickerService.swift b/AIProject/iCo/Data/API/Upbit/UpbitTickerService.swift index 2bcc2abe..7d5f16af 100644 --- a/AIProject/iCo/Data/API/Upbit/UpbitTickerService.swift +++ b/AIProject/iCo/Data/API/Upbit/UpbitTickerService.swift @@ -9,12 +9,12 @@ import Foundation /// 업비트 실시간 코인 시세 웹소켓 서비스 final class UpbitTickerService: RealTimeTickerProvider { - private let client: WebSocketClient + private let client: WebSocketProvider /// 소켓 상태 stream private var stateStreamTask: Task? - init(client: WebSocketClient = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!)) { + init(client: WebSocketProvider = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!)) { self.client = client } diff --git a/AIProject/iCo/Features/Market/CoinList/CoinListView.swift b/AIProject/iCo/Features/Market/CoinList/CoinListView.swift index 678a84c1..68e29ed8 100644 --- a/AIProject/iCo/Features/Market/CoinList/CoinListView.swift +++ b/AIProject/iCo/Features/Market/CoinList/CoinListView.swift @@ -125,9 +125,9 @@ extension CoinListView { print(#function, phase) switch phase { case .background: - await store.disconnect() - case .inactive: break + case .inactive: + await store.disconnect() case .active: await store.connect() @unknown default: diff --git a/AIProject/iCoTests/Socket/Resource/MockAsyncStreamBroadCaster.swift b/AIProject/iCoTests/Socket/Resource/MockAsyncStreamBroadCaster.swift new file mode 100644 index 00000000..259d42f9 --- /dev/null +++ b/AIProject/iCoTests/Socket/Resource/MockAsyncStreamBroadCaster.swift @@ -0,0 +1,17 @@ +// +// StateBroadCasterSpy.swift +// iCo +// +// Created by 강대훈 on 11/12/25. +// + +@testable import iCo + +final class MockAsyncStreamBroadCaster: AsyncStreamBroadcaster { + var log: [Element] = [] + + override func send(_ element: Element) async { + log.append(element) + await super.send(element) + } +} diff --git a/AIProject/iCoTests/Socket/Resource/MockURLSession.swift b/AIProject/iCoTests/Socket/Resource/MockURLSession.swift new file mode 100644 index 00000000..41920c41 --- /dev/null +++ b/AIProject/iCoTests/Socket/Resource/MockURLSession.swift @@ -0,0 +1,21 @@ +// +// MockURLSession.swift +// iCoTests +// +// Created by 강대훈 on 11/12/25. +// + +import Foundation +@testable import iCo + +final class MockURLSession: URLSessionType { + let task: MockWebSocketTask + + init(task: MockWebSocketTask) { + self.task = task + } + + func makeWebSocketTask(with url: URL) -> WebSocketType { + return task + } +} diff --git a/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift new file mode 100644 index 00000000..97f78f32 --- /dev/null +++ b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift @@ -0,0 +1,117 @@ +// +// MockWebSocketTask.swift +// iCo +// +// Created by 강대훈 on 11/12/25. +// + +import Foundation +@testable import iCo + +final class MockWebSocketTask: WebSocketType { + var delegate: URLSessionTaskDelegate? + + private var closed: Bool = false + private var taskState: URLSessionTask.State = .completed + private var fakeSession: URLSession = URLSession(configuration: .ephemeral) + private var fakeTask: URLSessionWebSocketTask { + fakeSession.webSocketTask(with: URL(string: "wss://")!) + } + + var state: URLSessionTask.State { + return taskState + } + + private var throwError: Bool + + var messages: [URLSessionWebSocketTask.Message] = [] + + var resumeCallCount: Int = 0 + var cancelCallCount: Int = 0 + var sendCallCount: Int = 0 + var sendPingCallCount: Int = 0 + var receiveCallCount: Int = 0 + + init(throwError: Bool = false) { + self.throwError = throwError + } + + func resume() { + resumeCallCount += 1 + + if let delegate = delegate as? URLSessionWebSocketDelegate { + taskState = .running + delegate.urlSession?(fakeSession, webSocketTask: fakeTask, didOpenWithProtocol: nil) + } + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + cancelCallCount += 1 + closed = true + taskState = .canceling + + if let delegate = delegate as? URLSessionWebSocketDelegate { + delegate.urlSession?(fakeSession, webSocketTask: fakeTask, didCloseWith: closeCode, reason: nil) + delegate.urlSession?(fakeSession, task: fakeTask, didCompleteWithError: nil) + } + + taskState = .completed + } + + func cancel() { + cancelCallCount += 1 + closed = true + taskState = .completed + + let error = URLError(.notConnectedToInternet) + delegate?.urlSession?(fakeSession, task: fakeTask, didCompleteWithError: error) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + if throwError { + taskState = .completed + throw NSError( + domain: NSURLErrorDomain, + code: -1009, + userInfo: [NSLocalizedDescriptionKey: "인터넷 에러 코드 -1009"] + ) + } + + messages.append(message) + sendCallCount += 1 + } + + func sendPing(pongReceiveHandler: @escaping ((any Error)?) -> Void) { + if throwError || state != .running { + pongReceiveHandler(NSError( + domain: NSURLErrorDomain, + code: -1009, + userInfo: [NSLocalizedDescriptionKey: "인터넷 에러 코드 -1009"] + )) + } else { + pongReceiveHandler(nil) + sendPingCallCount += 1 + } + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + if closed { + throw NSError( + domain: NSURLErrorDomain, + code: URLError.cancelled.rawValue, + userInfo: nil + ) + } + + receiveCallCount += 1 + return .string("데이터 잘 받았습니다.") + } + + func disconnect(with code: URLSessionWebSocketTask.CloseCode? = nil) { + if let code { + self.cancel(with: code, reason: nil) + } else { + self.cancel() + } + } +} diff --git a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift new file mode 100644 index 00000000..0df6839d --- /dev/null +++ b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift @@ -0,0 +1,200 @@ +// +// WebSocketTests.swift +// iCoTests +// +// Created by 강대훈 on 11/12/25. +// + +import XCTest +@testable import iCo + +final class WebSocketTests: XCTestCase { + let url: URL = URL(string: "wss://")! + var broadCaster: MockAsyncStreamBroadCaster! + var sut: WebSocketClient! + var task: MockWebSocketTask! + var urlSession: MockURLSession! + + override func setUp() async throws { + task = MockWebSocketTask() + broadCaster = MockAsyncStreamBroadCaster() + urlSession = MockURLSession(task: task) + sut = WebSocketClient(url: url, session: urlSession, stateBroadCaster: broadCaster) + } + + override func tearDown() async throws { + urlSession = nil + sut = nil + task = nil + broadCaster = nil + } + + func testInit() { + XCTAssertNil(sut.task) + XCTAssertNil(sut.receiveTask) + XCTAssertNil(sut.healthCheck) + XCTAssertNotNil(sut.stateTask) + } + + func testConnect() async { + // arrange + let expectedLog: [WebSocket.State] = [.connecting, .connected] + + // act + await sut.connect() + try? await Task.sleep(for: .seconds(0.1)) // WSS HandShake 대기 + + // assert + XCTAssertEqual(expectedLog, broadCaster.log) + XCTAssertNotNil(sut.task) + XCTAssertEqual(sut.task?.state, .running) + XCTAssertIdentical(sut.task?.delegate, sut) + XCTAssertEqual(1, task.resumeCallCount) + } + + func testDisconnect() async { + // arrange + let expectedLog: [WebSocket.State] = [.connecting, .connected, .closed] + + // act + await sut.connect() + try? await Task.sleep(for: .seconds(0.2)) + await sut.disconnect() + try? await Task.sleep(for: .seconds(0.2)) + + // assert + XCTAssertEqual(expectedLog, broadCaster.log) + XCTAssertNil(sut.healthCheck) + XCTAssertNil(sut.receiveTask) + XCTAssertNil(sut.task) + } + + func testReconnect_CloseCode를받았을때_재연결하는지() async { + // arrange + let expectedLog: [WebSocket.State] = [ + .connecting, + .connected, + .reconnecting, + .connecting, + .connected + ] + + // act + await sut.connect() + task.disconnect(with: .internalServerError) + try? await Task.sleep(for: .seconds(4)) + + // assert + XCTAssertEqual(expectedLog, broadCaster.log) + XCTAssertNotNil(sut.task) + XCTAssertNotNil(sut.stateTask) + XCTAssertNotNil(sut.receiveTask) + XCTAssertNotNil(sut.healthCheck) + } + + func testReconnect_CloseCode를받지못했을때_재연결하는지() async { + // arrange + let expectedLog: [WebSocket.State] = [ + .connecting, + .connected, + .reconnecting, + .connecting, + .connected + ] + + await sut.connect() + task.disconnect() + try? await Task.sleep(for: .seconds(4)) + + XCTAssertEqual(expectedLog, broadCaster.log) + XCTAssertNotNil(sut.task) + XCTAssertNotNil(sut.stateTask) + XCTAssertNotNil(sut.receiveTask) + XCTAssertNotNil(sut.healthCheck) + } + + func testSend_Failed_notConnected() async throws { + let value: ()? = try? await sut.send(text: "Hello") + XCTAssertNil(value) + } + + func testSend_Success() async throws { + let values = ["Hello", "Swift", "Test"] + + await sut.connect() + + try await sut.send(text: values[0]) + try await sut.send(text: values[1]) + try await sut.send(text: values[2]) + + if case .string(let text) = task.messages[1] { + XCTAssertEqual(text, values[1]) + } else { + XCTFail("not found") + } + + XCTAssertEqual(task.sendCallCount, 3) + XCTAssertEqual(task.messages.count, 3) + } + + func testSend_dataSucess() async throws { + await sut.connect() + + try await sut.send(data: Data("Hello".utf8)) + + if case .data(let encoded) = task.messages[0] { + XCTAssertEqual("Hello", String(data: encoded, encoding: .utf8)) + } else { + XCTFail("not found") + } + + XCTAssertEqual(task.sendCallCount, 1) + } + + func testPing_Failed_notConnected() async throws { + let exp = expectation(description: "Wait for request") + + makeSUTError() + task.sendPing { error in + XCTAssertNotNil(error) + exp.fulfill() + } + + await fulfillment(of: [exp], timeout: 0.3) + } + + func testPing_Failed_pingTimeout() async throws { + let exp = expectation(description: "Wait for request") + + makeSUTError() + await sut.connect() + task.sendPing { error in + XCTAssertNotNil(error) + exp.fulfill() + } + + await fulfillment(of: [exp], timeout: 0.3) + } + + func testPing_Success() async throws { + let exp = expectation(description: "Wait for request") + + await sut.connect() + + task.sendPing { error in + XCTAssertNil(error) + exp.fulfill() + } + + await fulfillment(of: [exp], timeout: 0.3) + } +} + +extension WebSocketTests { + private func makeSUTError() { + task = MockWebSocketTask(throwError: true) + broadCaster = MockAsyncStreamBroadCaster() + urlSession = MockURLSession(task: task) + sut = WebSocketClient(url: url, session: urlSession, stateBroadCaster: broadCaster) + } +} diff --git a/AIProject/iCoTests/Socket/WebSocketTest.swift b/AIProject/iCoTests/Socket/WebSocketTest.swift deleted file mode 100644 index 005f60af..00000000 --- a/AIProject/iCoTests/Socket/WebSocketTest.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// WebSocketTest.swift -// iCoTests -// -// Created by kangho on 10/25/25. -// - -import XCTest -import iCo - -final class WebSocketTest: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() async throws { - let sut = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!) - await sut.connect() - try await sut.send(text: "[{ticket:test},{type:ticker,codes:[KRW-BTC]}]") - } - - func testReconnenctWhenAbnormalClose() async throws { - let sut = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!) - - await sut.connect() - - try await Task.sleep(for: .seconds(2)) - sut.cancel(with: .abnormalClosure) - try await Task.sleep(for: .seconds(2)) - // connecting -> connected -> abnormal close -> handleDisconnect -> reconnect - // .... -> abnormal close -> didCompletWithError -> reconnect - } - - func testReceiveData() async throws { - let sut = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!) - let requestFormat = "[{ticket:test},{type:ticker,codes:[KRW-BTC]}]" - - await sut.connect() - try await sut.send(text: requestFormat) - try await Task.sleep(for: .seconds(3)) - - sut.cancel() - - try await Task.sleep(for: .seconds(3)) - try await sut.send(text: requestFormat) - try await Task.sleep(for: .seconds(10)) - } - - func testUserClose() async throws { - let sut = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!) - await sut.connect() - try await Task.sleep(for: .seconds(2)) - await sut.disconnect() - try await Task.sleep(for: .seconds(2)) - await sut.connect() - try await Task.sleep(for: .seconds(10)) - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } -} -