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>
This commit is contained in:
586
PlantGuideTests/TrefleAPIServiceTests.swift
Normal file
586
PlantGuideTests/TrefleAPIServiceTests.swift
Normal file
@@ -0,0 +1,586 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user