diff --git a/README.md b/README.md index fd2fe07..1c740c8 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,19 @@ MockResponse.plaintext("served three times", lifetime: .multiple(3)) MockResponse.plaintext("served forever", lifetime: .eternal) ``` +## Response delivery +Each response can optionally be given a `delivery` parameter that controls when the response is delivered to the client. The default value of the parameter is `.instant`. + +- `.instant`: The response is delivered immediately (default behavior). +- `.delayed(TimeInterval)`: The response is delayed and delivered after the specified number of seconds. + +Example: + +```swift +MockResponse.plaintext("immediate response", delivery: .instant) +MockResponse.plaintext("delayed response", delivery: .delayed(2.0)) // delivered after 2 seconds +``` + ## Handling unmocked requests By default, unmocked requests return a hardcoded 404 response with a small body. You can configure `HTTPMock.unmockedPolicy` to control this behavior, choosing between returning a 404 or allowing the request to pass through to the real network. The default is `notFound`, aka. the hardoced 404 response. @@ -223,7 +236,7 @@ Path("/user") { ## Goals - [X] Allow for passthrough networking when mock hasn't been registered for the incoming URL. - [X] Let user point to a file that should be served. -- [ ] Set delay on requests. +- [X] Set delay on requests. - [ ] Let user configure a default "not found" response. Will be used either when no matching mocks are found or if queue is empty. - [ ] Create separate instances of `HTTPMock`. The current single instance requires tests to be run in sequence, instead of parallel. - [ ] Does arrays in query parameters work? I think they're being overwritten with the current setup. diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index 9123086..4d3286e 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -99,23 +99,34 @@ final class HTTPMockURLProtocol: URLProtocol { // Look for, and pop, the next queued response mathing host, path and query params. if let mock = Self.pop(host: host, path: path, query: queryDict) { - do { - HTTPMockLog.info("Serving mock for \(host)\(path) (\(statusCode(of: mock)))") - HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(host: host, path: path, query: queryDict))") - - let response = HTTPURLResponse( - url: url, - statusCode: mock.status.code, - httpVersion: "HTTP/1.1", - headerFields: mock.headers - )! + let sendResponse = { [weak self] in + guard let self else { return } + do { + HTTPMockLog.info("Serving mock for \(host)\(path) (\(self.statusCode(of: mock)))") + HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(host: host, path: path, query: queryDict))") + + let response = HTTPURLResponse( + url: url, + statusCode: mock.status.code, + httpVersion: "HTTP/1.1", + headerFields: mock.headers + )! + + let payload = try mock.payloadData() + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: payload) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } - let payload = try mock.payloadData() - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: payload) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) + switch mock.delivery { + case .instant: + sendResponse() + case .delayed(let delay): + HTTPMockLog.info("Delaying response for \(requestDescription) for \(delay) seconds") + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay, execute: sendResponse) } } else { switch Self.unmockedPolicy { diff --git a/Sources/HTTPMock/Models/MockResponse+Delivery.swift b/Sources/HTTPMock/Models/MockResponse+Delivery.swift new file mode 100644 index 0000000..93e2fcc --- /dev/null +++ b/Sources/HTTPMock/Models/MockResponse+Delivery.swift @@ -0,0 +1,8 @@ +import Foundation + +extension MockResponse { + public enum Delivery: Hashable { + case instant + case delayed(TimeInterval) + } +} diff --git a/Sources/HTTPMock/Models/MockResponse.swift b/Sources/HTTPMock/Models/MockResponse.swift index 3c21d44..383b870 100644 --- a/Sources/HTTPMock/Models/MockResponse.swift +++ b/Sources/HTTPMock/Models/MockResponse.swift @@ -7,8 +7,9 @@ public struct MockResponse: Hashable { public let payload: Payload public let status: Status - public let headers: [String: String] public let lifetime: Lifetime + public let delivery: Delivery + public let headers: [String: String] // MARK: - Init @@ -16,11 +17,13 @@ public struct MockResponse: Hashable { payload: Payload, status: Status = .ok, lifetime: Lifetime = .single, + delivery: Delivery = .instant, headers: [String: String] = [:] ) { self.payload = payload self.status = status self.lifetime = lifetime + self.delivery = delivery if let contentType = payload.contentType { // Merge the headers, allowing the user to overwrite any we set. @@ -40,6 +43,7 @@ extension MockResponse { _ payload: T, status: Status = .ok, lifetime: Lifetime = .single, + delivery: Delivery = .instant, headers: [String: String] = [:], jsonEncoder: JSONEncoder = .mockDefault ) throws -> MockResponse { @@ -48,6 +52,7 @@ extension MockResponse { payload: .data(data, contentType: "application/json"), status: status, lifetime: lifetime, + delivery: delivery, headers: headers ) } @@ -56,6 +61,7 @@ extension MockResponse { _ payload: [String: Any], status: Status = .ok, lifetime: Lifetime = .single, + delivery: Delivery = .instant, headers: [String: String] = [:] ) throws -> MockResponse { let data = try JSONSerialization.data(withJSONObject: payload) @@ -63,6 +69,7 @@ extension MockResponse { payload: .data(data, contentType: "application/json"), status: status, lifetime: lifetime, + delivery: delivery, headers: headers ) } @@ -71,6 +78,7 @@ extension MockResponse { _ payload: String, status: Status = .ok, lifetime: Lifetime = .single, + delivery: Delivery = .instant, headers: [String: String] = [:] ) -> MockResponse { let data = Data(payload.utf8) @@ -78,6 +86,7 @@ extension MockResponse { payload: .data(data, contentType: "text/plain"), status: status, lifetime: lifetime, + delivery: delivery, headers: headers ) } @@ -85,12 +94,14 @@ extension MockResponse { public static func empty( status: Status = .ok, lifetime: Lifetime = .single, + delivery: Delivery = .instant, headers: [String: String] = [:] ) -> MockResponse { Self.init( payload: .empty, status: status, lifetime: lifetime, + delivery: delivery, headers: headers ) } @@ -111,6 +122,7 @@ extension MockResponse { in bundle: Bundle = .main, status: Status = .ok, lifetime: Lifetime = .single, + delivery: Delivery = .instant, headers: [String: String] = [:], contentType: String? = nil ) -> MockResponse { @@ -121,6 +133,7 @@ extension MockResponse { url: url, status: status, lifetime: lifetime, + delivery: delivery, headers: headers, contentType: contentType ) @@ -138,6 +151,7 @@ extension MockResponse { url: URL, status: Status = .ok, lifetime: Lifetime = .single, + delivery: Delivery = .instant, headers: [String: String] = [:], contentType: String? = nil ) -> MockResponse { @@ -154,6 +168,7 @@ extension MockResponse { payload: .data(data, contentType: contentType), status: status, lifetime: lifetime, + delivery: delivery, headers: headers ) } @@ -195,6 +210,7 @@ extension MockResponse { payload: self.payload, status: self.status, lifetime: lifetime, + delivery: delivery, headers: extra.mergedInOther(self.headers) ) } @@ -204,6 +220,7 @@ extension MockResponse { payload: payload, status: status, lifetime: newLifetime, + delivery: delivery, headers: headers ) } diff --git a/Tests/HTTPMockTests/HTTPMockTests.swift b/Tests/HTTPMockTests/HTTPMockTests.swift index 903c82c..b9ad04b 100644 --- a/Tests/HTTPMockTests/HTTPMockTests.swift +++ b/Tests/HTTPMockTests/HTTPMockTests.swift @@ -387,6 +387,70 @@ struct HTTPMockTests { } } + // MARK: - Delivery (delay) tests + + @Test + func delivery_immediate_returnsQuickly() async throws { + let key = createMockKey(path: "/delay-immediate") + httpMock.addResponse(.plaintext("ok", delivery: .instant), for: key) + + let url = try #require(URL(string: "https://example.com/delay-immediate")) + let start = Date() + let (data, response) = try await httpMock.urlSession.data(from: url) + let elapsed = Date().timeIntervalSince(start) + + #expect(response.httpStatusCode == 200) + #expect(data.toString == "ok") + + // Should complete fast. Allow some time, "just in case"™. + #expect(elapsed < 0.1) + } + + @Test + func delivery_delayed_respectsInterval() async throws { + let key = createMockKey(path: "/delay-300ms") + httpMock.addResponse(.plaintext("slow", delivery: .delayed(0.3)), for: key) + + let url = try #require(URL(string: "https://example.com/delay-300ms")) + let start = Date() + let (data, response) = try await httpMock.urlSession.data(from: url) + let elapsed = Date().timeIntervalSince(start) + + #expect(response.httpStatusCode == 200) + #expect(data.toString == "slow") + + // Subtract some time, just in case of scheduling jitter. + #expect(elapsed >= 0.28) + } + + @Test + func delivery_appliesPerResponse_inFifoOrder() async throws { + let key = createMockKey(path: "/delay-sequence") + httpMock.addResponse(.plaintext("requested-first-but-delivered-second", delivery: .delayed(0.2)), for: key) + httpMock.addResponse(.plaintext("requested-second-but-delivered-first", delivery: .delayed(0.1)), for: key) + + + let url = try #require(URL(string: "https://example.com/delay-sequence")) + + try await withThrowingTaskGroup(of: (Data, URLResponse).self) { group in + group.addTask { try await httpMock.urlSession.data(from: url) } + group.addTask { try await httpMock.urlSession.data(from: url) } + + var resultStrings = [String]() + for try await tuple in group { + resultStrings.append(tuple.0.toString) + } + + let expectedOrder = [ + "requested-second-but-delivered-first", + "requested-first-but-delivered-second" + ] + #expect(resultStrings == expectedOrder) + } + + #expect(mockQueues[key]?.isEmpty == true) + } + // MARK: - Helpers private func writeTempFile(named: String, ext: String, contents: Data) throws -> URL {