// // ImageCacheTests.swift // PlantGuideTests // // Unit tests for ImageCache - the actor-based image cache service // that provides both memory and disk caching for plant images. // import XCTest @testable import PlantGuide // MARK: - MockURLSession /// Mock URL session for testing image downloads final class MockURLSessionForCache: URLSession, @unchecked Sendable { var dataToReturn: Data? var errorToThrow: Error? var downloadCallCount = 0 var lastRequestedURL: URL? override func data(from url: URL) async throws -> (Data, URLResponse) { downloadCallCount += 1 lastRequestedURL = url if let error = errorToThrow { throw error } let response = HTTPURLResponse( url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil )! return (dataToReturn ?? Data(), response) } } // MARK: - ImageCacheTests final class ImageCacheTests: XCTestCase { // MARK: - Properties private var sut: ImageCache! private var mockSession: MockURLSessionForCache! private var testDirectory: URL! private var fileManager: FileManager! // MARK: - Test Lifecycle override func setUp() async throws { try await super.setUp() fileManager = FileManager.default mockSession = MockURLSessionForCache() // Create a unique test directory for each test let tempDir = fileManager.temporaryDirectory testDirectory = tempDir.appendingPathComponent("ImageCacheTests_\(UUID().uuidString)") try fileManager.createDirectory(at: testDirectory, withIntermediateDirectories: true) sut = ImageCache( fileManager: fileManager, urlSession: mockSession ) } override func tearDown() async throws { // Clean up test directory if let testDirectory = testDirectory, fileManager.fileExists(atPath: testDirectory.path) { try? fileManager.removeItem(at: testDirectory) } sut = nil mockSession = nil testDirectory = nil fileManager = nil try await super.tearDown() } // MARK: - Test Helpers private func createTestImageData() -> Data { let size = CGSize(width: 100, height: 100) let renderer = UIGraphicsImageRenderer(size: size) let image = renderer.image { context in UIColor.green.setFill() context.fill(CGRect(origin: .zero, size: size)) } return image.jpegData(compressionQuality: 0.8)! } private func createInvalidImageData() -> Data { return "This is not valid image data".data(using: .utf8)! } // MARK: - cacheImage() Tests func testCacheImage_WhenDownloadSucceeds_StoresImageInCache() async throws { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! mockSession.dataToReturn = createTestImageData() // When try await sut.cacheImage(from: url, for: plantID) // Then XCTAssertEqual(mockSession.downloadCallCount, 1) XCTAssertEqual(mockSession.lastRequestedURL, url) } func testCacheImage_WhenDownloadFails_ThrowsDownloadFailed() async { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! mockSession.errorToThrow = URLError(.notConnectedToInternet) // When/Then do { try await sut.cacheImage(from: url, for: plantID) XCTFail("Expected downloadFailed error to be thrown") } catch let error as ImageCacheError { switch error { case .downloadFailed: break // Expected default: XCTFail("Expected downloadFailed error, got \(error)") } } catch { XCTFail("Expected ImageCacheError, got \(error)") } } func testCacheImage_WhenInvalidImageData_ThrowsInvalidImageData() async { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! mockSession.dataToReturn = createInvalidImageData() // When/Then do { try await sut.cacheImage(from: url, for: plantID) XCTFail("Expected invalidImageData error to be thrown") } catch let error as ImageCacheError { XCTAssertEqual(error, .invalidImageData) } catch { XCTFail("Expected ImageCacheError, got \(error)") } } func testCacheImage_WhenAlreadyCached_DoesNotDownloadAgain() async throws { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! mockSession.dataToReturn = createTestImageData() // When - Cache twice try await sut.cacheImage(from: url, for: plantID) try await sut.cacheImage(from: url, for: plantID) // Then - Should only download once XCTAssertEqual(mockSession.downloadCallCount, 1) } // MARK: - getCachedImage() Tests func testGetCachedImage_WhenNotCached_ReturnsNil() async { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! // When let result = await sut.getCachedImage(for: plantID, url: url) // Then XCTAssertNil(result) } func testGetCachedImage_WhenCached_ReturnsImage() async throws { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! mockSession.dataToReturn = createTestImageData() // Cache the image first try await sut.cacheImage(from: url, for: plantID) // When let result = await sut.getCachedImage(for: plantID, url: url) // Then XCTAssertNotNil(result) } func testGetCachedImage_WithDifferentPlantID_ReturnsNil() async throws { // Given let plantID1 = UUID() let plantID2 = UUID() let url = URL(string: "https://example.com/plant.jpg")! mockSession.dataToReturn = createTestImageData() // Cache for plant 1 try await sut.cacheImage(from: url, for: plantID1) // When - Try to get for plant 2 let result = await sut.getCachedImage(for: plantID2, url: url) // Then XCTAssertNil(result) } func testGetCachedImage_WithDifferentURL_ReturnsNil() async throws { // Given let plantID = UUID() let url1 = URL(string: "https://example.com/plant1.jpg")! let url2 = URL(string: "https://example.com/plant2.jpg")! mockSession.dataToReturn = createTestImageData() // Cache url1 try await sut.cacheImage(from: url1, for: plantID) // When - Try to get url2 let result = await sut.getCachedImage(for: plantID, url: url2) // Then XCTAssertNil(result) } func testGetCachedImage_ByURLHash_ReturnsImage() async throws { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! let urlHash = url.absoluteString.sha256Hash mockSession.dataToReturn = createTestImageData() // Cache the image try await sut.cacheImage(from: url, for: plantID) // When let result = await sut.getCachedImage(for: plantID, urlHash: urlHash) // Then XCTAssertNotNil(result) } // MARK: - clearCache() Tests func testClearCache_ForSpecificPlant_RemovesOnlyThatPlantsImages() async throws { // Given let plantID1 = UUID() let plantID2 = UUID() let url1 = URL(string: "https://example.com/plant1.jpg")! let url2 = URL(string: "https://example.com/plant2.jpg")! mockSession.dataToReturn = createTestImageData() // Cache images for both plants try await sut.cacheImage(from: url1, for: plantID1) try await sut.cacheImage(from: url2, for: plantID2) // When - Clear only plant 1's cache await sut.clearCache(for: plantID1) // Then - Plant 1's image should be gone, plant 2's should still exist // Note: Due to memory cache clearing behavior, we need to re-cache // This test verifies the disk cache behavior mockSession.downloadCallCount = 0 try await sut.cacheImage(from: url1, for: plantID1) XCTAssertEqual(mockSession.downloadCallCount, 1) // Had to redownload } func testClearCache_ForPlant_RemovesMultipleImages() async throws { // Given let plantID = UUID() let url1 = URL(string: "https://example.com/plant1.jpg")! let url2 = URL(string: "https://example.com/plant2.jpg")! mockSession.dataToReturn = createTestImageData() // Cache multiple images for the same plant try await sut.cacheImage(from: url1, for: plantID) try await sut.cacheImage(from: url2, for: plantID) // When await sut.clearCache(for: plantID) // Then - Both images should be gone let result1 = await sut.getCachedImage(for: plantID, url: url1) let result2 = await sut.getCachedImage(for: plantID, url: url2) XCTAssertNil(result1) XCTAssertNil(result2) } // MARK: - clearAllCache() Tests func testClearAllCache_RemovesAllImages() async throws { // Given let plantID1 = UUID() let plantID2 = UUID() let url1 = URL(string: "https://example.com/plant1.jpg")! let url2 = URL(string: "https://example.com/plant2.jpg")! mockSession.dataToReturn = createTestImageData() // Cache images for multiple plants try await sut.cacheImage(from: url1, for: plantID1) try await sut.cacheImage(from: url2, for: plantID2) // When await sut.clearAllCache() // Then - All images should be gone let result1 = await sut.getCachedImage(for: plantID1, url: url1) let result2 = await sut.getCachedImage(for: plantID2, url: url2) XCTAssertNil(result1) XCTAssertNil(result2) } // MARK: - getCacheSize() Tests func testGetCacheSize_WhenEmpty_ReturnsZero() async { // Given - New cache // When let size = await sut.getCacheSize() // Then XCTAssertGreaterThanOrEqual(size, 0) } func testGetCacheSize_AfterCaching_ReturnsNonZero() async throws { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! mockSession.dataToReturn = createTestImageData() // Cache an image try await sut.cacheImage(from: url, for: plantID) // When let size = await sut.getCacheSize() // Then XCTAssertGreaterThan(size, 0) } func testGetCacheSize_AfterClearing_ReturnsZeroOrLess() async throws { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! mockSession.dataToReturn = createTestImageData() // Cache an image try await sut.cacheImage(from: url, for: plantID) // Clear cache await sut.clearAllCache() // When let size = await sut.getCacheSize() // Then - Size should be back to zero (or minimal) XCTAssertGreaterThanOrEqual(size, 0) // At least not negative } // MARK: - Memory Cache Tests func testMemoryCache_HitOnSecondAccess_NoReload() async throws { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! mockSession.dataToReturn = createTestImageData() // Cache the image try await sut.cacheImage(from: url, for: plantID) // When - Access twice let _ = await sut.getCachedImage(for: plantID, url: url) let result2 = await sut.getCachedImage(for: plantID, url: url) // Then - Image should still be available XCTAssertNotNil(result2) } // MARK: - Error Description Tests func testImageCacheError_InvalidImageData_HasCorrectDescription() { // Given let error = ImageCacheError.invalidImageData // Then XCTAssertNotNil(error.errorDescription) XCTAssertTrue(error.errorDescription?.contains("invalid") ?? false) } func testImageCacheError_CompressionFailed_HasCorrectDescription() { // Given let error = ImageCacheError.compressionFailed // Then XCTAssertNotNil(error.errorDescription) XCTAssertTrue(error.errorDescription?.contains("compress") ?? false) } func testImageCacheError_WriteFailed_HasCorrectDescription() { // Given let error = ImageCacheError.writeFailed // Then XCTAssertNotNil(error.errorDescription) XCTAssertTrue(error.errorDescription?.contains("write") ?? false) } func testImageCacheError_DownloadFailed_HasCorrectDescription() { // Given let underlyingError = URLError(.notConnectedToInternet) let error = ImageCacheError.downloadFailed(underlyingError) // Then XCTAssertNotNil(error.errorDescription) XCTAssertTrue(error.errorDescription?.contains("download") ?? false) } // MARK: - Protocol Conformance Tests func testImageCache_ConformsToProtocol() { XCTAssertTrue(sut is ImageCacheProtocol) } // MARK: - Edge Cases func testCacheImage_WithLongURL_Works() async throws { // Given let plantID = UUID() let longPath = String(repeating: "path/", count: 50) + "image.jpg" let url = URL(string: "https://example.com/\(longPath)")! mockSession.dataToReturn = createTestImageData() // When/Then - Should not throw try await sut.cacheImage(from: url, for: plantID) } func testCacheImage_WithSpecialCharactersInURL_Works() async throws { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant%20image%231.jpg")! mockSession.dataToReturn = createTestImageData() // When/Then - Should not throw try await sut.cacheImage(from: url, for: plantID) } func testCacheImage_MultipleImagesForSamePlant_AllCached() async throws { // Given let plantID = UUID() let urls = (0..<5).map { URL(string: "https://example.com/plant\($0).jpg")! } mockSession.dataToReturn = createTestImageData() // When - Cache all images for url in urls { try await sut.cacheImage(from: url, for: plantID) } // Then - All should be retrievable for url in urls { let result = await sut.getCachedImage(for: plantID, url: url) XCTAssertNotNil(result, "Image for \(url) should be cached") } } func testCacheImage_ConcurrentAccess_HandledCorrectly() async throws { // Given let plantID = UUID() let urls = (0..<10).map { URL(string: "https://example.com/plant\($0).jpg")! } mockSession.dataToReturn = createTestImageData() // When - Cache concurrently await withTaskGroup(of: Void.self) { group in for url in urls { group.addTask { try? await self.sut.cacheImage(from: url, for: plantID) } } } // Then - All should be retrievable (no crashes) for url in urls { let result = await sut.getCachedImage(for: plantID, url: url) XCTAssertNotNil(result) } } }