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