// // DeletePlantUseCaseTests.swift // PlantGuideTests // // Unit tests for DeletePlantUseCase - the use case for deleting plants // from the user's collection with proper cleanup of associated resources. // import XCTest @testable import PlantGuide // MARK: - DeletePlantUseCaseTests final class DeletePlantUseCaseTests: XCTestCase { // MARK: - Properties private var sut: DeletePlantUseCase! private var mockPlantRepository: MockPlantCollectionRepository! private var mockImageStorage: MockImageStorage! private var mockNotificationService: MockNotificationService! private var mockCareScheduleRepository: MockCareScheduleRepository! // MARK: - Test Lifecycle override func setUp() { super.setUp() mockPlantRepository = MockPlantCollectionRepository() mockImageStorage = MockImageStorage() mockNotificationService = MockNotificationService() mockCareScheduleRepository = MockCareScheduleRepository() sut = DeletePlantUseCase( plantRepository: mockPlantRepository, imageStorage: mockImageStorage, notificationService: mockNotificationService, careScheduleRepository: mockCareScheduleRepository ) } override func tearDown() async throws { sut = nil mockPlantRepository = nil await mockImageStorage.reset() await mockNotificationService.reset() mockCareScheduleRepository = nil try await super.tearDown() } // MARK: - execute() Basic Delete Tests func testExecute_WhenPlantExists_SuccessfullyDeletesPlant() async throws { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) // When try await sut.execute(plantID: plant.id) // Then XCTAssertEqual(mockPlantRepository.deleteCallCount, 1) XCTAssertEqual(mockPlantRepository.lastDeletedPlantID, plant.id) XCTAssertNil(mockPlantRepository.plants[plant.id]) } func testExecute_WhenPlantDoesNotExist_ThrowsPlantNotFound() async { // Given let nonExistentPlantID = UUID() // When/Then do { try await sut.execute(plantID: nonExistentPlantID) XCTFail("Expected plantNotFound error to be thrown") } catch let error as DeletePlantError { switch error { case .plantNotFound(let plantID): XCTAssertEqual(plantID, nonExistentPlantID) default: XCTFail("Expected plantNotFound error, got \(error)") } } catch { XCTFail("Expected DeletePlantError, got \(error)") } XCTAssertEqual(mockPlantRepository.deleteCallCount, 0) } // MARK: - Notification Cancellation Tests func testExecute_WhenDeleting_CancelsAllNotificationsForPlant() async throws { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) // Schedule some notifications for this plant let task = CareTask.mockWatering(plantID: plant.id) try await mockNotificationService.scheduleReminder( for: task, plantName: plant.displayName, plantID: plant.id ) // When try await sut.execute(plantID: plant.id) // Then let cancelAllCount = await mockNotificationService.cancelAllRemindersCallCount XCTAssertEqual(cancelAllCount, 1) let lastCancelledPlantID = await mockNotificationService.lastCancelledAllPlantID XCTAssertEqual(lastCancelledPlantID, plant.id) } func testExecute_WhenNotificationCancellationFails_StillDeletesPlant() async throws { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) // Note: Notification cancellation is non-throwing by design, so we just verify // the plant is still deleted even if there were internal notification issues // When try await sut.execute(plantID: plant.id) // Then - Plant should still be deleted XCTAssertEqual(mockPlantRepository.deleteCallCount, 1) XCTAssertNil(mockPlantRepository.plants[plant.id]) } // MARK: - Image Cleanup Tests func testExecute_WhenDeleting_DeletesAllImagesForPlant() async throws { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) // Add some images for this plant let testImage = MockImageStorage.createTestImage() _ = try await mockImageStorage.save(testImage, for: plant.id) _ = try await mockImageStorage.save(testImage, for: plant.id) let initialCount = await mockImageStorage.imageCount(for: plant.id) XCTAssertEqual(initialCount, 2) // When try await sut.execute(plantID: plant.id) // Then let deleteAllCount = await mockImageStorage.deleteAllCallCount XCTAssertEqual(deleteAllCount, 1) let lastDeletedPlantID = await mockImageStorage.lastDeletedAllPlantID XCTAssertEqual(lastDeletedPlantID, plant.id) } func testExecute_WhenImageDeletionFails_StillDeletesPlant() async throws { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) // Configure image storage to fail on delete // Note: The use case logs but doesn't throw on image deletion failure // When try await sut.execute(plantID: plant.id) // Then - Plant should still be deleted XCTAssertEqual(mockPlantRepository.deleteCallCount, 1) XCTAssertNil(mockPlantRepository.plants[plant.id]) } // MARK: - Care Schedule Cleanup Tests func testExecute_WhenDeleting_DeletesCareScheduleForPlant() async throws { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) // Add a care schedule for this plant let schedule = PlantCareSchedule.mock(plantID: plant.id) mockCareScheduleRepository.addSchedule(schedule) XCTAssertNotNil(mockCareScheduleRepository.schedules[plant.id]) // When try await sut.execute(plantID: plant.id) // Then XCTAssertEqual(mockCareScheduleRepository.deleteCallCount, 1) XCTAssertEqual(mockCareScheduleRepository.lastDeletedPlantID, plant.id) } func testExecute_WhenCareScheduleDeletionFails_StillDeletesPlant() async throws { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) mockCareScheduleRepository.shouldThrowOnDelete = true // When try await sut.execute(plantID: plant.id) // Then - Plant should still be deleted despite schedule deletion failure XCTAssertEqual(mockPlantRepository.deleteCallCount, 1) XCTAssertNil(mockPlantRepository.plants[plant.id]) } // MARK: - Complete Cleanup Flow Tests func testExecute_PerformsCleanupInCorrectOrder() async throws { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) let schedule = PlantCareSchedule.mock(plantID: plant.id) mockCareScheduleRepository.addSchedule(schedule) let testImage = MockImageStorage.createTestImage() _ = try await mockImageStorage.save(testImage, for: plant.id) // When try await sut.execute(plantID: plant.id) // Then - Verify all cleanup operations were called let notificationCancelCount = await mockNotificationService.cancelAllRemindersCallCount XCTAssertEqual(notificationCancelCount, 1, "Notifications should be cancelled") let imageDeleteCount = await mockImageStorage.deleteAllCallCount XCTAssertEqual(imageDeleteCount, 1, "Images should be deleted") XCTAssertEqual(mockCareScheduleRepository.deleteCallCount, 1, "Care schedule should be deleted") XCTAssertEqual(mockPlantRepository.deleteCallCount, 1, "Plant should be deleted") } func testExecute_WhenAllCleanupSucceeds_PlantIsDeleted() async throws { // Given let plant = Plant.mockComplete() mockPlantRepository.addPlant(plant) let schedule = PlantCareSchedule.mockWithMixedTasks(plantID: plant.id) mockCareScheduleRepository.addSchedule(schedule) let testImage = MockImageStorage.createTestImage() _ = try await mockImageStorage.save(testImage, for: plant.id) // When try await sut.execute(plantID: plant.id) // Then XCTAssertNil(mockPlantRepository.plants[plant.id]) } // MARK: - Error Handling Tests func testExecute_WhenRepositoryDeleteFails_ThrowsRepositoryDeleteFailed() async { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) mockPlantRepository.shouldThrowOnDelete = true mockPlantRepository.errorToThrow = NSError(domain: "CoreData", code: 500) // When/Then do { try await sut.execute(plantID: plant.id) XCTFail("Expected repositoryDeleteFailed error to be thrown") } catch let error as DeletePlantError { switch error { case .repositoryDeleteFailed(let underlyingError): XCTAssertEqual((underlyingError as NSError).domain, "CoreData") default: XCTFail("Expected repositoryDeleteFailed error, got \(error)") } } catch { XCTFail("Expected DeletePlantError, got \(error)") } } func testExecute_WhenExistsCheckFails_PropagatesError() async { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) mockPlantRepository.shouldThrowOnExists = true // When/Then do { try await sut.execute(plantID: plant.id) XCTFail("Expected error to be thrown") } catch { // Error should be propagated XCTAssertNotNil(error) } XCTAssertEqual(mockPlantRepository.existsCallCount, 1) XCTAssertEqual(mockPlantRepository.deleteCallCount, 0) } // MARK: - Error Description Tests func testDeletePlantError_PlantNotFound_HasCorrectDescription() { // Given let plantID = UUID() let error = DeletePlantError.plantNotFound(plantID: plantID) // Then XCTAssertNotNil(error.errorDescription) XCTAssertTrue(error.errorDescription?.contains(plantID.uuidString) ?? false) XCTAssertNotNil(error.failureReason) XCTAssertNotNil(error.recoverySuggestion) } func testDeletePlantError_RepositoryDeleteFailed_HasCorrectDescription() { // Given let underlyingError = NSError(domain: "Test", code: 123) let error = DeletePlantError.repositoryDeleteFailed(underlyingError) // Then XCTAssertNotNil(error.errorDescription) XCTAssertNotNil(error.failureReason) XCTAssertNotNil(error.recoverySuggestion) } func testDeletePlantError_ImageDeletionFailed_HasCorrectDescription() { // Given let underlyingError = NSError(domain: "ImageError", code: 456) let error = DeletePlantError.imageDeletionFailed(underlyingError) // Then XCTAssertNotNil(error.errorDescription) XCTAssertNotNil(error.failureReason) XCTAssertNotNil(error.recoverySuggestion) } func testDeletePlantError_CareScheduleDeletionFailed_HasCorrectDescription() { // Given let underlyingError = NSError(domain: "ScheduleError", code: 789) let error = DeletePlantError.careScheduleDeletionFailed(underlyingError) // Then XCTAssertNotNil(error.errorDescription) XCTAssertNotNil(error.failureReason) XCTAssertNotNil(error.recoverySuggestion) } // MARK: - Protocol Conformance Tests func testDeletePlantUseCase_ConformsToProtocol() { XCTAssertTrue(sut is DeletePlantUseCaseProtocol) } // MARK: - Edge Cases func testExecute_WhenPlantHasNoAssociatedData_SuccessfullyDeletes() async throws { // Given - Plant with no images, no schedule, no notifications let plant = Plant.mock() mockPlantRepository.addPlant(plant) // When try await sut.execute(plantID: plant.id) // Then XCTAssertEqual(mockPlantRepository.deleteCallCount, 1) XCTAssertNil(mockPlantRepository.plants[plant.id]) } func testExecute_WithConcurrentDeletes_HandlesCorrectly() async throws { // Given let plant1 = Plant.mock() let plant2 = Plant.mock() mockPlantRepository.addPlants([plant1, plant2]) // When - Delete both plants concurrently await withTaskGroup(of: Void.self) { group in group.addTask { [sut] in try? await sut!.execute(plantID: plant1.id) } group.addTask { [sut] in try? await sut!.execute(plantID: plant2.id) } } // Then XCTAssertEqual(mockPlantRepository.deleteCallCount, 2) } func testExecute_WhenDeletingSamePlantTwice_SecondAttemptFails() async throws { // Given let plant = Plant.mock() mockPlantRepository.addPlant(plant) // When - First delete try await sut.execute(plantID: plant.id) // Then - Second delete should fail with plantNotFound do { try await sut.execute(plantID: plant.id) XCTFail("Expected plantNotFound error on second delete") } catch let error as DeletePlantError { switch error { case .plantNotFound: break // Expected default: XCTFail("Expected plantNotFound error, got \(error)") } } catch { XCTFail("Expected DeletePlantError, got \(error)") } } }