[api-upload-refactor] Replace request surface: typed body, pathParams, bounded retry
- request(): drop params: Any? and jsonEncoding:; add body: (any HTTPBody)? and pathParams: [String: String] - remove legacy magic-"file" multipart branch (-> upload() in T6) and the now-unused mimeTypeForPath - decompose into makeURL / buildRequest / attach / send / classify / decodeResponse / friendlyError (each < 30 lines) - headers now merge over defaults (custom wins per key) instead of replacing - fix unbounded persistConnection recursion on permanent 4xx: cap at maxPersistRetries (3) - APIRequestTests: 7 cases over StubURLProtocol
This commit is contained in:
122
Tests/LCEssentialsTests/APIRequestTests.swift
Normal file
122
Tests/LCEssentialsTests/APIRequestTests.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
import XCTest
|
||||
@testable import LCEssentials
|
||||
|
||||
private struct Echo: Decodable, Sendable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
private struct CreateDTO: Encodable, Sendable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
final class APIRequestTests: XCTestCase {
|
||||
|
||||
private var api: API!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.protocolClasses = [StubURLProtocol.self]
|
||||
api = API(testConfiguration: cfg)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
StubURLProtocol.reset()
|
||||
api = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testJSONBodyRequestSendsEncodedBodyAndDecodesResponse() async throws {
|
||||
var stub = StubURLProtocol.Stub()
|
||||
stub.statusCode = 200
|
||||
stub.body = Data(#"{"id":10,"name":"x"}"#.utf8)
|
||||
StubURLProtocol.setStub(stub)
|
||||
|
||||
let result: Echo = try await api.request(
|
||||
url: "https://api.example.com/users",
|
||||
method: .post,
|
||||
body: jsonBody(CreateDTO(name: "x"))
|
||||
)
|
||||
|
||||
XCTAssertEqual(result, Echo(id: 10, name: "x"))
|
||||
let sent = StubURLProtocol.capturedRequests.first
|
||||
XCTAssertEqual(sent?.httpMethod, "POST")
|
||||
XCTAssertEqual(sent?.value(forHTTPHeaderField: "Content-Type"), "application/json; charset=UTF-8")
|
||||
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
|
||||
}
|
||||
|
||||
func testCustomHeaderOverridesDefault() async throws {
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":1,"name":"a"}"#.utf8)))
|
||||
|
||||
let _: Echo = try await api.request(
|
||||
url: "https://api.example.com/x",
|
||||
method: .get,
|
||||
headers: ["Accept": "application/xml"]
|
||||
)
|
||||
|
||||
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.value(forHTTPHeaderField: "Accept"),
|
||||
"application/xml")
|
||||
}
|
||||
|
||||
func testPathParamsSubstitution() async throws {
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":42,"name":"a"}"#.utf8)))
|
||||
|
||||
let _: Echo = try await api.request(
|
||||
url: "https://api.example.com/users/{id}/posts",
|
||||
method: .get,
|
||||
pathParams: ["id": "42"]
|
||||
)
|
||||
|
||||
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.url?.absoluteString,
|
||||
"https://api.example.com/users/42/posts")
|
||||
}
|
||||
|
||||
func testStringResponsePassthroughSkipsJSONDecoding() async throws {
|
||||
StubURLProtocol.setStub(.init(statusCode: 200,
|
||||
headers: ["Content-Type": "text/plain"],
|
||||
body: Data("plain hello".utf8)))
|
||||
|
||||
let text: String = try await api.request(url: "https://api.example.com/ping", method: .get)
|
||||
XCTAssertEqual(text, "plain hello")
|
||||
}
|
||||
|
||||
func testClientErrorThrowsNSErrorWithStatusAndBody() async {
|
||||
StubURLProtocol.setStub(.init(statusCode: 422,
|
||||
body: Data(#"{"error":"invalid"}"#.utf8)))
|
||||
|
||||
do {
|
||||
let _: Echo = try await api.request(url: "https://api.example.com/x", method: .post,
|
||||
body: jsonBody(CreateDTO(name: "")))
|
||||
XCTFail("expected throw")
|
||||
} catch let error as NSError {
|
||||
XCTAssertEqual(error.code, 422)
|
||||
XCTAssertTrue(error.localizedFailureReason?.contains("invalid") ?? false,
|
||||
"reason: \(error.localizedFailureReason ?? "nil")")
|
||||
}
|
||||
}
|
||||
|
||||
func testPersistConnectionRetriesButIsBoundedOnPermanentClientError() async {
|
||||
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
|
||||
|
||||
do {
|
||||
let _: Echo = try await api.request(url: "https://api.example.com/x",
|
||||
method: .get, persistConnection: true)
|
||||
XCTFail("expected throw after retries exhausted")
|
||||
} catch let error as NSError {
|
||||
XCTAssertEqual(error.code, 400)
|
||||
} catch {
|
||||
XCTFail("unexpected error type: \(error)")
|
||||
}
|
||||
|
||||
// retried, but did NOT loop forever
|
||||
XCTAssertGreaterThanOrEqual(StubURLProtocol.requestCount, 2)
|
||||
XCTAssertLessThanOrEqual(StubURLProtocol.requestCount, API.maxPersistRetries + 1)
|
||||
}
|
||||
|
||||
func testNoPersistConnectionDoesNotRetry() async {
|
||||
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
|
||||
let _: Echo? = try? await api.request(url: "https://api.example.com/x", method: .get)
|
||||
XCTAssertEqual(StubURLProtocol.requestCount, 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user