- 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>
409 lines
14 KiB
Swift
409 lines
14 KiB
Swift
//
|
|
// 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)")
|
|
}
|
|
}
|
|
}
|