Files
PlantGuide/PlantGuideTests/UpdatePlantUseCaseTests.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

648 lines
20 KiB
Swift

//
// UpdatePlantUseCaseTests.swift
// PlantGuideTests
//
// Unit tests for UpdatePlantUseCase - the use case for updating plant entities
// in the user's collection.
//
import XCTest
@testable import PlantGuide
// MARK: - Protocol Extension for Testing
/// Extension to add exists method to PlantCollectionRepositoryProtocol for testing
/// This matches the implementation in concrete repository classes
extension PlantCollectionRepositoryProtocol {
func exists(id: UUID) async throws -> Bool {
let plant = try await fetch(id: id)
return plant != nil
}
}
// MARK: - Mock Plant Collection Repository
/// Mock implementation of PlantCollectionRepositoryProtocol for testing UpdatePlantUseCase
final class UpdatePlantTestMockRepository: PlantCollectionRepositoryProtocol, @unchecked Sendable {
// MARK: - Properties for Testing
var plants: [UUID: Plant] = [:]
var existsCallCount = 0
var updatePlantCallCount = 0
var saveCallCount = 0
var fetchByIdCallCount = 0
var fetchAllCallCount = 0
var deleteCallCount = 0
var searchCallCount = 0
var filterCallCount = 0
var getFavoritesCallCount = 0
var setFavoriteCallCount = 0
var getStatisticsCallCount = 0
var shouldThrowOnExists = false
var shouldThrowOnUpdate = false
var shouldThrowOnSave = false
var shouldThrowOnFetch = false
var shouldThrowOnDelete = false
var shouldThrowOnSearch = false
var shouldThrowOnFilter = false
var shouldThrowOnGetFavorites = false
var shouldThrowOnSetFavorite = false
var shouldThrowOnGetStatistics = false
var errorToThrow: Error = NSError(domain: "MockError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Mock repository error"])
var lastUpdatedPlant: Plant?
var lastSavedPlant: Plant?
var lastSearchQuery: String?
var lastFilter: PlantFilter?
// MARK: - PlantRepositoryProtocol
func save(_ plant: Plant) async throws {
saveCallCount += 1
lastSavedPlant = plant
if shouldThrowOnSave {
throw errorToThrow
}
plants[plant.id] = plant
}
func fetch(id: UUID) async throws -> Plant? {
fetchByIdCallCount += 1
if shouldThrowOnFetch {
throw errorToThrow
}
return plants[id]
}
func fetchAll() async throws -> [Plant] {
fetchAllCallCount += 1
if shouldThrowOnFetch {
throw errorToThrow
}
return Array(plants.values)
}
func delete(id: UUID) async throws {
deleteCallCount += 1
if shouldThrowOnDelete {
throw errorToThrow
}
plants.removeValue(forKey: id)
}
// MARK: - PlantCollectionRepositoryProtocol - Additional Methods
func exists(id: UUID) async throws -> Bool {
existsCallCount += 1
if shouldThrowOnExists {
throw errorToThrow
}
return plants[id] != nil
}
func updatePlant(_ plant: Plant) async throws {
updatePlantCallCount += 1
lastUpdatedPlant = plant
if shouldThrowOnUpdate {
throw errorToThrow
}
plants[plant.id] = plant
}
func searchPlants(query: String) async throws -> [Plant] {
searchCallCount += 1
lastSearchQuery = query
if shouldThrowOnSearch {
throw errorToThrow
}
return plants.values.filter { plant in
plant.scientificName.lowercased().contains(query.lowercased()) ||
plant.commonNames.contains { $0.lowercased().contains(query.lowercased()) }
}
}
func filterPlants(by filter: PlantFilter) async throws -> [Plant] {
filterCallCount += 1
lastFilter = filter
if shouldThrowOnFilter {
throw errorToThrow
}
var result = Array(plants.values)
if let isFavorite = filter.isFavorite {
result = result.filter { $0.isFavorite == isFavorite }
}
if let families = filter.families {
result = result.filter { families.contains($0.family) }
}
if let source = filter.identificationSource {
result = result.filter { $0.identificationSource == source }
}
return result
}
func getFavorites() async throws -> [Plant] {
getFavoritesCallCount += 1
if shouldThrowOnGetFavorites {
throw errorToThrow
}
return plants.values.filter { $0.isFavorite }
}
func setFavorite(plantID: UUID, isFavorite: Bool) async throws {
setFavoriteCallCount += 1
if shouldThrowOnSetFavorite {
throw errorToThrow
}
if var plant = plants[plantID] {
plant.isFavorite = isFavorite
plants[plantID] = plant
}
}
func getCollectionStatistics() async throws -> CollectionStatistics {
getStatisticsCallCount += 1
if shouldThrowOnGetStatistics {
throw errorToThrow
}
return CollectionStatistics(
totalPlants: plants.count,
favoriteCount: plants.values.filter { $0.isFavorite }.count,
familyDistribution: [:],
identificationSourceBreakdown: [:],
plantsAddedThisMonth: 0,
upcomingTasksCount: 0,
overdueTasksCount: 0
)
}
// MARK: - Helper Methods
func reset() {
plants = [:]
existsCallCount = 0
updatePlantCallCount = 0
saveCallCount = 0
fetchByIdCallCount = 0
fetchAllCallCount = 0
deleteCallCount = 0
searchCallCount = 0
filterCallCount = 0
getFavoritesCallCount = 0
setFavoriteCallCount = 0
getStatisticsCallCount = 0
shouldThrowOnExists = false
shouldThrowOnUpdate = false
shouldThrowOnSave = false
shouldThrowOnFetch = false
shouldThrowOnDelete = false
shouldThrowOnSearch = false
shouldThrowOnFilter = false
shouldThrowOnGetFavorites = false
shouldThrowOnSetFavorite = false
shouldThrowOnGetStatistics = false
lastUpdatedPlant = nil
lastSavedPlant = nil
lastSearchQuery = nil
lastFilter = nil
}
func addPlant(_ plant: Plant) {
plants[plant.id] = plant
}
}
// MARK: - UpdatePlantUseCaseTests
final class UpdatePlantUseCaseTests: XCTestCase {
// MARK: - Properties
private var sut: UpdatePlantUseCase!
private var mockRepository: UpdatePlantTestMockRepository!
// MARK: - Test Lifecycle
override func setUp() {
super.setUp()
mockRepository = UpdatePlantTestMockRepository()
sut = UpdatePlantUseCase(plantRepository: mockRepository)
}
override func tearDown() {
sut = nil
mockRepository = nil
super.tearDown()
}
// MARK: - Test Helpers
private func createTestPlant(
id: UUID = UUID(),
scientificName: String = "Monstera deliciosa",
commonNames: [String] = ["Swiss Cheese Plant"],
family: String = "Araceae",
genus: String = "Monstera",
isFavorite: Bool = false,
notes: String? = nil,
customName: String? = nil,
location: String? = nil
) -> Plant {
Plant(
id: id,
scientificName: scientificName,
commonNames: commonNames,
family: family,
genus: genus,
identificationSource: .onDeviceML,
isFavorite: isFavorite,
customName: customName,
location: location
)
}
// MARK: - execute() Success Tests
func testExecute_WhenPlantExistsAndDataIsValid_SuccessfullyUpdatesPlant() async throws {
// Given
let plantID = UUID()
let originalPlant = createTestPlant(id: plantID, notes: "Original notes")
mockRepository.addPlant(originalPlant)
var updatedPlant = originalPlant
updatedPlant.notes = "Updated notes"
updatedPlant.customName = "My Monstera"
updatedPlant.location = "Living Room"
// When
let result = try await sut.execute(plant: updatedPlant)
// Then
XCTAssertEqual(result.id, plantID)
XCTAssertEqual(result.notes, "Updated notes")
XCTAssertEqual(result.customName, "My Monstera")
XCTAssertEqual(result.location, "Living Room")
XCTAssertEqual(mockRepository.existsCallCount, 1)
XCTAssertEqual(mockRepository.updatePlantCallCount, 1)
XCTAssertEqual(mockRepository.lastUpdatedPlant?.id, plantID)
}
func testExecute_WhenUpdatingFavoriteStatus_SuccessfullyUpdates() async throws {
// Given
let plantID = UUID()
let originalPlant = createTestPlant(id: plantID, isFavorite: false)
mockRepository.addPlant(originalPlant)
var updatedPlant = originalPlant
updatedPlant.isFavorite = true
// When
let result = try await sut.execute(plant: updatedPlant)
// Then
XCTAssertTrue(result.isFavorite)
XCTAssertEqual(mockRepository.updatePlantCallCount, 1)
}
func testExecute_WhenUpdatingOnlyNotes_SuccessfullyUpdates() async throws {
// Given
let plantID = UUID()
let originalPlant = createTestPlant(id: plantID)
mockRepository.addPlant(originalPlant)
var updatedPlant = originalPlant
updatedPlant.notes = "This plant needs more water during summer"
// When
let result = try await sut.execute(plant: updatedPlant)
// Then
XCTAssertEqual(result.notes, "This plant needs more water during summer")
}
func testExecute_WhenUpdatingOnlyCustomName_SuccessfullyUpdates() async throws {
// Given
let plantID = UUID()
let originalPlant = createTestPlant(id: plantID)
mockRepository.addPlant(originalPlant)
var updatedPlant = originalPlant
updatedPlant.customName = "Bob the Plant"
// When
let result = try await sut.execute(plant: updatedPlant)
// Then
XCTAssertEqual(result.customName, "Bob the Plant")
}
func testExecute_WhenUpdatingOnlyLocation_SuccessfullyUpdates() async throws {
// Given
let plantID = UUID()
let originalPlant = createTestPlant(id: plantID)
mockRepository.addPlant(originalPlant)
var updatedPlant = originalPlant
updatedPlant.location = "Kitchen windowsill"
// When
let result = try await sut.execute(plant: updatedPlant)
// Then
XCTAssertEqual(result.location, "Kitchen windowsill")
}
func testExecute_PreservesImmutableProperties() async throws {
// Given
let plantID = UUID()
let originalPlant = createTestPlant(
id: plantID,
scientificName: "Monstera deliciosa",
commonNames: ["Swiss Cheese Plant"],
family: "Araceae",
genus: "Monstera"
)
mockRepository.addPlant(originalPlant)
var updatedPlant = originalPlant
updatedPlant.notes = "New notes"
// When
let result = try await sut.execute(plant: updatedPlant)
// Then - Immutable properties should be preserved
XCTAssertEqual(result.scientificName, "Monstera deliciosa")
XCTAssertEqual(result.commonNames, ["Swiss Cheese Plant"])
XCTAssertEqual(result.family, "Araceae")
XCTAssertEqual(result.genus, "Monstera")
XCTAssertEqual(result.identificationSource, .onDeviceML)
}
// MARK: - execute() Throws plantNotFound Tests
func testExecute_WhenPlantDoesNotExist_ThrowsPlantNotFound() async {
// Given
let nonExistentPlantID = UUID()
let plant = createTestPlant(id: nonExistentPlantID)
// Don't add plant to repository
// When/Then
do {
_ = try await sut.execute(plant: plant)
XCTFail("Expected plantNotFound error to be thrown")
} catch let error as UpdatePlantError {
switch error {
case .plantNotFound(let plantID):
XCTAssertEqual(plantID, nonExistentPlantID)
default:
XCTFail("Expected plantNotFound error, got \(error)")
}
} catch {
XCTFail("Expected UpdatePlantError, got \(error)")
}
XCTAssertEqual(mockRepository.existsCallCount, 1)
XCTAssertEqual(mockRepository.updatePlantCallCount, 0)
}
func testExecute_WhenPlantWasDeleted_ThrowsPlantNotFound() async {
// Given
let plantID = UUID()
let plant = createTestPlant(id: plantID)
mockRepository.addPlant(plant)
// Simulate plant being deleted before update
mockRepository.plants.removeValue(forKey: plantID)
// When/Then
do {
_ = try await sut.execute(plant: plant)
XCTFail("Expected plantNotFound error to be thrown")
} catch let error as UpdatePlantError {
switch error {
case .plantNotFound(let id):
XCTAssertEqual(id, plantID)
default:
XCTFail("Expected plantNotFound error, got \(error)")
}
} catch {
XCTFail("Expected UpdatePlantError, got \(error)")
}
}
// MARK: - execute() Throws invalidPlantData Tests
func testExecute_WhenScientificNameIsEmpty_ThrowsInvalidPlantData() async {
// Given
let plantID = UUID()
let originalPlant = createTestPlant(id: plantID, scientificName: "Monstera deliciosa")
mockRepository.addPlant(originalPlant)
// Create a plant with empty scientific name (invalid)
let invalidPlant = Plant(
id: plantID,
scientificName: "",
commonNames: ["Swiss Cheese Plant"],
family: "Araceae",
genus: "Monstera",
identificationSource: .onDeviceML
)
// When/Then
do {
_ = try await sut.execute(plant: invalidPlant)
XCTFail("Expected invalidPlantData error to be thrown")
} catch let error as UpdatePlantError {
switch error {
case .invalidPlantData(let reason):
XCTAssertTrue(reason.contains("Scientific name"))
default:
XCTFail("Expected invalidPlantData error, got \(error)")
}
} catch {
XCTFail("Expected UpdatePlantError, got \(error)")
}
XCTAssertEqual(mockRepository.existsCallCount, 1)
XCTAssertEqual(mockRepository.updatePlantCallCount, 0)
}
func testExecute_WhenScientificNameIsWhitespaceOnly_ThrowsInvalidPlantData() async {
// Given
let plantID = UUID()
let originalPlant = createTestPlant(id: plantID)
mockRepository.addPlant(originalPlant)
// Note: The current implementation only checks for empty string, not whitespace
// This test documents the current behavior
let whitespaceOnlyPlant = Plant(
id: plantID,
scientificName: " ",
commonNames: ["Swiss Cheese Plant"],
family: "Araceae",
genus: "Monstera",
identificationSource: .onDeviceML
)
// When
// Current implementation does not trim whitespace, so this will succeed
// If the implementation changes to validate whitespace, this test should be updated
let result = try? await sut.execute(plant: whitespaceOnlyPlant)
// Then
// Documenting current behavior - whitespace-only scientific names are allowed
XCTAssertNotNil(result)
}
// MARK: - execute() Throws repositoryUpdateFailed Tests
func testExecute_WhenRepositoryUpdateFails_ThrowsRepositoryUpdateFailed() async {
// Given
let plantID = UUID()
let plant = createTestPlant(id: plantID)
mockRepository.addPlant(plant)
let underlyingError = NSError(domain: "CoreData", code: 500, userInfo: [NSLocalizedDescriptionKey: "Database error"])
mockRepository.shouldThrowOnUpdate = true
mockRepository.errorToThrow = underlyingError
// When/Then
do {
_ = try await sut.execute(plant: plant)
XCTFail("Expected repositoryUpdateFailed error to be thrown")
} catch let error as UpdatePlantError {
switch error {
case .repositoryUpdateFailed(let wrappedError):
XCTAssertEqual((wrappedError as NSError).domain, "CoreData")
XCTAssertEqual((wrappedError as NSError).code, 500)
default:
XCTFail("Expected repositoryUpdateFailed error, got \(error)")
}
} catch {
XCTFail("Expected UpdatePlantError, got \(error)")
}
XCTAssertEqual(mockRepository.existsCallCount, 1)
XCTAssertEqual(mockRepository.updatePlantCallCount, 1)
}
func testExecute_WhenRepositoryThrowsNetworkError_ThrowsRepositoryUpdateFailed() async {
// Given
let plantID = UUID()
let plant = createTestPlant(id: plantID)
mockRepository.addPlant(plant)
let networkError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet)
mockRepository.shouldThrowOnUpdate = true
mockRepository.errorToThrow = networkError
// When/Then
do {
_ = try await sut.execute(plant: plant)
XCTFail("Expected repositoryUpdateFailed error to be thrown")
} catch let error as UpdatePlantError {
switch error {
case .repositoryUpdateFailed(let wrappedError):
XCTAssertEqual((wrappedError as NSError).domain, NSURLErrorDomain)
default:
XCTFail("Expected repositoryUpdateFailed error, got \(error)")
}
} catch {
XCTFail("Expected UpdatePlantError, got \(error)")
}
}
// MARK: - Error Description Tests
func testUpdatePlantError_PlantNotFound_HasCorrectDescription() {
// Given
let plantID = UUID()
let error = UpdatePlantError.plantNotFound(plantID: plantID)
// Then
XCTAssertTrue(error.errorDescription?.contains(plantID.uuidString) ?? false)
XCTAssertNotNil(error.failureReason)
XCTAssertNotNil(error.recoverySuggestion)
}
func testUpdatePlantError_InvalidPlantData_HasCorrectDescription() {
// Given
let error = UpdatePlantError.invalidPlantData(reason: "Scientific name cannot be empty")
// Then
XCTAssertTrue(error.errorDescription?.contains("Scientific name") ?? false)
XCTAssertNotNil(error.failureReason)
XCTAssertNotNil(error.recoverySuggestion)
}
func testUpdatePlantError_RepositoryUpdateFailed_HasCorrectDescription() {
// Given
let underlyingError = NSError(domain: "Test", code: 123, userInfo: [NSLocalizedDescriptionKey: "Underlying error"])
let error = UpdatePlantError.repositoryUpdateFailed(underlyingError)
// Then
XCTAssertTrue(error.errorDescription?.contains("Underlying error") ?? false)
XCTAssertNotNil(error.failureReason)
XCTAssertNotNil(error.recoverySuggestion)
}
// MARK: - Protocol Conformance Tests
func testUpdatePlantUseCase_ConformsToProtocol() {
// Then
XCTAssertTrue(sut is UpdatePlantUseCaseProtocol)
}
// MARK: - Edge Cases
func testExecute_WithMultipleConcurrentUpdates_HandlesCorrectly() async throws {
// Given
let plantID = UUID()
let plant = createTestPlant(id: plantID)
mockRepository.addPlant(plant)
// When - Perform multiple concurrent updates
await withTaskGroup(of: Void.self) { group in
for i in 0..<10 {
group.addTask { [sut, mockRepository] in
var updatedPlant = plant
updatedPlant.notes = "Update \(i)"
_ = try? await sut!.execute(plant: updatedPlant)
}
}
}
// Then - All updates should complete
XCTAssertEqual(mockRepository.updatePlantCallCount, 10)
}
func testExecute_WhenExistsCheckThrows_PropagatesError() async {
// Given
let plantID = UUID()
let plant = createTestPlant(id: plantID)
mockRepository.addPlant(plant)
mockRepository.shouldThrowOnExists = true
// When/Then
do {
_ = try await sut.execute(plant: plant)
XCTFail("Expected error to be thrown")
} catch {
// Error should be propagated (wrapped or as-is)
XCTAssertNotNil(error)
}
XCTAssertEqual(mockRepository.existsCallCount, 1)
XCTAssertEqual(mockRepository.updatePlantCallCount, 0)
}
}