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) } }