diff --git a/Package.swift b/Package.swift index b7fea4f..7e529d2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.8 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,7 +7,7 @@ let package = Package( name: "GraphQLAPIKit", platforms: [ .iOS(.v16), - .macOS(.v10_14) + .macOS(.v13) ], products: [ .library( @@ -29,6 +29,12 @@ let package = Package( dependencies: [ .product(name: "Apollo", package: "apollo-ios"), ] + ), + .testTarget( + name: "GraphQLAPIKitTests", + dependencies: [ + "GraphQLAPIKit" + ] ) ] ) diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index 5d064d4..0b372c7 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -18,7 +18,7 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject { queue: DispatchQueue, resultHandler: @escaping (Result) -> Void ) -> Cancellable - + /// Performs a mutation by sending it to the server. /// /// - Parameters: @@ -38,14 +38,42 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject { public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { private let apollo: ApolloClientProtocol + public init( + url: URL, + urlSessionConfiguration: URLSessionConfiguration = .default, + defaultHeaders: [String: String] = [:], + networkObservers: repeat each Observer + ) { + var observers: [any GraphQLNetworkObserver] = [] + repeat observers.append(each networkObservers) + + let provider = NetworkInterceptorProvider( + client: URLSessionClient(sessionConfiguration: urlSessionConfiguration), + defaultHeaders: defaultHeaders, + networkObservers: observers + ) + + let networkTransport = RequestChainNetworkTransport( + interceptorProvider: provider, + endpointURL: url + ) + + self.apollo = ApolloClient( + networkTransport: networkTransport, + store: ApolloStore() + ) + } + public init( url: URL, urlSessionConfiguration: URLSessionConfiguration = .default, - defaultHeaders: [String: String] = [:] + defaultHeaders: [String: String] = [:], + networkObservers: [any GraphQLNetworkObserver] ) { let provider = NetworkInterceptorProvider( client: URLSessionClient(sessionConfiguration: urlSessionConfiguration), - defaultHeaders: defaultHeaders + defaultHeaders: defaultHeaders, + networkObservers: networkObservers ) let networkTransport = RequestChainNetworkTransport( @@ -64,7 +92,7 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { context: RequestHeaders?, queue: DispatchQueue, resultHandler: @escaping (Result) -> Void - ) -> Cancellable where Query : GraphQLQuery { + ) -> Cancellable where Query: GraphQLQuery { apollo.fetch( query: query, cachePolicy: .fetchIgnoringCacheCompletely, @@ -92,7 +120,7 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { context: RequestHeaders?, queue: DispatchQueue, resultHandler: @escaping (Result) -> Void - ) -> Cancellable where Mutation : GraphQLMutation { + ) -> Cancellable where Mutation: GraphQLMutation { apollo.perform( mutation: mutation, publishResultToStore: false, @@ -114,50 +142,3 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { } } } - -private struct NetworkInterceptorProvider: InterceptorProvider { - private let client: URLSessionClient - private let defaultHeaders: [String: String] - - init(client: URLSessionClient, defaultHeaders: [String: String]) { - self.client = client - self.defaultHeaders = defaultHeaders - } - - func interceptors(for operation: Operation) -> [ApolloInterceptor] { - [ - RequestHeaderInterceptor(defaultHeaders: defaultHeaders), - MaxRetryInterceptor(), - NetworkFetchInterceptor(client: self.client), - ResponseCodeInterceptor(), - MultipartResponseParsingInterceptor(), - JSONResponseParsingInterceptor() - ] - } -} - -private struct RequestHeaderInterceptor: ApolloInterceptor { - var id: String = UUID().uuidString - - private let defaultHeaders: [String: String] - - init(defaultHeaders: [String: String]) { - self.defaultHeaders = defaultHeaders - } - - func interceptAsync( - chain: RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void - ) { - defaultHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) } - if let additionalHeaders = request.context as? RequestHeaders { - additionalHeaders.additionalHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) } - } - - chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion) - } -} - - diff --git a/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift b/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift new file mode 100644 index 0000000..4b55acf --- /dev/null +++ b/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift @@ -0,0 +1,45 @@ +import Apollo +import ApolloAPI +import Foundation + +struct NetworkInterceptorProvider: InterceptorProvider { + private let client: URLSessionClient + private let defaultHeaders: [String: String] + private let pairOfObserverInterceptors: [(before: ApolloInterceptor, after: ApolloInterceptor)] + + init( + client: URLSessionClient, + defaultHeaders: [String: String], + networkObservers: [any GraphQLNetworkObserver] + ) { + self.client = client + self.defaultHeaders = defaultHeaders + // Create interceptor pairs with shared context stores + self.pairOfObserverInterceptors = networkObservers.map { Self.makePair(of: $0) } + } + + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + // Headers first, then before-observers, then network fetch, then after-observers + [ + RequestHeaderInterceptor(defaultHeaders: defaultHeaders), + ] + + pairOfObserverInterceptors.map(\.before) // Before network - captures timing + + [ + MaxRetryInterceptor(), + NetworkFetchInterceptor(client: client) + ] + + pairOfObserverInterceptors.map(\.after) // After network - captures response + + [ + ResponseCodeInterceptor(), + MultipartResponseParsingInterceptor(), + JSONResponseParsingInterceptor() + ] + } + + static private func makePair(of observer: T) -> (before: ApolloInterceptor, after: ApolloInterceptor) { + let contextStore = ObserverContextStore() + let beforeInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) + let afterInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) + return (before: beforeInterceptor, after: afterInterceptor) + } +} diff --git a/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift b/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift new file mode 100644 index 0000000..81da4c3 --- /dev/null +++ b/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift @@ -0,0 +1,61 @@ +import Apollo +import ApolloAPI +import Foundation + +/// Interceptor that observes network requests. Place TWO instances in chain: +/// - One BEFORE NetworkFetchInterceptor (captures request timing) +/// - One AFTER NetworkFetchInterceptor (captures response) +/// Both instances share state via the contextStore actor. +struct ObserverInterceptor: ApolloInterceptor { + let id = UUID().uuidString + + private let observer: Observer + private let contextStore: ObserverContextStore + + init(observer: Observer, contextStore: ObserverContextStore) { + self.observer = observer + self.contextStore = contextStore + } + + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void + ) { + guard let urlRequest = try? request.toURLRequest() else { + chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion) + return + } + + let requestId = ObjectIdentifier(request).debugDescription + + if response == nil { + // BEFORE network fetch - call willSendRequest and store context synchronously + let context = observer.willSendRequest(urlRequest) + contextStore.store(context, for: requestId) + } else { + // AFTER network fetch - retrieve context and call didReceiveResponse + if let context = contextStore.retrieve(for: requestId) { + observer.didReceiveResponse( + for: urlRequest, + response: response?.httpResponse, + data: response?.rawData, + context: context + ) + } + } + + // Wrap completion to handle errors + let wrappedCompletion: (Result, Error>) -> Void = { result in + if case .failure(let error) = result { + if let context = contextStore.retrieve(for: requestId) { + observer.didFail(request: urlRequest, error: error, context: context) + } + } + completion(result) + } + + chain.proceedAsync(request: request, response: response, interceptor: self, completion: wrappedCompletion) + } +} diff --git a/Sources/GraphQLAPIKit/Interceptors/RequestHeaderInterceptor.swift b/Sources/GraphQLAPIKit/Interceptors/RequestHeaderInterceptor.swift new file mode 100644 index 0000000..39d8134 --- /dev/null +++ b/Sources/GraphQLAPIKit/Interceptors/RequestHeaderInterceptor.swift @@ -0,0 +1,27 @@ +import Apollo +import ApolloAPI +import Foundation + +struct RequestHeaderInterceptor: ApolloInterceptor { + let id: String = UUID().uuidString + + private let defaultHeaders: [String: String] + + init(defaultHeaders: [String: String]) { + self.defaultHeaders = defaultHeaders + } + + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void + ) { + defaultHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) } + if let additionalHeaders = request.context as? RequestHeaders { + additionalHeaders.additionalHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) } + } + + chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion) + } +} diff --git a/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift new file mode 100644 index 0000000..a56d5ef --- /dev/null +++ b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Protocol for observing GraphQL network request lifecycle events. +/// +/// Implement this protocol to add logging, analytics, or request tracking to GraphQL operations. +/// Observers are passive - they cannot modify requests or responses, only observe them. +/// +/// ## Context Lifecycle +/// The `Context` associated type allows passing correlation data (request ID, start time, etc.) +/// through the request lifecycle: +/// 1. `willSendRequest` is called before the request starts and returns a `Context` value +/// 2. `didReceiveResponse` is always called with the raw HTTP response data (useful for debugging) +/// 3. `didFail` is called additionally if the request fails +/// 4. If the observer is deallocated before the request completes, the context is discarded +/// and no completion callback is invoked +/// +public protocol GraphQLNetworkObserver: AnyObject, Sendable { + associatedtype Context: Sendable + + /// Called immediately before a request is sent. + /// - Parameter request: The URLRequest about to be sent + /// - Returns: Context to be passed to `didReceiveResponse` and optionally `didFail` + func willSendRequest(_ request: URLRequest) -> Context + + /// Called when a response is received from the server. + /// + /// This is always called with the raw response data, even if processing subsequently fails. + /// This allows observers to inspect the actual response for debugging purposes. + /// - Parameters: + /// - request: The original request + /// - response: The URL response (may be nil if network error occurred before response) + /// - data: Response body data, if any + /// - context: Value returned from `willSendRequest` + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: Context) + + /// Called when a request fails with an error. + /// + /// Called after `didReceiveResponse` if processing determines the request failed. + /// - Parameters: + /// - request: The original request + /// - error: The error that occurred + /// - context: Value returned from `willSendRequest` + func didFail(request: URLRequest, error: Error, context: Context) +} diff --git a/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift b/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift new file mode 100644 index 0000000..072f305 --- /dev/null +++ b/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift @@ -0,0 +1,16 @@ +import Foundation +import os + +/// Thread-safe store for observer contexts keyed by request identifier. +/// Enables two interceptor instances to share state across the interceptor chain. +final class ObserverContextStore: Sendable { + private let state = OSAllocatedUnfairLock(initialState: [String: Context]()) + + func store(_ context: Context, for requestId: String) { + state.withLock { $0[requestId] = context } + } + + func retrieve(for requestId: String) -> Context? { + state.withLock { $0.removeValue(forKey: requestId) } + } +} diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift new file mode 100644 index 0000000..5635d4a --- /dev/null +++ b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift @@ -0,0 +1,344 @@ +import Apollo +import ApolloAPI +import XCTest +@testable import GraphQLAPIKit + +// MARK: - MockURLProtocol + +final class MockURLProtocol: URLProtocol { + /// Captured requests for verification + static var capturedRequests: [URLRequest] = [] + + /// Response to return + static var mockResponse: (data: Data, statusCode: Int)? + + /// Error to return + static var mockError: Error? + + /// Reset state between tests + static func reset() { + capturedRequests = [] + mockResponse = nil + mockError = nil + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + // Capture the request + MockURLProtocol.capturedRequests.append(request) + + if let error = MockURLProtocol.mockError { + client?.urlProtocol(self, didFailWithError: error) + return + } + + let response = MockURLProtocol.mockResponse ?? ( + data: validGraphQLResponse, + statusCode: 200 + ) + + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: response.statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + + client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + /// A valid GraphQL response with minimal data + private var validGraphQLResponse: Data { + """ + {"data": {"__typename": "Query"}} + """.data(using: .utf8)! + } +} + +// MARK: - Mock GraphQL Schema and Query + +enum MockSchema: SchemaMetadata { + static let configuration: any SchemaConfiguration.Type = MockSchemaConfiguration.self + + static func objectType(forTypename typename: String) -> Object? { + if typename == "Query" { return MockQuery.Data.self.__parentType as? Object } + return nil + } +} + +enum MockSchemaConfiguration: SchemaConfiguration { + static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? { + nil + } +} + +/// Minimal mock query for testing +final class MockQuery: GraphQLQuery { + typealias Data = MockQueryData + + static let operationName: String = "MockQuery" + static let operationDocument: OperationDocument = OperationDocument( + definition: .init("query MockQuery { __typename }") + ) + + init() {} + + struct MockQueryData: RootSelectionSet { + typealias Schema = MockSchema + + static var __parentType: any ParentType { Object(typename: "Query", implementedInterfaces: []) } + static var __selections: [Selection] { [] } + + var __data: DataDict + + init(_dataDict: DataDict) { + self.__data = _dataDict + } + } +} + +// MARK: - Mock Request Headers + +struct MockRequestHeaders: RequestHeaders { + let additionalHeaders: [String: String] +} + +// MARK: - Mock Observer for Integration Tests + +final class IntegrationMockObserver: GraphQLNetworkObserver { + struct Context: Sendable { + let timestamp: Date + } + + var capturedRequests: [URLRequest] = [] + var capturedResponses: [(response: URLResponse?, data: Data?)] = [] + var capturedErrors: [Error] = [] + + func willSendRequest(_ request: URLRequest) -> Context { + capturedRequests.append(request) + return Context(timestamp: Date()) + } + + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: Context) { + capturedResponses.append((response, data)) + } + + func didFail(request: URLRequest, error: Error, context: Context) { + capturedErrors.append(error) + } +} + +// MARK: - Integration Tests + +final class GraphQLAPIAdapterIntegrationTests: XCTestCase { + + override func setUp() { + super.setUp() + MockURLProtocol.reset() + } + + override func tearDown() { + MockURLProtocol.reset() + super.tearDown() + } + + /// Creates a URLSessionConfiguration that uses MockURLProtocol + private func mockSessionConfiguration() -> URLSessionConfiguration { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return config + } + + // MARK: - Default Headers Tests + + func testObserverReceivesDefaultHeaders() { + let expectation = expectation(description: "Request completed") + + let observer = IntegrationMockObserver() + let defaultHeaders = [ + "X-API-Key": "test-api-key", + "X-Client-Version": "1.0.0" + ] + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: defaultHeaders, + networkObservers: observer + ) + + _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + // Verify observer captured the request + XCTAssertEqual(observer.capturedRequests.count, 1) + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("No request captured") + return + } + + // Verify default headers are present + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-API-Key"), "test-api-key") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-Client-Version"), "1.0.0") + } + + func testObserverReceivesContextHeaders() { + let expectation = expectation(description: "Request completed") + + let observer = IntegrationMockObserver() + let contextHeaders = MockRequestHeaders(additionalHeaders: [ + "Authorization": "Bearer test-token", + "X-Request-ID": "request-123" + ]) + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: [:], + networkObservers: observer + ) + + _ = adapter.fetch(query: MockQuery(), context: contextHeaders, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + // Verify observer captured the request + XCTAssertEqual(observer.capturedRequests.count, 1) + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("No request captured") + return + } + + // Verify context headers are present + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-Request-ID"), "request-123") + } + + func testObserverReceivesBothDefaultAndContextHeaders() { + let expectation = expectation(description: "Request completed") + + let observer = IntegrationMockObserver() + let defaultHeaders = [ + "X-API-Key": "api-key-456", + "Accept-Language": "en-US" + ] + let contextHeaders = MockRequestHeaders(additionalHeaders: [ + "Authorization": "Bearer context-token", + "X-Trace-ID": "trace-789" + ]) + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: defaultHeaders, + networkObservers: observer + ) + + _ = adapter.fetch(query: MockQuery(), context: contextHeaders, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("No request captured") + return + } + + // Verify both default and context headers are present + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-API-Key"), "api-key-456") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "Accept-Language"), "en-US") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "Authorization"), "Bearer context-token") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-Trace-ID"), "trace-789") + } + + // MARK: - Multiple Observers Tests + + func testMultipleObserversAllReceiveHeaders() { + let expectation = expectation(description: "Request completed") + + let observer1 = IntegrationMockObserver() + let observer2 = IntegrationMockObserver() + let observer3 = IntegrationMockObserver() + + let defaultHeaders = ["X-Shared-Header": "shared-value"] + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: defaultHeaders, + networkObservers: observer1, observer2, observer3 + ) + + _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + // Verify all observers captured the request with headers + for (index, observer) in [observer1, observer2, observer3].enumerated() { + XCTAssertEqual(observer.capturedRequests.count, 1, "Observer \(index + 1) should have captured 1 request") + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("Observer \(index + 1) did not capture request") + continue + } + + XCTAssertEqual( + capturedRequest.value(forHTTPHeaderField: "X-Shared-Header"), + "shared-value", + "Observer \(index + 1) should see the shared header" + ) + } + } + + // MARK: - Apollo Headers Tests + + func testObserverReceivesApolloHeaders() { + let expectation = expectation(description: "Request completed") + + let observer = IntegrationMockObserver() + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: [:], + networkObservers: observer + ) + + _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("No request captured") + return + } + + // Verify Apollo automatically adds these headers + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-APOLLO-OPERATION-NAME"), "MockQuery") + XCTAssertNotNil(capturedRequest.value(forHTTPHeaderField: "Content-Type")) + } + +} diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift new file mode 100644 index 0000000..70fbba6 --- /dev/null +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -0,0 +1,111 @@ +import XCTest +@testable import GraphQLAPIKit + +final class GraphQLNetworkObserverTests: XCTestCase { + + // MARK: - MockObserver + + final class MockObserver: GraphQLNetworkObserver { + struct Context: Sendable { + let requestId: String + let startTime: Date + } + + var willSendRequestCalled = false + var didReceiveResponseCalled = false + var didFailCalled = false + + var lastRequest: URLRequest? + var lastResponse: URLResponse? + var lastData: Data? + var lastError: Error? + + func willSendRequest(_ request: URLRequest) -> Context { + willSendRequestCalled = true + lastRequest = request + return Context(requestId: UUID().uuidString, startTime: Date()) + } + + func didReceiveResponse( + for request: URLRequest, + response: URLResponse?, + data: Data?, + context: Context + ) { + didReceiveResponseCalled = true + lastResponse = response + lastData = data + } + + func didFail(request: URLRequest, error: Error, context: Context) { + didFailCalled = true + lastError = error + } + } + + // MARK: - ObserverInterceptor Tests + + func testObserverInterceptorCreation() { + let observer = MockObserver() + let contextStore = ObserverContextStore() + + let interceptor1 = ObserverInterceptor(observer: observer, contextStore: contextStore) + let interceptor2 = ObserverInterceptor(observer: observer, contextStore: contextStore) + + XCTAssertNotNil(interceptor1.id) + XCTAssertNotNil(interceptor2.id) + XCTAssertNotEqual(interceptor1.id, interceptor2.id) + XCTAssertFalse(observer.willSendRequestCalled) + } + + func testProtocolMethodSignatures() { + let observer = MockObserver() + let url = URL(string: "https://api.example.com/graphql")! + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") + + // Test willSendRequest returns Context + let context = observer.willSendRequest(request) + XCTAssertTrue(observer.willSendRequestCalled) + XCTAssertNotNil(context.requestId) + XCTAssertEqual(observer.lastRequest?.url, url) + XCTAssertEqual(observer.lastRequest?.httpMethod, "POST") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") + + // Test didReceiveResponse + observer.didReceiveResponse(for: request, response: nil, data: nil, context: context) + XCTAssertTrue(observer.didReceiveResponseCalled) + + // Test didFail + observer.didFail(request: request, error: NSError(domain: "Test", code: 1), context: context) + XCTAssertTrue(observer.didFailCalled) + } + + // MARK: - Context Store Tests + + func testContextStoreOperations() { + let store = ObserverContextStore() + + // Test store and retrieve + store.store("context-1", for: "request-1") + store.store("context-2", for: "request-2") + store.store("context-3", for: "request-3") + + // Retrieve in different order + let context2 = store.retrieve(for: "request-2") + let context1 = store.retrieve(for: "request-1") + let context3 = store.retrieve(for: "request-3") + + XCTAssertEqual(context1, "context-1") + XCTAssertEqual(context2, "context-2") + XCTAssertEqual(context3, "context-3") + + // Verify retrieve removes context + let secondRetrieve = store.retrieve(for: "request-1") + XCTAssertNil(secondRetrieve) + } +}