- 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>
587 lines
19 KiB
Swift
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
|
|
}
|
|
}
|