// // TrefleAPIServiceTests.swift // PlantGuideTests // // Unit tests for TrefleAPIService error handling. // Tests that HTTP status codes are properly mapped to TrefleAPIError cases. // import XCTest @testable import PlantGuide final class TrefleAPIServiceTests: XCTestCase { // MARK: - Properties private var sut: TrefleAPIService! private var mockSession: URLSession! // MARK: - Setup override func setUp() { super.setUp() // Configure mock URL protocol let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [MockURLProtocol.self] mockSession = URLSession(configuration: configuration) let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase sut = TrefleAPIService(session: mockSession, decoder: decoder) } override func tearDown() { MockURLProtocol.requestHandler = nil sut = nil mockSession = nil super.tearDown() } // MARK: - Error Handling Tests func testSearchPlants_With401Response_ThrowsInvalidTokenError() async { // Given MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.searchPlants(query: "rose", page: 1) XCTFail("Expected invalidToken error to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .invalidToken) XCTAssertEqual(error.errorDescription, "Invalid API token. Please check your Trefle API configuration.") } catch { XCTFail("Expected TrefleAPIError.invalidToken, got \(error)") } } func testGetSpecies_With401Response_ThrowsInvalidTokenError() async { // Given MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.getSpecies(slug: "rosa-gallica") XCTFail("Expected invalidToken error to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .invalidToken) } catch { XCTFail("Expected TrefleAPIError.invalidToken, got \(error)") } } func testGetSpeciesById_With401Response_ThrowsInvalidTokenError() async { // Given MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.getSpeciesById(id: 12345) XCTFail("Expected invalidToken error to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .invalidToken) } catch { XCTFail("Expected TrefleAPIError.invalidToken, got \(error)") } } func testSearchPlants_With404Response_ThrowsSpeciesNotFoundError() async { // Given let searchQuery = "nonexistentplant" MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.searchPlants(query: searchQuery, page: 1) XCTFail("Expected speciesNotFound error to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .speciesNotFound(query: searchQuery)) XCTAssertEqual(error.errorDescription, "No species found matching '\(searchQuery)'.") } catch { XCTFail("Expected TrefleAPIError.speciesNotFound, got \(error)") } } func testGetSpecies_With404Response_ThrowsSpeciesNotFoundError() async { // Given let slug = "nonexistent-plant-slug" MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.getSpecies(slug: slug) XCTFail("Expected speciesNotFound error to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .speciesNotFound(query: slug)) } catch { XCTFail("Expected TrefleAPIError.speciesNotFound, got \(error)") } } func testGetSpeciesById_With404Response_ThrowsSpeciesNotFoundError() async { // Given let id = 99999999 MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.getSpeciesById(id: id) XCTFail("Expected speciesNotFound error to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .speciesNotFound(query: String(id))) } catch { XCTFail("Expected TrefleAPIError.speciesNotFound, got \(error)") } } func testSearchPlants_With429Response_ThrowsRateLimitExceededError() async { // Given MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 429, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.searchPlants(query: "rose", page: 1) XCTFail("Expected rateLimitExceeded error to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .rateLimitExceeded) XCTAssertEqual(error.errorDescription, "Request limit reached. Please try again later.") } catch { XCTFail("Expected TrefleAPIError.rateLimitExceeded, got \(error)") } } func testGetSpecies_With429Response_ThrowsRateLimitExceededError() async { // Given MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 429, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.getSpecies(slug: "rosa-gallica") XCTFail("Expected rateLimitExceeded error to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .rateLimitExceeded) } catch { XCTFail("Expected TrefleAPIError.rateLimitExceeded, got \(error)") } } func testGetSpeciesById_With429Response_ThrowsRateLimitExceededError() async { // Given MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 429, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.getSpeciesById(id: 123) XCTFail("Expected rateLimitExceeded error to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .rateLimitExceeded) } catch { XCTFail("Expected TrefleAPIError.rateLimitExceeded, got \(error)") } } // MARK: - Server Error Tests func testSearchPlants_With500Response_ThrowsServerError() async { // Given MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.searchPlants(query: "rose", page: 1) XCTFail("Expected serverError to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .serverError(statusCode: 500)) XCTAssertEqual(error.errorDescription, "Server error occurred (code: 500). Please try again later.") } catch { XCTFail("Expected TrefleAPIError.serverError, got \(error)") } } func testSearchPlants_With503Response_ThrowsServerError() async { // Given MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 503, httpVersion: nil, headerFields: nil )! return (response, Data()) } // When/Then do { _ = try await sut.searchPlants(query: "rose", page: 1) XCTFail("Expected serverError to be thrown") } catch let error as TrefleAPIError { XCTAssertEqual(error, .serverError(statusCode: 503)) } catch { XCTFail("Expected TrefleAPIError.serverError, got \(error)") } } // MARK: - TrefleAPIError Equatable Tests func testTrefleAPIErrorEquality_InvalidToken() { // Given let error1 = TrefleAPIError.invalidToken let error2 = TrefleAPIError.invalidToken // Then XCTAssertEqual(error1, error2) } func testTrefleAPIErrorEquality_RateLimitExceeded() { // Given let error1 = TrefleAPIError.rateLimitExceeded let error2 = TrefleAPIError.rateLimitExceeded // Then XCTAssertEqual(error1, error2) } func testTrefleAPIErrorEquality_SpeciesNotFoundSameQuery() { // Given let error1 = TrefleAPIError.speciesNotFound(query: "rose") let error2 = TrefleAPIError.speciesNotFound(query: "rose") // Then XCTAssertEqual(error1, error2) } func testTrefleAPIErrorEquality_SpeciesNotFoundDifferentQuery() { // Given let error1 = TrefleAPIError.speciesNotFound(query: "rose") let error2 = TrefleAPIError.speciesNotFound(query: "tulip") // Then XCTAssertNotEqual(error1, error2) } func testTrefleAPIErrorEquality_ServerErrorSameCode() { // Given let error1 = TrefleAPIError.serverError(statusCode: 500) let error2 = TrefleAPIError.serverError(statusCode: 500) // Then XCTAssertEqual(error1, error2) } func testTrefleAPIErrorEquality_ServerErrorDifferentCode() { // Given let error1 = TrefleAPIError.serverError(statusCode: 500) let error2 = TrefleAPIError.serverError(statusCode: 503) // Then XCTAssertNotEqual(error1, error2) } func testTrefleAPIErrorEquality_DifferentTypes() { // Given let error1 = TrefleAPIError.invalidToken let error2 = TrefleAPIError.rateLimitExceeded // Then XCTAssertNotEqual(error1, error2) } // MARK: - Error Message Tests func testInvalidTokenErrorMessage() { // Given let error = TrefleAPIError.invalidToken // Then XCTAssertEqual(error.errorDescription, "Invalid API token. Please check your Trefle API configuration.") XCTAssertEqual(error.failureReason, "The Trefle API token is missing or has been revoked.") XCTAssertEqual(error.recoverySuggestion, "Verify your Trefle API token in the app configuration.") } func testRateLimitExceededErrorMessage() { // Given let error = TrefleAPIError.rateLimitExceeded // Then XCTAssertEqual(error.errorDescription, "Request limit reached. Please try again later.") XCTAssertEqual(error.failureReason, "Too many requests have been made in a short period.") XCTAssertEqual(error.recoverySuggestion, "Wait a few minutes before making another request.") } func testSpeciesNotFoundErrorMessage() { // Given let query = "nonexistent plant" let error = TrefleAPIError.speciesNotFound(query: query) // Then XCTAssertEqual(error.errorDescription, "No species found matching '\(query)'.") XCTAssertEqual(error.failureReason, "No results for query: \(query)") XCTAssertEqual(error.recoverySuggestion, "Try a different search term or check the spelling.") } func testNetworkUnavailableErrorMessage() { // Given let error = TrefleAPIError.networkUnavailable // Then XCTAssertEqual(error.errorDescription, "No internet connection. Please check your network and try again.") XCTAssertEqual(error.failureReason, "The device is not connected to the internet.") XCTAssertEqual(error.recoverySuggestion, "Connect to Wi-Fi or enable cellular data.") } func testTimeoutErrorMessage() { // Given let error = TrefleAPIError.timeout // Then XCTAssertEqual(error.errorDescription, "The request timed out. Please try again.") XCTAssertEqual(error.failureReason, "The server did not respond within the timeout period.") XCTAssertEqual(error.recoverySuggestion, "Check your internet connection and try again.") } func testInvalidResponseErrorMessage() { // Given let error = TrefleAPIError.invalidResponse // Then XCTAssertEqual(error.errorDescription, "Received an invalid response from the Trefle API.") XCTAssertEqual(error.failureReason, "The server response format was unexpected.") XCTAssertEqual(error.recoverySuggestion, "The app may need to be updated.") } // MARK: - Successful Response Tests func testSearchPlants_With200Response_ReturnsDecodedData() async { // Given let jsonResponse = """ { "data": [ { "id": 1, "common_name": "Rose", "slug": "rosa", "scientific_name": "Rosa", "family": "Rosaceae", "genus": "Rosa" } ], "links": { "self": "/api/v1/plants/search?q=rose", "first": "/api/v1/plants/search?page=1&q=rose" }, "meta": { "total": 1 } } """.data(using: .utf8)! MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil )! return (response, jsonResponse) } // When do { let result = try await sut.searchPlants(query: "rose", page: 1) // Then XCTAssertEqual(result.data.count, 1) XCTAssertEqual(result.data.first?.commonName, "Rose") XCTAssertEqual(result.meta.total, 1) } catch { XCTFail("Unexpected error: \(error)") } } func testGetSpecies_With200Response_ReturnsDecodedData() async { // Given let jsonResponse = """ { "data": { "id": 1, "common_name": "Rose", "slug": "rosa", "scientific_name": "Rosa species" }, "meta": {} } """.data(using: .utf8)! MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil )! return (response, jsonResponse) } // When do { let result = try await sut.getSpecies(slug: "rosa") // Then XCTAssertEqual(result.data.id, 1) XCTAssertEqual(result.data.commonName, "Rose") XCTAssertEqual(result.data.scientificName, "Rosa species") } catch { XCTFail("Unexpected error: \(error)") } } // MARK: - Decoding Error Tests func testSearchPlants_WithInvalidJSON_ThrowsDecodingError() async { // Given let invalidJSON = "{ invalid json }".data(using: .utf8)! MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil )! return (response, invalidJSON) } // When/Then do { _ = try await sut.searchPlants(query: "rose", page: 1) XCTFail("Expected decodingFailed error to be thrown") } catch let error as TrefleAPIError { if case .decodingFailed = error { // Success XCTAssertEqual(error.errorDescription, "Failed to process the server response.") } else { XCTFail("Expected TrefleAPIError.decodingFailed, got \(error)") } } catch { XCTFail("Expected TrefleAPIError.decodingFailed, got \(error)") } } } // MARK: - MockURLProtocol /// A mock URL protocol for intercepting and customizing network responses in tests. final class MockURLProtocol: URLProtocol { /// Handler to provide custom responses for requests. static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? override class func canInit(with request: URLRequest) -> Bool { return true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request } override func startLoading() { guard let handler = MockURLProtocol.requestHandler else { fatalError("MockURLProtocol.requestHandler not set") } do { let (response, data) = try handler(request) client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: data) client?.urlProtocolDidFinishLoading(self) } catch { client?.urlProtocol(self, didFailWithError: error) } } override func stopLoading() { // Required override, but no-op for our purposes } }