Files
PlantGuide/PlantGuideTests/ImageCacheTests.swift
Trey t 08ced7dbbb Fix test infrastructure for Rooms feature and improve testability
- Update Plant test fixtures to use roomID instead of deprecated location
- Add URLDataFetcher protocol to ImageCache for dependency injection
- Update ImageCacheTests to use protocol-based mock instead of URLSession subclass
- Add missing cancelReminders(for:plantID:) method to MockNotificationService
- Add Equatable conformance to ImageCacheError for test assertions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 14:55:50 -06:00

494 lines
15 KiB
Swift

//
// 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: - MockURLDataFetcher
/// Mock URL data fetcher for testing image downloads
final class MockURLDataFetcher: URLDataFetcher, @unchecked Sendable {
var dataToReturn: Data?
var errorToThrow: Error?
var downloadCallCount = 0
var lastRequestedURL: URL?
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 mockFetcher: MockURLDataFetcher!
private var testDirectory: URL!
private var fileManager: FileManager!
// MARK: - Test Lifecycle
override func setUp() async throws {
try await super.setUp()
fileManager = FileManager.default
mockFetcher = MockURLDataFetcher()
// 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,
urlDataFetcher: mockFetcher
)
}
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
mockFetcher = 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")!
mockFetcher.dataToReturn = createTestImageData()
// When
try await sut.cacheImage(from: url, for: plantID)
// Then
XCTAssertEqual(mockFetcher.downloadCallCount, 1)
XCTAssertEqual(mockFetcher.lastRequestedURL, url)
}
func testCacheImage_WhenDownloadFails_ThrowsDownloadFailed() async {
// Given
let plantID = UUID()
let url = URL(string: "https://example.com/plant.jpg")!
mockFetcher.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")!
mockFetcher.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")!
mockFetcher.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(mockFetcher.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")!
mockFetcher.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")!
mockFetcher.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")!
mockFetcher.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
mockFetcher.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")!
mockFetcher.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
mockFetcher.downloadCallCount = 0
try await sut.cacheImage(from: url1, for: plantID1)
XCTAssertEqual(mockFetcher.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")!
mockFetcher.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")!
mockFetcher.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")!
mockFetcher.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")!
mockFetcher.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")!
mockFetcher.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)")!
mockFetcher.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")!
mockFetcher.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")! }
mockFetcher.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")! }
mockFetcher.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)
}
}
}