Skip to content

Commit ffd296c

Browse files
authored
Feature: Response delay (#3)
* MockResponse: Add property delivery * Delay response, if applicable * Add tests for delay * Update README.md * Update README.md
1 parent 46471b5 commit ffd296c

File tree

5 files changed

+131
-18
lines changed

5 files changed

+131
-18
lines changed

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,19 @@ MockResponse.plaintext("served three times", lifetime: .multiple(3))
156156
MockResponse.plaintext("served forever", lifetime: .eternal)
157157
```
158158

159+
## Response delivery
160+
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`.
161+
162+
- `.instant`: The response is delivered immediately (default behavior).
163+
- `.delayed(TimeInterval)`: The response is delayed and delivered after the specified number of seconds.
164+
165+
Example:
166+
167+
```swift
168+
MockResponse.plaintext("immediate response", delivery: .instant)
169+
MockResponse.plaintext("delayed response", delivery: .delayed(2.0)) // delivered after 2 seconds
170+
```
171+
159172
## Handling unmocked requests
160173
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.
161174

@@ -223,7 +236,7 @@ Path("/user") {
223236
## Goals
224237
- [X] Allow for passthrough networking when mock hasn't been registered for the incoming URL.
225238
- [X] Let user point to a file that should be served.
226-
- [ ] Set delay on requests.
239+
- [X] Set delay on requests.
227240
- [ ] Let user configure a default "not found" response. Will be used either when no matching mocks are found or if queue is empty.
228241
- [ ] Create separate instances of `HTTPMock`. The current single instance requires tests to be run in sequence, instead of parallel.
229242
- [ ] Does arrays in query parameters work? I think they're being overwritten with the current setup.

Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -99,23 +99,34 @@ final class HTTPMockURLProtocol: URLProtocol {
9999

100100
// Look for, and pop, the next queued response mathing host, path and query params.
101101
if let mock = Self.pop(host: host, path: path, query: queryDict) {
102-
do {
103-
HTTPMockLog.info("Serving mock for \(host)\(path) (\(statusCode(of: mock)))")
104-
HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(host: host, path: path, query: queryDict))")
105-
106-
let response = HTTPURLResponse(
107-
url: url,
108-
statusCode: mock.status.code,
109-
httpVersion: "HTTP/1.1",
110-
headerFields: mock.headers
111-
)!
102+
let sendResponse = { [weak self] in
103+
guard let self else { return }
104+
do {
105+
HTTPMockLog.info("Serving mock for \(host)\(path) (\(self.statusCode(of: mock)))")
106+
HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(host: host, path: path, query: queryDict))")
107+
108+
let response = HTTPURLResponse(
109+
url: url,
110+
statusCode: mock.status.code,
111+
httpVersion: "HTTP/1.1",
112+
headerFields: mock.headers
113+
)!
114+
115+
let payload = try mock.payloadData()
116+
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
117+
self.client?.urlProtocol(self, didLoad: payload)
118+
self.client?.urlProtocolDidFinishLoading(self)
119+
} catch {
120+
self.client?.urlProtocol(self, didFailWithError: error)
121+
}
122+
}
112123

113-
let payload = try mock.payloadData()
114-
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
115-
client?.urlProtocol(self, didLoad: payload)
116-
client?.urlProtocolDidFinishLoading(self)
117-
} catch {
118-
client?.urlProtocol(self, didFailWithError: error)
124+
switch mock.delivery {
125+
case .instant:
126+
sendResponse()
127+
case .delayed(let delay):
128+
HTTPMockLog.info("Delaying response for \(requestDescription) for \(delay) seconds")
129+
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay, execute: sendResponse)
119130
}
120131
} else {
121132
switch Self.unmockedPolicy {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Foundation
2+
3+
extension MockResponse {
4+
public enum Delivery: Hashable {
5+
case instant
6+
case delayed(TimeInterval)
7+
}
8+
}

Sources/HTTPMock/Models/MockResponse.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,23 @@ public struct MockResponse: Hashable {
77

88
public let payload: Payload
99
public let status: Status
10-
public let headers: [String: String]
1110
public let lifetime: Lifetime
11+
public let delivery: Delivery
12+
public let headers: [String: String]
1213

1314
// MARK: - Init
1415

1516
public init(
1617
payload: Payload,
1718
status: Status = .ok,
1819
lifetime: Lifetime = .single,
20+
delivery: Delivery = .instant,
1921
headers: [String: String] = [:]
2022
) {
2123
self.payload = payload
2224
self.status = status
2325
self.lifetime = lifetime
26+
self.delivery = delivery
2427

2528
if let contentType = payload.contentType {
2629
// Merge the headers, allowing the user to overwrite any we set.
@@ -40,6 +43,7 @@ extension MockResponse {
4043
_ payload: T,
4144
status: Status = .ok,
4245
lifetime: Lifetime = .single,
46+
delivery: Delivery = .instant,
4347
headers: [String: String] = [:],
4448
jsonEncoder: JSONEncoder = .mockDefault
4549
) throws -> MockResponse {
@@ -48,6 +52,7 @@ extension MockResponse {
4852
payload: .data(data, contentType: "application/json"),
4953
status: status,
5054
lifetime: lifetime,
55+
delivery: delivery,
5156
headers: headers
5257
)
5358
}
@@ -56,13 +61,15 @@ extension MockResponse {
5661
_ payload: [String: Any],
5762
status: Status = .ok,
5863
lifetime: Lifetime = .single,
64+
delivery: Delivery = .instant,
5965
headers: [String: String] = [:]
6066
) throws -> MockResponse {
6167
let data = try JSONSerialization.data(withJSONObject: payload)
6268
return Self.init(
6369
payload: .data(data, contentType: "application/json"),
6470
status: status,
6571
lifetime: lifetime,
72+
delivery: delivery,
6673
headers: headers
6774
)
6875
}
@@ -71,26 +78,30 @@ extension MockResponse {
7178
_ payload: String,
7279
status: Status = .ok,
7380
lifetime: Lifetime = .single,
81+
delivery: Delivery = .instant,
7482
headers: [String: String] = [:]
7583
) -> MockResponse {
7684
let data = Data(payload.utf8)
7785
return Self.init(
7886
payload: .data(data, contentType: "text/plain"),
7987
status: status,
8088
lifetime: lifetime,
89+
delivery: delivery,
8190
headers: headers
8291
)
8392
}
8493

8594
public static func empty(
8695
status: Status = .ok,
8796
lifetime: Lifetime = .single,
97+
delivery: Delivery = .instant,
8898
headers: [String: String] = [:]
8999
) -> MockResponse {
90100
Self.init(
91101
payload: .empty,
92102
status: status,
93103
lifetime: lifetime,
104+
delivery: delivery,
94105
headers: headers
95106
)
96107
}
@@ -111,6 +122,7 @@ extension MockResponse {
111122
in bundle: Bundle = .main,
112123
status: Status = .ok,
113124
lifetime: Lifetime = .single,
125+
delivery: Delivery = .instant,
114126
headers: [String: String] = [:],
115127
contentType: String? = nil
116128
) -> MockResponse {
@@ -121,6 +133,7 @@ extension MockResponse {
121133
url: url,
122134
status: status,
123135
lifetime: lifetime,
136+
delivery: delivery,
124137
headers: headers,
125138
contentType: contentType
126139
)
@@ -138,6 +151,7 @@ extension MockResponse {
138151
url: URL,
139152
status: Status = .ok,
140153
lifetime: Lifetime = .single,
154+
delivery: Delivery = .instant,
141155
headers: [String: String] = [:],
142156
contentType: String? = nil
143157
) -> MockResponse {
@@ -154,6 +168,7 @@ extension MockResponse {
154168
payload: .data(data, contentType: contentType),
155169
status: status,
156170
lifetime: lifetime,
171+
delivery: delivery,
157172
headers: headers
158173
)
159174
}
@@ -195,6 +210,7 @@ extension MockResponse {
195210
payload: self.payload,
196211
status: self.status,
197212
lifetime: lifetime,
213+
delivery: delivery,
198214
headers: extra.mergedInOther(self.headers)
199215
)
200216
}
@@ -204,6 +220,7 @@ extension MockResponse {
204220
payload: payload,
205221
status: status,
206222
lifetime: newLifetime,
223+
delivery: delivery,
207224
headers: headers
208225
)
209226
}

Tests/HTTPMockTests/HTTPMockTests.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,70 @@ struct HTTPMockTests {
387387
}
388388
}
389389

390+
// MARK: - Delivery (delay) tests
391+
392+
@Test
393+
func delivery_immediate_returnsQuickly() async throws {
394+
let key = createMockKey(path: "/delay-immediate")
395+
httpMock.addResponse(.plaintext("ok", delivery: .instant), for: key)
396+
397+
let url = try #require(URL(string: "https://example.com/delay-immediate"))
398+
let start = Date()
399+
let (data, response) = try await httpMock.urlSession.data(from: url)
400+
let elapsed = Date().timeIntervalSince(start)
401+
402+
#expect(response.httpStatusCode == 200)
403+
#expect(data.toString == "ok")
404+
405+
// Should complete fast. Allow some time, "just in case"™.
406+
#expect(elapsed < 0.1)
407+
}
408+
409+
@Test
410+
func delivery_delayed_respectsInterval() async throws {
411+
let key = createMockKey(path: "/delay-300ms")
412+
httpMock.addResponse(.plaintext("slow", delivery: .delayed(0.3)), for: key)
413+
414+
let url = try #require(URL(string: "https://example.com/delay-300ms"))
415+
let start = Date()
416+
let (data, response) = try await httpMock.urlSession.data(from: url)
417+
let elapsed = Date().timeIntervalSince(start)
418+
419+
#expect(response.httpStatusCode == 200)
420+
#expect(data.toString == "slow")
421+
422+
// Subtract some time, just in case of scheduling jitter.
423+
#expect(elapsed >= 0.28)
424+
}
425+
426+
@Test
427+
func delivery_appliesPerResponse_inFifoOrder() async throws {
428+
let key = createMockKey(path: "/delay-sequence")
429+
httpMock.addResponse(.plaintext("requested-first-but-delivered-second", delivery: .delayed(0.2)), for: key)
430+
httpMock.addResponse(.plaintext("requested-second-but-delivered-first", delivery: .delayed(0.1)), for: key)
431+
432+
433+
let url = try #require(URL(string: "https://example.com/delay-sequence"))
434+
435+
try await withThrowingTaskGroup(of: (Data, URLResponse).self) { group in
436+
group.addTask { try await httpMock.urlSession.data(from: url) }
437+
group.addTask { try await httpMock.urlSession.data(from: url) }
438+
439+
var resultStrings = [String]()
440+
for try await tuple in group {
441+
resultStrings.append(tuple.0.toString)
442+
}
443+
444+
let expectedOrder = [
445+
"requested-second-but-delivered-first",
446+
"requested-first-but-delivered-second"
447+
]
448+
#expect(resultStrings == expectedOrder)
449+
}
450+
451+
#expect(mockQueues[key]?.isEmpty == true)
452+
}
453+
390454
// MARK: - Helpers
391455

392456
private func writeTempFile(named: String, ext: String, contents: Data) throws -> URL {

0 commit comments

Comments
 (0)