Files
PlantGuide/PlantGuideTests/TrefleAPIServiceTests.swift
Trey t 136dfbae33 Add PlantGuide iOS app with plant identification and care management
- Implement camera capture and plant identification workflow
- Add Core Data persistence for plants, care schedules, and cached API data
- Create collection view with grid/list layouts and filtering
- Build plant detail views with care information display
- Integrate Trefle botanical API for plant care data
- Add local image storage for captured plant photos
- Implement dependency injection container for testability
- Include accessibility support throughout the app

Bug fixes in this commit:
- Fix Trefle API decoding by removing duplicate CodingKeys
- Fix LocalCachedImage to load from correct PlantImages directory
- Set dateAdded when saving plants for proper collection sorting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:18:01 -06:00

587 lines
19 KiB
Swift

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