diff --git a/.github/workflows/run-unit-tests-with-coverage.yml b/.github/workflows/run-unit-tests-with-coverage.yml new file mode 100644 index 0000000..1159bdb --- /dev/null +++ b/.github/workflows/run-unit-tests-with-coverage.yml @@ -0,0 +1,88 @@ +name: Run Tests with Coverage + +on: + pull_request: + types: ["opened", "reopened", "ready_for_review", "synchronize"] + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + test-and-coverage: + runs-on: macos-15 + env: + COVERAGE_HTML_DIR: coverage-html + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Show toolchain info + run: | + swift --version + + - name: Build & Test (SwiftPM, with coverage) + run: | + set -eo pipefail + swift build --configuration debug + swift test --parallel --enable-code-coverage + + - name: Generate coverage summary and HTML + run: | + set -eo pipefail + BIN_PATH=$(swift build --show-bin-path) + PROF_DATA="${BIN_PATH}/codecov/default.profdata" + + # Fallback: merge any .profraw into default.profdata if needed + if [[ ! -f "${PROF_DATA}" ]]; then + mkdir -p "${BIN_PATH}/codecov" + RAWS=$(find "${BIN_PATH}/codecov" -name "*.profraw" -print || true) + if [[ -n "$RAWS" ]]; then + xcrun llvm-profdata merge -sparse $RAWS -o "${BIN_PATH}/codecov/default.profdata" + fi + fi + + if [[ ! -f "${PROF_DATA}" ]]; then + echo "Coverage profile not found at ${PROF_DATA}" >&2 + exit 1 + fi + + # Collect test binaries (.xctest bundles) + TEST_BINS=() + while IFS= read -r b; do + name=$(basename "$b" .xctest) + TEST_BINS+=("$b/Contents/MacOS/$name") + done < <(find "${BIN_PATH}" -type d -name "*.xctest" -print) + + if [[ ${#TEST_BINS[@]} -eq 0 ]]; then + echo "No test binaries found in ${BIN_PATH}" >&2 + exit 1 + fi + + # Human-readable summary + xcrun llvm-cov report \ + -instr-profile "${PROF_DATA}" \ + "${TEST_BINS[@]}" | sed -E 's/\x1b\[[0-9;]*m//g' > coverage-summary.txt + + # HTML report + mkdir -p "${COVERAGE_HTML_DIR}" + xcrun llvm-cov show \ + -format=html \ + -instr-profile "${PROF_DATA}" \ + -ignore-filename-regex '\\.(build|Build)|Tests' \ + "${TEST_BINS[@]}" \ + -output-dir "${COVERAGE_HTML_DIR}" + + - name: Upload HTML coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: ${{ env.COVERAGE_HTML_DIR }}/ + + - name: Print coverage summary + if: always() + run: | + echo "=== llvm-cov summary ===" + cat coverage-summary.txt || true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1e54057..a2bd87d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc .claude/settings.local.json +.cursor +.swiftpm \ No newline at end of file diff --git a/Sources/Hume/Services/Networking/AccessTokenResolver.swift b/Sources/Hume/Services/Networking/AccessTokenResolver.swift deleted file mode 100644 index e69de29..0000000 diff --git a/Sources/Hume/Services/Networking/HTTPMethod.swift b/Sources/Hume/Services/Networking/HTTPMethod.swift index 3dae523..34d0947 100644 --- a/Sources/Hume/Services/Networking/HTTPMethod.swift +++ b/Sources/Hume/Services/Networking/HTTPMethod.swift @@ -7,7 +7,7 @@ import Foundation -enum HTTPMethod: String { +enum HTTPMethod: String, CaseIterable { case get = "GET" case post = "POST" case put = "PUT" diff --git a/Tests/HumeTests/Extensions/AVAdditionsTest.swift b/Tests/HumeTests/Extensions/AVAdditionsTest.swift new file mode 100644 index 0000000..68f527f --- /dev/null +++ b/Tests/HumeTests/Extensions/AVAdditionsTest.swift @@ -0,0 +1,33 @@ +// +// AVAdditionsTest.swift +// Hume +// + +import AVFoundation +import Foundation +import Testing + +@testable import Hume + +struct AVAdditionsTest { + #if os(iOS) + @Test func avAudioSession_recordPermission_maps_to_MicrophonePermission() async throws { + // Assert direct mapping values compile and map correctly + #expect(AVAudioSession.RecordPermission.undetermined.asMicrophonePermission == .undetermined) + #expect(AVAudioSession.RecordPermission.denied.asMicrophonePermission == .denied) + #expect(AVAudioSession.RecordPermission.granted.asMicrophonePermission == .granted) + } + + @Test func avAudioApplication_recordPermission_maps_to_MicrophonePermission() async throws { + if #available(iOS 17.0, *) { + #expect( + AVAudioApplication.recordPermission.undetermined.asMicrophonePermission == .undetermined) + #expect(AVAudioApplication.recordPermission.denied.asMicrophonePermission == .denied) + #expect(AVAudioApplication.recordPermission.granted.asMicrophonePermission == .granted) + } else { + // Can't reference AVAudioApplication on earlier iOS; ensure test doesn't run + #expect(true) + } + } + #endif +} diff --git a/Tests/HumeTests/Extensions/AssistantInputExtensionsTest.swift b/Tests/HumeTests/Extensions/AssistantInputExtensionsTest.swift new file mode 100644 index 0000000..ddd19c1 --- /dev/null +++ b/Tests/HumeTests/Extensions/AssistantInputExtensionsTest.swift @@ -0,0 +1,25 @@ +// +// AssistantInputExtensionsTest.swift +// Hume +// + +import Foundation +import Testing + +@testable import Hume + +struct AssistantInputExtensionsTest { + + @Test func init_text_sets_nil_sessionId_and_text_matches() async throws { + // Arrange + let inputText = "Hello, Assistant!" + + // Act + let sut = AssistantInput(text: inputText) + + // Assert + #expect(sut.customSessionId == nil) + #expect(sut.text == inputText) + } + +} diff --git a/Tests/HumeTests/Extensions/AudioOutputExtensionsTest.swift b/Tests/HumeTests/Extensions/AudioOutputExtensionsTest.swift new file mode 100644 index 0000000..ca57f65 --- /dev/null +++ b/Tests/HumeTests/Extensions/AudioOutputExtensionsTest.swift @@ -0,0 +1,49 @@ +// +// AudioOutputExtensionsTest.swift +// Hume +// + +import Foundation +import Testing + +@testable import Hume + +struct AudioOutputExtensionsTest { + + @Test func asBase64EncodedData_returns_data_for_valid_base64() async throws { + // Arrange + let bytes = Data([0x61, 0x62, 0x63]) // "abc" + let base64 = bytes.base64EncodedString() // "YWJj" + let model = AudioOutput( + type: "audio_output", + customSessionId: nil, + id: "id1", + index: 0, + data: base64 + ) + + // Act + let decoded = model.asBase64EncodedData + + // Assert + #expect(decoded == bytes) + } + + @Test func asBase64EncodedData_returns_nil_for_invalid_base64() async throws { + // Arrange + let model = AudioOutput( + type: "audio_output", + customSessionId: nil, + id: "id2", + index: 1, + data: "not-base64!!" + ) + + // Act + let decoded = model.asBase64EncodedData + + // Assert + #expect(decoded == nil) + } + +} diff --git a/Tests/HumeTests/Extensions/DataAdditionsTest.swift b/Tests/HumeTests/Extensions/DataAdditionsTest.swift new file mode 100644 index 0000000..f7a0828 --- /dev/null +++ b/Tests/HumeTests/Extensions/DataAdditionsTest.swift @@ -0,0 +1,69 @@ +// +// DataAdditionsTest.swift +// Hume +// + +import Foundation +import Testing + +@testable import Hume + +struct DataAdditionsTest { + #if os(iOS) + @Test func parseWAVHeader_parses_minimal_valid_header_and_maps_to_avformat() async throws { + // Arrange: Build 44-byte RIFF/WAVE header (little-endian) matching WAVHeader expectations + // Fields we care about: chunkID "RIFF", format "WAVE", subchunk1ID "fmt ", + // audioFormat=1 (PCM), numChannels=2, sampleRate=48000, byteRate, blockAlign, bitsPerSample=16 + var bytes = [UInt8](repeating: 0, count: 44) + + func putString(_ s: String, at offset: Int) { + let data = s.data(using: .ascii)! + for (i, b) in data.enumerated() { bytes[offset + i] = b } + } + func putUInt16(_ v: UInt16, at offset: Int) { + let le = withUnsafeBytes(of: v.littleEndian) { Array($0) } + bytes[offset] = le[0] + bytes[offset + 1] = le[1] + } + func putUInt32(_ v: UInt32, at offset: Int) { + let le = withUnsafeBytes(of: v.littleEndian) { Array($0) } + bytes[offset] = le[0] + bytes[offset + 1] = le[1] + bytes[offset + 2] = le[2] + bytes[offset + 3] = le[3] + } + + // RIFF header + putString("RIFF", at: 0) + putUInt32(36, at: 4) // chunk size (unused by parser) + putString("WAVE", at: 8) + // fmt subchunk + putString("fmt ", at: 12) + putUInt32(16, at: 16) // subchunk1 size (PCM) + putUInt16(1, at: 20) // audioFormat = PCM + putUInt16(2, at: 22) // numChannels = 2 + putUInt32(48000, at: 24) // sampleRate + putUInt32(48000 * 2 * 16 / 8, at: 28) // byteRate = sampleRate * channels * blitsPerSample/8 + putUInt16(2 * 16 / 8, at: 32) // blockAlign = channels * bitsPerSample/8 + putUInt16(16, at: 34) // bitsPerSample + // data subchunk header (not read by parser but include to make header realistic) + putString("data", at: 36) + putUInt32(0, at: 40) + + let data = Data(bytes) + + // Act + let header = data.parseWAVHeader() + + // Assert header parsed + #expect(header != nil) + #expect(header!.isValid) + + // Verify fields and mapping to AVAudioFormat + let asFormat = header!.asAVAudioFormat + #expect(asFormat != nil) + #expect(asFormat!.sampleRate == 48000) + #expect(asFormat!.channelCount == 2) + } + #endif +} diff --git a/Tests/HumeTests/Extensions/DecodingErrorAdditionsTest.swift b/Tests/HumeTests/Extensions/DecodingErrorAdditionsTest.swift new file mode 100644 index 0000000..543ee9c --- /dev/null +++ b/Tests/HumeTests/Extensions/DecodingErrorAdditionsTest.swift @@ -0,0 +1,13 @@ +// +// DecodingErrorAdditionsTest.swift +// Hume +// + +import Foundation +import Testing + +@testable import Hume + +struct DecodingErrorAdditionsTest { + +} diff --git a/Tests/HumeTests/Extensions/EmotionScoresExtensionsTest.swift b/Tests/HumeTests/Extensions/EmotionScoresExtensionsTest.swift new file mode 100644 index 0000000..6c9b134 --- /dev/null +++ b/Tests/HumeTests/Extensions/EmotionScoresExtensionsTest.swift @@ -0,0 +1,55 @@ +// +// EmotionScoresExtensionsTest.swift +// Hume +// + +import Foundation +import Testing + +@testable import Hume + +struct EmotionScoresExtensionsTest { + @Test func topThree_returns_top3_sorted_desc() async throws { + // Arrange + let scores: EmotionScores = [ + "Joy": 0.9, + "Sadness": 0.1, + "Anger": 0.7, + "Calmness": 0.8, + "Boredom": 0.05, + ] + + // Act + let top = scores.topThree + + // Assert + #expect(top.count == 3) + #expect(top[0].name == "Joy" && top[0].value == 0.9) + #expect(top[1].name == "Calmness" && top[1].value == 0.8) + #expect(top[2].name == "Anger" && top[2].value == 0.7) + } + + @Test func topThree_handles_fewer_than_three_entries() async throws { + // Arrange + let scores: EmotionScores = ["Joy": 0.2, "Calmness": 0.3] + + // Act + let top = scores.topThree + + // Assert + #expect(top.count == 2) + #expect(Set(top.map { $0.name }) == Set(["Calmness", "Joy"])) + #expect(top[0].value >= top[1].value) + } + + @Test func topThree_on_empty_returns_empty() async throws { + // Arrange + let scores: EmotionScores = [:] + + // Act + let top = scores.topThree + + // Assert + #expect(top.isEmpty) + } +} diff --git a/Tests/HumeTests/Extensions/SessionSettingsCopyTest.swift b/Tests/HumeTests/Extensions/SessionSettingsCopyTest.swift new file mode 100644 index 0000000..55393d0 --- /dev/null +++ b/Tests/HumeTests/Extensions/SessionSettingsCopyTest.swift @@ -0,0 +1,82 @@ +// +// SessionSettingsCopyTest.swift +// Hume +// + +import Foundation +import Testing + +@testable import Hume + +struct SessionSettingsCopyTest { + private func makeSettings() -> SessionSettings { + SessionSettings( + audio: AudioConfiguration(channels: 1, encoding: .linear16, sampleRate: 16000), + builtinTools: [BuiltinToolConfig(fallbackContent: nil, name: .hangUp)], + context: Context(text: "ctx", type: .temporary), + customSessionId: "sid", + languageModelApiKey: "lm-key", + systemPrompt: "sys", + tools: [ + Tool(description: nil, fallbackContent: nil, name: "t1", parameters: "{}", type: .builtin) + ], + variables: ["k": "v"] + ) + } + + @Test func copy_without_overrides_returns_identical_values() async throws { + // Arrange + let original = makeSettings() + + // Act + let copied = original.copy() + + // Assert + #expect(copied.audio == original.audio) + #expect(copied.builtinTools == original.builtinTools) + #expect(copied.context == original.context) + #expect(copied.customSessionId == original.customSessionId) + #expect(copied.languageModelApiKey == original.languageModelApiKey) + #expect(copied.systemPrompt == original.systemPrompt) + #expect(copied.tools == original.tools) + #expect(copied.variables == original.variables) + #expect(copied.type == "session_settings") + } + + @Test func copy_with_overrides_applies_new_values_and_keeps_others() async throws { + // Arrange + let original = makeSettings() + let newAudio = AudioConfiguration(channels: 2, encoding: .linear16, sampleRate: 44100) + let newBuiltinTools = [BuiltinToolConfig(fallbackContent: "fb", name: .webSearch)] + let newContext = Context(text: "newctx", type: .persistent) + let newTools = [ + Tool( + description: "d", fallbackContent: "fc", name: "t2", parameters: "{\"x\":1}", type: .builtin + ) + ] + let newVars = ["a": "b"] + + // Act + let copied = original.copy( + audio: newAudio, + builtinTools: newBuiltinTools, + context: newContext, + customSessionId: "sid2", + languageModelApiKey: "lm2", + systemPrompt: "sys2", + tools: newTools, + variables: newVars + ) + + // Assert + #expect(copied.audio == newAudio) + #expect(copied.builtinTools == newBuiltinTools) + #expect(copied.context == newContext) + #expect(copied.customSessionId == "sid2") + #expect(copied.languageModelApiKey == "lm2") + #expect(copied.systemPrompt == "sys2") + #expect(copied.tools == newTools) + #expect(copied.variables == newVars) + #expect(copied.type == "session_settings") + } +} diff --git a/Tests/HumeTests/Extensions/UserInputConvenienceTest.swift b/Tests/HumeTests/Extensions/UserInputConvenienceTest.swift new file mode 100644 index 0000000..fcb53ba --- /dev/null +++ b/Tests/HumeTests/Extensions/UserInputConvenienceTest.swift @@ -0,0 +1,21 @@ +// +// UserInputConvenienceTest.swift +// Hume +// + +import Foundation +import Testing + +@testable import Hume + +struct UserInputConvenienceTest { + @Test func init_text_sets_properties_and_type() async throws { + // Act + let model = UserInput(text: "hello") + + // Assert + #expect(model.text == "hello") + #expect(model.customSessionId == nil) + #expect(model.type == "user_input") + } +} diff --git a/Tests/HumeTests/Extensions/WebSocketErrorExtensionsTest.swift b/Tests/HumeTests/Extensions/WebSocketErrorExtensionsTest.swift new file mode 100644 index 0000000..427aefa --- /dev/null +++ b/Tests/HumeTests/Extensions/WebSocketErrorExtensionsTest.swift @@ -0,0 +1,13 @@ +// +// WebSocketErrorExtensionsTest.swift +// Hume +// + +import Foundation +import Testing + +@testable import Hume + +struct WebSocketErrorExtensionsTest { + +} diff --git a/Tests/HumeTests/Services/Networking/EndpointTest.swift b/Tests/HumeTests/Services/Networking/EndpointTest.swift new file mode 100644 index 0000000..173b700 --- /dev/null +++ b/Tests/HumeTests/Services/Networking/EndpointTest.swift @@ -0,0 +1,51 @@ +// +// EndpointTest.swift +// Hume +// +// Created by AI on 9/9/25. +// + +import Foundation +import Testing + +@testable import Hume + +private struct DummyResponse: NetworkClientResponse {} + +struct EndpointTest { + + @Test func initializes_with_defaults() async throws { + let ep = Endpoint(path: "/v1/defaults") + #expect(ep.path == "/v1/defaults") + #expect(ep.method == .get) + #expect(ep.headers == nil) + #expect(ep.queryParams == nil) + #expect(ep.body == nil) + #expect(ep.cachePolicy == .useProtocolCachePolicy) + #expect(ep.timeoutDuration == 60) + #expect(ep.maxRetries == 0) + } + + @Test func preserves_custom_values() async throws { + struct Req: NetworkClientRequest { let x: Int } + let ep = Endpoint( + path: "/v1/custom", + method: .post, + headers: ["X": "1"], + queryParams: ["q": "z"], + body: Req(x: 9), + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutDuration: 15, + maxRetries: 2 + ) + + #expect(ep.path == "/v1/custom") + #expect(ep.method == .post) + #expect(ep.headers?["X"] == "1") + #expect(ep.queryParams?["q"] == "z") + #expect(ep.body != nil) + #expect(ep.cachePolicy == .reloadIgnoringLocalCacheData) + #expect(ep.timeoutDuration == 15) + #expect(ep.maxRetries == 2) + } +} diff --git a/Tests/HumeTests/Services/Networking/HTTPMethodTest.swift b/Tests/HumeTests/Services/Networking/HTTPMethodTest.swift new file mode 100644 index 0000000..6f892f0 --- /dev/null +++ b/Tests/HumeTests/Services/Networking/HTTPMethodTest.swift @@ -0,0 +1,31 @@ +// +// HTTPMethodTest.swift +// Hume +// +// Created by Chris on 9/9/25. +// + +import Testing + +@testable import Hume + +struct HTTPMethodTest { + + @Test func testRawVAlues() async throws { + for method in HTTPMethod.allCases { + switch method { + case .get: + #expect(method.rawValue == "GET") + case .post: + #expect(method.rawValue == "POST") + case .put: + #expect(method.rawValue == "PUT") + case .patch: + #expect(method.rawValue == "PATCH") + case .delete: + #expect(method.rawValue == "DELETE") + } + } + } + +} diff --git a/Tests/HumeTests/Services/Networking/HumeAuthTest.swift b/Tests/HumeTests/Services/Networking/HumeAuthTest.swift new file mode 100644 index 0000000..0ac231a --- /dev/null +++ b/Tests/HumeTests/Services/Networking/HumeAuthTest.swift @@ -0,0 +1,160 @@ +// +// HumeAuthTest.swift +// Hume +// +// Created by Chris on 9/9/25. +// + +import Foundation +import Testing + +@testable import Hume + +struct HumeAuthTest { + + // MARK: - Local Constants + private enum Constants { + static let baseURLString = "https://api.example.com" + static let apiPath = "/v1/test" + + static let authorizationHeader = "Authorization" + static let xApiKeyHeader = "X-API-Key" + + static let accessTokenQueryItem = "accessToken" + static let apiKeyQueryItem = "apiKey" + + static let accessToken = "abc123" + static let apiKey = "key_123" + static let providerAccessToken = "tok_success" + + static let wsScheme = "wss" + static let wsHost = "example.com" + static let wsPath = "/socket" + } + + // MARK: - Helpers + private func makeRequestBuilder() -> RequestBuilder { + return RequestBuilder(baseURL: URL(string: Constants.baseURLString)!) + .setPath(Constants.apiPath) + } + + // MARK: - RequestBuilder authenticate(_:) + + @Test func requestBuilder_accessToken_setsAuthorizationHeader() async throws { + let builder = makeRequestBuilder() + let auth = HumeAuth.accessToken(Constants.accessToken) + + let authed = try await auth.authenticate(builder) + let request = try authed.build() + let headers = request.allHTTPHeaderFields ?? [:] + + #expect(headers[Constants.authorizationHeader] == "Bearer \(Constants.accessToken)") + } + + @Test func requestBuilder_apiKey_setsXAPIKeyHeader() async throws { + let builder = makeRequestBuilder() + let auth = HumeAuth.apiKey(Constants.apiKey) + + let authed = try await auth.authenticate(builder) + let request = try authed.build() + let headers = request.allHTTPHeaderFields ?? [:] + + #expect(headers[Constants.xApiKeyHeader] == Constants.apiKey) + } + + @Test func requestBuilder_accessTokenProvider_success_setsAuthorizationHeader() async throws { + let builder = makeRequestBuilder() + let auth = HumeAuth.accessTokenProvider { Constants.providerAccessToken } + + let authed = try await auth.authenticate(builder) + let request = try authed.build() + let headers = request.allHTTPHeaderFields ?? [:] + + #expect(headers[Constants.authorizationHeader] == "Bearer \(Constants.providerAccessToken)") + } + + @Test func requestBuilder_accessTokenProvider_throwing_rethrows() async { + enum DummyError: Error { case boom } + let builder = makeRequestBuilder() + let auth = HumeAuth.accessTokenProvider { + throw DummyError.boom + } + + var didThrow = false + do { + _ = try await auth.authenticate(builder) + } catch { + didThrow = true + } + #expect(didThrow == true) + } + + // MARK: - URLComponents authenticate(_:) + + @Test func urlComponents_accessToken_addsQueryItem() async throws { + var components = URLComponents() + components.scheme = Constants.wsScheme + components.host = Constants.wsHost + components.path = Constants.wsPath + + let auth = HumeAuth.accessToken(Constants.accessToken) + try await auth.authenticate(&components) + + let items = components.queryItems ?? [] + #expect( + items.contains(where: { + $0.name == Constants.accessTokenQueryItem && $0.value == Constants.accessToken + })) + } + + @Test func urlComponents_apiKey_addsQueryItem() async throws { + var components = URLComponents() + components.scheme = Constants.wsScheme + components.host = Constants.wsHost + components.path = Constants.wsPath + + let auth = HumeAuth.apiKey(Constants.apiKey) + try await auth.authenticate(&components) + + let items = components.queryItems ?? [] + #expect( + items.contains(where: { $0.name == Constants.apiKeyQueryItem && $0.value == Constants.apiKey } + )) + } + + @Test func urlComponents_accessTokenProvider_success_addsQueryItem() async throws { + var components = URLComponents() + components.scheme = Constants.wsScheme + components.host = Constants.wsHost + components.path = Constants.wsPath + + let auth = HumeAuth.accessTokenProvider { Constants.providerAccessToken } + try await auth.authenticate(&components) + + let items = components.queryItems ?? [] + #expect( + items.contains(where: { + $0.name == Constants.accessTokenQueryItem && $0.value == Constants.providerAccessToken + })) + } + + @Test func urlComponents_accessTokenProvider_throwing_rethrows() async { + enum DummyError: Error { case boom } + var components = URLComponents() + components.scheme = "wss" + components.host = "example.com" + components.path = "/socket" + + let auth = HumeAuth.accessTokenProvider { + throw DummyError.boom + } + + var didThrow = false + do { + try await auth.authenticate(&components) + } catch { + didThrow = true + } + #expect(didThrow == true) + } +} diff --git a/Tests/HumeTests/Services/Networking/NetworkClientTest.swift b/Tests/HumeTests/Services/Networking/NetworkClientTest.swift new file mode 100644 index 0000000..07ad025 --- /dev/null +++ b/Tests/HumeTests/Services/Networking/NetworkClientTest.swift @@ -0,0 +1,282 @@ +// +// NetworkClientTest.swift +// Hume +// +// Created by AI on 9/9/25. +// + +import Foundation +import Testing + +@testable import Hume + +// MARK: - Mocks + +private final class MockNetworkingService: NetworkingService { + var lastRequest: URLRequest? + + var performRequestImpl: ((URLRequest) async throws -> Any)? + var streamDataImpl: ((URLRequest) -> AsyncThrowingStream)? + + func performRequest(_ request: URLRequest) async throws -> Response + where Response: Decodable { + lastRequest = request + if let impl = performRequestImpl { + let any = try await impl(request) + guard let typed = any as? Response else { + fatalError("MockNetworkingService: type mismatch for Response=\(Response.self)") + } + return typed + } + fatalError("MockNetworkingService.performRequestImpl not set") + } + + func streamData(for request: URLRequest) -> AsyncThrowingStream { + lastRequest = request + if let impl = streamDataImpl { + return impl(request) + } + return AsyncThrowingStream { continuation in + continuation.finish() + } + } +} + +// MARK: - Fixtures + +private struct TestBody: Codable, Hashable { + let foo: String + let bar: Int +} +private struct TestEvent: Decodable, Hashable { let x: Int } + +extension NetworkClientImpl { + fileprivate static func makeForTests( + baseURL: URL = URL(string: "https://api.example.com")!, + auth: HumeAuth = .accessToken("tok"), + service: NetworkingService + ) -> NetworkClientImpl { + .init(baseURL: baseURL, auth: auth, networkingService: service) + } +} + +struct NetworkClientTest { + + // nc-send-builds-and-returns + @Test func send_builds_request_and_returns_response() async throws { + let mock = MockNetworkingService() + let expected = EmptyResponse() + mock.performRequestImpl = { _ in expected } + + let client = NetworkClientImpl.makeForTests(service: mock) + let endpoint = Endpoint( + path: "/v1/foo", + method: .post, + headers: [ + "Content-Type": "text/plain", + "X-Custom": "1", + ], + queryParams: ["q": "1", "w": "z"], + body: TestBody(foo: "hello", bar: 42), + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutDuration: 5 + ) + + let response = try await client.send(endpoint) + #expect(response == expected) + + // Validate built URLRequest + let request = try #require(mock.lastRequest) + + // URL + query params + let url = try #require(request.url) + let comps = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(comps.path == "/v1/foo") + let items = comps.queryItems ?? [] + #expect(items.contains(where: { $0.name == "q" && $0.value == "1" })) + #expect(items.contains(where: { $0.name == "w" && $0.value == "z" })) + + // Method + #expect(request.httpMethod == HTTPMethod.post.rawValue) + + // Headers (auth + override of Content-Type + custom) + let headers = request.allHTTPHeaderFields ?? [:] + #expect(headers["Authorization"] == "Bearer tok") + #expect(headers["Content-Type"] == "text/plain") + #expect(headers["X-Custom"] == "1") + + // Cache policy and timeout + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + #expect(request.timeoutInterval == 5) + + // Body encodes our payload + let body = try #require(request.httpBody) + let decoded = try JSONDecoder().decode(TestBody.self, from: body) + #expect(decoded == TestBody(foo: "hello", bar: 42)) + } + + // nc-send-notification-on-error + @Test func send_posts_notification_and_throws_on_error_nonretryable() async { + enum Dummy: Error { case boom } + let mock = MockNetworkingService() + mock.performRequestImpl = { _ in throw Dummy.boom } + + let client = NetworkClientImpl.makeForTests(service: mock) + let endpoint = Endpoint(path: "/v1/err") + + // Observe notification + let notifications = NotificationCenter.default.notifications( + named: NetworkClientNotification.DidReceiveNetworkError + ) + + var didReceiveNotification = false + let notifierTask = Task { + for await _ in notifications { + didReceiveNotification = true + break + } + } + + var didThrow = false + do { + _ = try await client.send(endpoint) + } catch { + didThrow = true + } + + // Allow the async notification to post on main + try? await Task.sleep(nanoseconds: 100_000_000) + notifierTask.cancel() + + #expect(didThrow) + #expect(didReceiveNotification) + } + + // nc-send-headers-override + @Test func send_applies_and_overrides_endpoint_headers() async throws { + let mock = MockNetworkingService() + mock.performRequestImpl = { _ in EmptyResponse() } + + let client = NetworkClientImpl.makeForTests(service: mock) + let endpoint = Endpoint( + path: "/v1/headers", + method: .get, + headers: ["Content-Type": "application/xml"] + ) + + _ = try await client.send(endpoint) + let request = try #require(mock.lastRequest) + let headers = request.allHTTPHeaderFields ?? [:] + // Default set to application/json in makeRequestBuilder, then overridden by endpoint.headers + #expect(headers["Content-Type"] == "application/xml") + } + + // nc-stream-data-pass-through + @Test func stream_yields_data_chunks_for_Data_response() async throws { + let mock = MockNetworkingService() + mock.performRequestImpl = { _ in Data() } // not used + mock.streamDataImpl = { _ in + AsyncThrowingStream { continuation in + continuation.yield(Data([0x61, 0x62, 0x63])) // "abc" + continuation.yield(Data([0x64, 0x65])) // "de" + continuation.finish() + } + } + + let client = NetworkClientImpl.makeForTests(service: mock) + let endpoint = Endpoint(path: "/v1/stream", method: .get) + + var chunks: [Data] = [] + for try await data in client.stream(endpoint) { + chunks.append(data) + } + + #expect(chunks.count == 2) + #expect(chunks[0] == Data([0x61, 0x62, 0x63])) + #expect(chunks[1] == Data([0x64, 0x65])) + } + + // nc-stream-jsonl-decoding + @Test func stream_decodes_newline_delimited_json_across_chunk_boundaries() async throws { + let mock = MockNetworkingService() + mock.streamDataImpl = { _ in + AsyncThrowingStream { continuation in + // {"x":1}\n{"x":2}\n split across chunks + continuation.yield(Data("{\"x\":1}\n{\"x\":".utf8)) + continuation.yield(Data("2}\n".utf8)) + continuation.finish() + } + } + + let client = NetworkClientImpl.makeForTests(service: mock) + let endpoint = Endpoint(path: "/v1/jsonl", method: .get) + + var events: [TestEvent] = [] + for try await evt in client.stream(endpoint) { + events.append(evt) + } + + #expect(events == [TestEvent(x: 1), TestEvent(x: 2)]) + } + + // nc-stream-error-propagation + @Test func stream_finishes_with_error_when_underlying_stream_throws() async { + enum DummyErr: Error { case nope } + let mock = MockNetworkingService() + mock.streamDataImpl = { _ in + AsyncThrowingStream { continuation in + continuation.finish(throwing: DummyErr.nope) + } + } + + let client = NetworkClientImpl.makeForTests(service: mock) + let endpoint = Endpoint(path: "/v1/err-stream", method: .get) + + var didThrow = false + do { + for try await _ in client.stream(endpoint) { + // no-op + } + } catch { + didThrow = true + } + #expect(didThrow) + } + + // nc-factory-base-url + @Test func factory_uses_default_host_in_base_url() async throws { + let mock = MockNetworkingService() + mock.performRequestImpl = { _ in EmptyResponse() } + + let client = NetworkClientImpl.makeHumeClient( + auth: .accessToken("tok"), networkingService: mock) + let endpoint = Endpoint(path: "/factory-test", method: .get) + + _ = try await client.send(endpoint) + + let request = try #require(mock.lastRequest) + let url = try #require(request.url) + let comps = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(comps.scheme == "https") + #expect(comps.host == SDKConfiguration.default.host) + #expect(comps.path == "/factory-test") + } + + // nc-send-retryable-error-once + @Test func send_retries_once_on_retryable_error_and_then_succeeds() async throws { + let mock = MockNetworkingService() + var calls = 0 + mock.performRequestImpl = { _ in + calls += 1 + if calls == 1 { throw URLError(.timedOut) } + return EmptyResponse() + } + + let client = NetworkClientImpl.makeForTests(service: mock) + let endpoint = Endpoint(path: "/retry", method: .get, maxRetries: 1) + + let result = try await client.send(endpoint) + #expect(result == EmptyResponse()) + #expect(calls == 2) + } +} diff --git a/Tests/HumeTests/Services/Networking/NetworkErrorTest.swift b/Tests/HumeTests/Services/Networking/NetworkErrorTest.swift new file mode 100644 index 0000000..6baa801 --- /dev/null +++ b/Tests/HumeTests/Services/Networking/NetworkErrorTest.swift @@ -0,0 +1,36 @@ +// +// NetworkErrorTest.swift +// Hume +// +// Created by AI on 9/9/25. +// + +import Foundation +import Testing + +@testable import Hume + +struct NetworkErrorTest { + + @Test func error_descriptions_are_human_readable() async throws { + // Map each case to an expected description substring + let samples: [(NetworkError, String)] = [ + (.authenticationError, "authentication credentials were invalid"), + (.unauthorized, "unauthorized"), + (.forbidden, "forbidden"), + (.invalidRequest, "could not be created"), + (.invalidResponse, "invalid response"), + (.errorResponse(code: 418, message: "teapot"), "Error 418: teapot"), + (.noData, "No data"), + (.requestDecodingFailed, "decode the request"), + (.responseDecodingFailed, "decode the response"), + (.unknownMessageType, "unknown message type"), + (.unknown, "unknown error"), + ] + + for (err, expectedSubstring) in samples { + let desc = try #require(err.errorDescription) + #expect(desc.localizedCaseInsensitiveContains(expectedSubstring)) + } + } +} diff --git a/Tests/HumeTests/Services/Networking/NetworkServiceTest.swift b/Tests/HumeTests/Services/Networking/NetworkServiceTest.swift new file mode 100644 index 0000000..84e09d5 --- /dev/null +++ b/Tests/HumeTests/Services/Networking/NetworkServiceTest.swift @@ -0,0 +1,230 @@ +import Foundation +import Testing + +// +// Untitled.swift +// Hume +// +// Created by Chris on 9/9/25. +// +// +@testable import Hume + +// MARK: - Mocks & Helpers + +private final class MockNetworkingServiceSession: NetworkingServiceSession { + var session: URLSession = .shared + + var nextData: Data = Data() + var nextResponse: URLResponse = HTTPURLResponse( + url: URL(string: "https://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + var nextError: Error? + + func data( + for request: URLRequest, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> (Data, URLResponse) { + if let error = nextError { throw error } + return (nextData, nextResponse) + } + + func bytes( + for request: URLRequest, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> (URLSession.AsyncBytes, URLResponse) { + fatalError("Not implemented for tests") + } +} + +private func makeRequest(urlString: String = "https://example.com/test") -> URLRequest { + var request = URLRequest(url: URL(string: urlString)!) + request.httpMethod = "GET" + return request +} + +private func makeHTTPURLResponse(status: Int, url: String = "https://example.com/test") + -> HTTPURLResponse +{ + return HTTPURLResponse( + url: URL(string: url)!, statusCode: status, httpVersion: nil, headerFields: nil)! +} + +private func makeService(using session: MockNetworkingServiceSession) -> NetworkingServiceImpl { + return NetworkingServiceImpl(session: session, decoder: Defaults.decoder) +} + +private struct TestModel: Codable, Equatable { + let message: String +} + +// MARK: - Tests + +struct NetworkServiceTest { + + @Test func performRequest_success_decodesModel() async throws { + let session = MockNetworkingServiceSession() + let expected = TestModel(message: "ok") + session.nextData = try JSONEncoder().encode(expected) + session.nextResponse = makeHTTPURLResponse(status: 200) + + let service = makeService(using: session) + let result: TestModel = try await service.performRequest(makeRequest()) + + #expect(result == expected) + } + + @Test func performRequest_returnsRawData_whenResponseIsData() async throws { + let session = MockNetworkingServiceSession() + let payload = Data([0x00, 0x01, 0x02]) + session.nextData = payload + session.nextResponse = makeHTTPURLResponse(status: 200) + + let service = makeService(using: session) + let result: Data = try await service.performRequest(makeRequest()) + + #expect(result == payload) + } + + @Test func performRequest_returnsEmptyResponse_whenDataEmpty() async throws { + let session = MockNetworkingServiceSession() + session.nextData = Data() + session.nextResponse = makeHTTPURLResponse(status: 200) + + let service = makeService(using: session) + let result: EmptyResponse = try await service.performRequest(makeRequest()) + + #expect(type(of: result) == EmptyResponse.self) + } + + @Test func performRequest_sessionThrows_mapsToInvalidResponse() async { + enum Dummy: Error { case boom } + let session = MockNetworkingServiceSession() + session.nextError = Dummy.boom + + let service = makeService(using: session) + var received: NetworkError? + do { + let _: TestModel = try await service.performRequest(makeRequest()) + } catch let error as NetworkError { + received = error + } catch {} + + #expect(received == .invalidResponse) + } + + @Test func performRequest_nonHTTPResponse_mapsToInvalidResponse() async { + let session = MockNetworkingServiceSession() + session.nextData = Data() + session.nextResponse = URLResponse( + url: URL(string: "https://example.com/test")!, + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil + ) + + let service = makeService(using: session) + var isInvalidResponse = false + do { + let _: TestModel = try await service.performRequest(makeRequest()) + } catch NetworkError.invalidResponse { + isInvalidResponse = true + } catch {} + + #expect(isInvalidResponse == true) + } + + @Test func performRequest_errorJSONWithMessage_throwsErrorResponse() async { + let session = MockNetworkingServiceSession() + let json = ["message": "Uh oh"] + session.nextData = try! JSONSerialization.data(withJSONObject: json) + session.nextResponse = makeHTTPURLResponse(status: 500) + + let service = makeService(using: session) + var matched = false + do { + let _: TestModel = try await service.performRequest(makeRequest()) + } catch let error as NetworkError { + if case .errorResponse(let code, let message) = error { + matched = (code == 500 && message == "Uh oh") + } + } catch {} + + #expect(matched == true) + } + + @Test func performRequest_400_mapsToInvalidRequest() async { + let session = MockNetworkingServiceSession() + session.nextData = Data("{}".utf8) + session.nextResponse = makeHTTPURLResponse(status: 400) + + let service = makeService(using: session) + var received: NetworkError? + do { let _: TestModel = try await service.performRequest(makeRequest()) } catch let error + as NetworkError + { received = error } catch {} + + #expect(received == .invalidRequest) + } + + @Test func performRequest_401_mapsToUnauthorized() async { + let session = MockNetworkingServiceSession() + session.nextData = Data("{}".utf8) + session.nextResponse = makeHTTPURLResponse(status: 401) + + let service = makeService(using: session) + var received: NetworkError? + do { let _: TestModel = try await service.performRequest(makeRequest()) } catch let error + as NetworkError + { received = error } catch {} + + #expect(received == .unauthorized) + } + + @Test func performRequest_403_mapsToForbidden() async { + let session = MockNetworkingServiceSession() + session.nextData = Data("{}".utf8) + session.nextResponse = makeHTTPURLResponse(status: 403) + + let service = makeService(using: session) + var received: NetworkError? + do { let _: TestModel = try await service.performRequest(makeRequest()) } catch let error + as NetworkError + { received = error } catch {} + + #expect(received == .forbidden) + } + + @Test func performRequest_otherStatus_mapsToInvalidResponse() async { + let session = MockNetworkingServiceSession() + session.nextData = Data("{}".utf8) + session.nextResponse = makeHTTPURLResponse(status: 500) + + let service = makeService(using: session) + var received: NetworkError? + do { let _: TestModel = try await service.performRequest(makeRequest()) } catch let error + as NetworkError + { received = error } catch {} + + #expect(received == .invalidResponse) + } + + @Test func performRequest_decodeFailure_mapsToResponseDecodingFailed() async { + struct Mismatch: Decodable { let other: String } + + let session = MockNetworkingServiceSession() + session.nextData = Data("{\"message\":\"ok\"}".utf8) + session.nextResponse = makeHTTPURLResponse(status: 200) + + let service = makeService(using: session) + var received: NetworkError? + do { let _: Mismatch = try await service.performRequest(makeRequest()) } catch let error + as NetworkError + { received = error } catch {} + + #expect(received == .responseDecodingFailed) + } +} diff --git a/Tests/HumeTests/Services/Networking/RequestBuilderTest.swift b/Tests/HumeTests/Services/Networking/RequestBuilderTest.swift new file mode 100644 index 0000000..c8d6d75 --- /dev/null +++ b/Tests/HumeTests/Services/Networking/RequestBuilderTest.swift @@ -0,0 +1,85 @@ +// +// RequestBuilderTest.swift +// Hume +// +// Created by AI on 9/9/25. +// + +import Foundation +import Testing + +@testable import Hume + +private struct Body: Codable, Equatable { let url: String } + +struct RequestBuilderTest { + + private func makeBuilder() -> RequestBuilder { + RequestBuilder(baseURL: URL(string: "https://api.example.com")!) + } + + @Test func build_constructs_url_with_path_and_query() async throws { + let builder = makeBuilder() + .setPath("/v1/items") + .setQueryParams(["q": "swift", "page": "2"]) + + let request = try builder.build() + let url = try #require(request.url) + let comps = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + #expect(comps.path == "/v1/items") + let items = comps.queryItems ?? [] + #expect(items.contains(where: { $0.name == "q" && $0.value == "swift" })) + #expect(items.contains(where: { $0.name == "page" && $0.value == "2" })) + } + + @Test func sets_method_and_headers() async throws { + let builder = makeBuilder() + .setPath("/v1/h") + .setMethod(.post) + .setHeaders(["X-Base": "1"]) // baseline + .addHeader(key: "X-Base", value: "2") // override + .addHeader(key: "X-Extra", value: "y") + + let request = try builder.build() + #expect(request.httpMethod == HTTPMethod.post.rawValue) + let headers = request.allHTTPHeaderFields ?? [:] + #expect(headers["X-Base"] == "2") + #expect(headers["X-Extra"] == "y") + } + + @Test func encodes_body_and_keeps_slashes_unescaped() async throws { + let payload = Body(url: "https://example.com/a/b") + let builder = makeBuilder() + .setPath("/v1/body") + .setBody(payload) + + let request = try builder.build() + let body = try #require(request.httpBody) + let json = try #require(String(data: body, encoding: .utf8)) + // Expect no \/ escaping + #expect(json.contains("https://example.com/a/b")) + let decoded = try JSONDecoder().decode(Body.self, from: body) + #expect(decoded == payload) + } + + @Test func body_nil_leaves_httpBody_nil() async throws { + let builder = makeBuilder() + .setPath("/v1/nobody") + .setBody(nil) + + let request = try builder.build() + #expect(request.httpBody == nil) + } + + @Test func sets_cache_policy_and_timeout() async throws { + let builder = makeBuilder() + .setPath("/v1/conf") + .setCachePolicy(.reloadIgnoringLocalCacheData) + .setTimeout(5) + + let request = try builder.build() + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + #expect(request.timeoutInterval == 5) + } +} diff --git a/Tests/HumeTests/Services/Networking/TokenProvidableTest.swift b/Tests/HumeTests/Services/Networking/TokenProvidableTest.swift new file mode 100644 index 0000000..7b4a595 --- /dev/null +++ b/Tests/HumeTests/Services/Networking/TokenProvidableTest.swift @@ -0,0 +1,27 @@ +// +// TokenProvidableTest.swift +// Hume +// +// Created by AI on 9/9/25. +// + +import Foundation +import Testing + +@testable import Hume + +struct TokenProvidableTest { + + @Test func bearer_updates_request_with_authorization_header() async throws { + let token = AuthTokenType.bearer("tok_123") + let base = URL(string: "https://api.example.com")! + var builder = RequestBuilder(baseURL: base) + .setPath("/v1/foo") + .setHeaders([:]) + + builder = try await token.updateRequest(builder) + let request = try builder.build() + let headers = request.allHTTPHeaderFields ?? [:] + #expect(headers["Authorization"] == "Bearer tok_123") + } +}