diff --git a/README.md b/README.md index 1b95303..28e9c7c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,24 @@ RemoteImage(type: .phAsset(localIdentifier: "541D4013-D51C-463C-AD85-0A1E4EA838F }) ``` +## Custom cache + +The `RemoteImageService` uses a default cache. To use a custom one just conform to the protocol `RemoteImageCache` and set it on the type `RemoteImageService`. + +```swift +RemoteImageService.cache = yourCache +``` + +## Custom cache key + +The default cache uses the associated value of the related `RemoteImageType` as the key. You can customize this by setting a cache key provider through + +```swift +RemoteImageService.cacheKeyProvider = { remoteImageType -> AnyObject in + // return a key here +} +``` + ## Migration from 0.1.0 -> 1.0.0 The `url parameter` was refactored to a `type parameter` which makes it easy to fetch images at a URL or from the iCloud. diff --git a/Sources/RemoteImage/private/Extensions/URLSession+RemoteImageURLDataPublisher.swift b/Sources/RemoteImage/private/Extensions/URLSession+RemoteImageURLDataPublisher.swift new file mode 100644 index 0000000..1e29b2e --- /dev/null +++ b/Sources/RemoteImage/private/Extensions/URLSession+RemoteImageURLDataPublisher.swift @@ -0,0 +1,15 @@ +// +// URLSession+RemoteImageURLDataPublisher.swift +// RemoteImage +// +// Created by Christian Elies on 15.12.19. +// + +import Combine +import Foundation + +extension URLSession: RemoteImageURLDataPublisher { + func dataPublisher(for request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { + dataTaskPublisher(for: request).eraseToAnyPublisher() + } +} diff --git a/Sources/RemoteImage/private/Models/RemoteImageState.swift b/Sources/RemoteImage/private/Models/RemoteImageState.swift index e9308e2..5d6ca06 100644 --- a/Sources/RemoteImage/private/Models/RemoteImageState.swift +++ b/Sources/RemoteImage/private/Models/RemoteImageState.swift @@ -8,23 +8,8 @@ import Foundation -enum RemoteImageState { - case error(_ error: Error) +enum RemoteImageState: Hashable { + case error(_ error: NSError) case image(_ image: PlatformSpecificImageType) case loading } - -extension RemoteImageState: Equatable { - static func == (lhs: RemoteImageState, rhs: RemoteImageState) -> Bool { - switch (lhs, rhs) { - case (.error(let lhsError), .error(let rhsError)): - return (lhsError as NSError) == (rhsError as NSError) - case (.image(let lhsImage), .image(let rhsImage)): - return lhsImage == rhsImage - case (.loading, .loading): - return true - default: - return false - } - } -} diff --git a/Sources/RemoteImage/private/Protocols/RemoteImageServiceProtocol.swift b/Sources/RemoteImage/private/Protocols/RemoteImageServiceProtocol.swift new file mode 100644 index 0000000..4c9e1ae --- /dev/null +++ b/Sources/RemoteImage/private/Protocols/RemoteImageServiceProtocol.swift @@ -0,0 +1,16 @@ +// +// RemoteImageServiceProtocol.swift +// RemoteImage +// +// Created by Christian Elies on 15.12.19. +// + +import Combine + +protocol RemoteImageServiceProtocol where Self: ObservableObject { + static var cache: RemoteImageCache { get set } + static var cacheKeyProvider: RemoteImageCacheKeyProvider { get set } + + var state: RemoteImageState { get set } + func fetchImage(ofType type: RemoteImageType) +} diff --git a/Sources/RemoteImage/private/Protocols/RemoteImageURLDataPublisher.swift b/Sources/RemoteImage/private/Protocols/RemoteImageURLDataPublisher.swift new file mode 100644 index 0000000..1f50d31 --- /dev/null +++ b/Sources/RemoteImage/private/Protocols/RemoteImageURLDataPublisher.swift @@ -0,0 +1,13 @@ +// +// RemoteImageURLDataPublisher.swift +// RemoteImage +// +// Created by Christian Elies on 15.12.19. +// + +import Combine +import Foundation + +protocol RemoteImageURLDataPublisher { + func dataPublisher(for request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError> +} diff --git a/Sources/RemoteImage/private/Protocols/RemoteImageURLDataPublisherProvider.swift b/Sources/RemoteImage/private/Protocols/RemoteImageURLDataPublisherProvider.swift new file mode 100644 index 0000000..6e87756 --- /dev/null +++ b/Sources/RemoteImage/private/Protocols/RemoteImageURLDataPublisherProvider.swift @@ -0,0 +1,10 @@ +// +// RemoteImageURLDataPublisherProvider.swift +// RemoteImage +// +// Created by Christian Elies on 15.12.19. +// + +protocol RemoteImageURLDataPublisherProvider { + var remoteImageURLDataPublisher: RemoteImageURLDataPublisher { get } +} diff --git a/Sources/RemoteImage/private/Services/DefaultRemoteImageCache.swift b/Sources/RemoteImage/private/Services/DefaultRemoteImageCache.swift new file mode 100644 index 0000000..4418720 --- /dev/null +++ b/Sources/RemoteImage/private/Services/DefaultRemoteImageCache.swift @@ -0,0 +1,22 @@ +// +// DefaultRemoteImageCache.swift +// +// +// Created by Christian Elies on 14.12.19. +// + +import Foundation + +struct DefaultRemoteImageCache { + let cache = NSCache() +} + +extension DefaultRemoteImageCache: RemoteImageCache { + func object(forKey key: AnyObject) -> PlatformSpecificImageType? { cache.object(forKey: key) } + + func setObject(_ object: PlatformSpecificImageType, forKey key: AnyObject) { cache.setObject(object, forKey: key) } + + func removeObject(forKey key: AnyObject) { cache.removeObject(forKey: key) } + + func removeAllObjects() { cache.removeAllObjects() } +} diff --git a/Sources/RemoteImage/private/Services/PhotoKitService.swift b/Sources/RemoteImage/private/Services/PhotoKitService.swift index 9f6b762..a630387 100644 --- a/Sources/RemoteImage/private/Services/PhotoKitService.swift +++ b/Sources/RemoteImage/private/Services/PhotoKitService.swift @@ -6,35 +6,34 @@ protocol PhotoKitServiceProvider { protocol PhotoKitServiceProtocol { func getPhotoData(localIdentifier: String, - success: @escaping (Data) -> Void, - failure: @escaping (Error) -> Void) + _ completion: @escaping (Result) -> Void) } final class PhotoKitService { - + static var asset: PHAsset.Type = PHAsset.self + static var imageManager: PHImageManager = PHImageManager.default() } extension PhotoKitService: PhotoKitServiceProtocol { func getPhotoData(localIdentifier: String, - success: @escaping (Data) -> Void, - failure: @escaping (Error) -> Void) { - let fetchAssetsResult = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil) + _ completion: @escaping (Result) -> Void) { + let fetchAssetsResult = Self.asset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil) guard let phAsset = fetchAssetsResult.firstObject else { - failure(PhotoKitServiceError.phAssetNotFound(localIdentifier: localIdentifier)) + completion(.failure(PhotoKitServiceError.phAssetNotFound(localIdentifier: localIdentifier))) return } - + let options = PHImageRequestOptions() options.isNetworkAccessAllowed = true - PHImageManager.default().requestImageDataAndOrientation(for: phAsset, - options: options, - resultHandler: { data, _, _, info in + Self.imageManager.requestImageDataAndOrientation(for: phAsset, + options: options, + resultHandler: { data, _, _, info in if let error = info?[PHImageErrorKey] as? Error { - failure(error) + completion(.failure(error)) } else if let data = data { - success(data) + completion(.success(data)) } else { - failure(PhotoKitServiceError.missingData) + completion(.failure(PhotoKitServiceError.missingData)) } }) } diff --git a/Sources/RemoteImage/private/Services/RemoteImageServiceDependencies.swift b/Sources/RemoteImage/private/Services/RemoteImageServiceDependencies.swift index 3f8eec0..5c49875 100644 --- a/Sources/RemoteImage/private/Services/RemoteImageServiceDependencies.swift +++ b/Sources/RemoteImage/private/Services/RemoteImageServiceDependencies.swift @@ -7,14 +7,16 @@ import Foundation -protocol RemoteImageServiceDependenciesProtocol: PhotoKitServiceProvider { +protocol RemoteImageServiceDependenciesProtocol: PhotoKitServiceProvider, RemoteImageURLDataPublisherProvider { } struct RemoteImageServiceDependencies: RemoteImageServiceDependenciesProtocol { let photoKitService: PhotoKitServiceProtocol + let remoteImageURLDataPublisher: RemoteImageURLDataPublisher init() { photoKitService = PhotoKitService() + remoteImageURLDataPublisher = URLSession.shared } } diff --git a/Sources/RemoteImage/public/Models/PhotoKitServiceError.swift b/Sources/RemoteImage/public/Models/PhotoKitServiceError.swift index 684b6e3..86066a8 100644 --- a/Sources/RemoteImage/public/Models/PhotoKitServiceError.swift +++ b/Sources/RemoteImage/public/Models/PhotoKitServiceError.swift @@ -5,6 +5,8 @@ public enum PhotoKitServiceError: Error { case phAssetNotFound(localIdentifier: String) } +extension PhotoKitServiceError: Equatable {} + extension PhotoKitServiceError: LocalizedError { public var errorDescription: String? { switch self { diff --git a/Sources/RemoteImage/public/Protocols/RemoteImageCache.swift b/Sources/RemoteImage/public/Protocols/RemoteImageCache.swift new file mode 100644 index 0000000..97ee17c --- /dev/null +++ b/Sources/RemoteImage/public/Protocols/RemoteImageCache.swift @@ -0,0 +1,15 @@ +// +// RemoteImageCache.swift +// +// +// Created by Christian Elies on 14.12.19. +// + +import Foundation + +public protocol RemoteImageCache { + func object(forKey key: AnyObject) -> PlatformSpecificImageType? + func setObject(_ object: PlatformSpecificImageType, forKey key: AnyObject) + func removeObject(forKey key: AnyObject) + func removeAllObjects() +} diff --git a/Sources/RemoteImage/public/Services/RemoteImageService.swift b/Sources/RemoteImage/public/Services/RemoteImageService.swift index f2ae910..1e13a92 100644 --- a/Sources/RemoteImage/public/Services/RemoteImageService.swift +++ b/Sources/RemoteImage/public/Services/RemoteImageService.swift @@ -9,18 +9,26 @@ import Combine import Foundation -public final class RemoteImageService: NSObject, ObservableObject { +public typealias RemoteImageCacheKeyProvider = (RemoteImageType) -> AnyObject + +public final class RemoteImageService: NSObject, ObservableObject, RemoteImageServiceProtocol { private let dependencies: RemoteImageServiceDependenciesProtocol private var cancellable: AnyCancellable? - + @Published var state: RemoteImageState = .loading - - public static let cache = NSCache() - + + public static var cache: RemoteImageCache = DefaultRemoteImageCache() + public static var cacheKeyProvider: RemoteImageCacheKeyProvider = { remoteImageType in + switch remoteImageType { + case .phAsset(let localIdentifier): return localIdentifier as NSString + case .url(let url): return url as NSURL + } + } + init(dependencies: RemoteImageServiceDependenciesProtocol) { self.dependencies = dependencies } - + func fetchImage(ofType type: RemoteImageType) { switch type { case .url(let url): @@ -34,49 +42,53 @@ public final class RemoteImageService: NSObject, ObservableObject { extension RemoteImageService { private func fetchImage(atURL url: URL) { cancellable?.cancel() - - if let image = RemoteImageService.cache.object(forKey: url as NSURL) { + + let cacheKey = Self.cacheKeyProvider(.url(url)) + if let image = Self.cache.object(forKey: cacheKey) { state = .image(image) return } - - let urlSession = URLSession.shared + let urlRequest = URLRequest(url: url) - - cancellable = urlSession.dataTaskPublisher(for: urlRequest) + + cancellable = dependencies.remoteImageURLDataPublisher.dataPublisher(for: urlRequest) .map { PlatformSpecificImageType(data: $0.data) } .receive(on: RunLoop.main) .sink(receiveCompletion: { completion in switch completion { - case .failure(let failure): - self.state = .error(failure) + case .failure(let error): + self.state = .error(error as NSError) default: () } }) { image in if let image = image { - RemoteImageService.cache.setObject(image, forKey: url as NSURL) + Self.cache.setObject(image, forKey: cacheKey) self.state = .image(image) } else { - self.state = .error(RemoteImageServiceError.couldNotCreateImage) + self.state = .error(RemoteImageServiceError.couldNotCreateImage as NSError) } } } - + private func fetchImage(withLocalIdentifier localIdentifier: String) { - if let image = RemoteImageService.cache.object(forKey: localIdentifier as NSString) { + let cacheKey = Self.cacheKeyProvider(.phAsset(localIdentifier: localIdentifier)) + if let image = Self.cache.object(forKey: cacheKey) { state = .image(image) return } - - dependencies.photoKitService.getPhotoData(localIdentifier: localIdentifier, success: { data in - if let image = PlatformSpecificImageType(data: data) { - RemoteImageService.cache.setObject(image, forKey: localIdentifier as NSString) - self.state = .image(image) - } else { - self.state = .error(RemoteImageServiceError.couldNotCreateImage) + + dependencies.photoKitService.getPhotoData(localIdentifier: localIdentifier) { result in + switch result { + case .success(let data): + if let image = PlatformSpecificImageType(data: data) { + Self.cache.setObject(image, forKey: cacheKey) + self.state = .image(image) + } else { + self.state = .error(RemoteImageServiceError.couldNotCreateImage as NSError) + } + case .failure(let error): + self.state = .error(error as NSError) } - }) { error in - self.state = .error(error) } } } diff --git a/Sources/RemoteImage/public/Views/RemoteImage.swift b/Sources/RemoteImage/public/Views/RemoteImage.swift index a16c0a0..28270a5 100644 --- a/Sources/RemoteImage/public/Views/RemoteImage.swift +++ b/Sources/RemoteImage/public/Views/RemoteImage.swift @@ -14,9 +14,9 @@ public struct RemoteImage: private let errorView: (Error) -> ErrorView private let imageView: (Image) -> ImageView private let loadingView: () -> LoadingView - + @ObservedObject private var service = RemoteImageServiceFactory.makeRemoteImageService() - + public var body: AnyView { switch service.state { case .error(let error): @@ -46,7 +46,7 @@ public struct RemoteImage: ) } } - + public init(type: RemoteImageType, @ViewBuilder errorView: @escaping (Error) -> ErrorView, @ViewBuilder imageView: @escaping (Image) -> ImageView, @ViewBuilder loadingView: @escaping () -> LoadingView) { self.type = type self.errorView = errorView diff --git a/Tests/RemoteImageTests/Extensions/URLSession+RemoteImageURLDataPublisherTests.swift b/Tests/RemoteImageTests/Extensions/URLSession+RemoteImageURLDataPublisherTests.swift new file mode 100644 index 0000000..47b7daf --- /dev/null +++ b/Tests/RemoteImageTests/Extensions/URLSession+RemoteImageURLDataPublisherTests.swift @@ -0,0 +1,24 @@ +// +// URLSession+RemoteImageURLDataPublisherTests.swift +// RemoteImageTests +// +// Created by Christian Elies on 15.12.19. +// + +import Foundation +@testable import RemoteImage +import XCTest + +final class URLSession_RemoteImageURLDataPublisherTests: XCTestCase { + func testDataPublisher() { + guard let url = URL(string: "https://google.de") else { + XCTFail("Could not create mock URL") + return + } + let urlSession: URLSession = .shared + let urlRequest = URLRequest(url: url) + let dataTaskPublisher = urlSession.dataTaskPublisher(for: urlRequest).eraseToAnyPublisher() + let dataPublisher = urlSession.dataPublisher(for: urlRequest) + XCTAssertEqual(dataPublisher.description, dataTaskPublisher.description) + } +} diff --git a/Tests/RemoteImageTests/Mocks/MockImageManager.swift b/Tests/RemoteImageTests/Mocks/MockImageManager.swift new file mode 100644 index 0000000..32f0379 --- /dev/null +++ b/Tests/RemoteImageTests/Mocks/MockImageManager.swift @@ -0,0 +1,19 @@ +// +// MockImageManager.swift +// RemoteImageTests +// +// Created by Christian Elies on 14.12.19. +// + +import Photos + +final class MockImageManager: PHImageManager { + var imageRequestID = PHImageRequestID() + var dataToReturn: Data? + var infoToReturn: [AnyHashable:Any]? + + override func requestImageDataAndOrientation(for asset: PHAsset, options: PHImageRequestOptions?, resultHandler: @escaping (Data?, String?, CGImagePropertyOrientation, [AnyHashable : Any]?) -> Void) -> PHImageRequestID { + resultHandler(dataToReturn, nil, .up, infoToReturn) + return imageRequestID + } +} diff --git a/Tests/RemoteImageTests/Mocks/MockPHAsset.swift b/Tests/RemoteImageTests/Mocks/MockPHAsset.swift new file mode 100644 index 0000000..fefe8ab --- /dev/null +++ b/Tests/RemoteImageTests/Mocks/MockPHAsset.swift @@ -0,0 +1,16 @@ +// +// MockPHAsset.swift +// RemoteImageTests +// +// Created by Christian Elies on 14.12.19. +// + +import Photos + +final class MockPHAsset: PHAsset { + static var fetchResult = MockPHAssetFetchResult() + + override class func fetchAssets(withLocalIdentifiers identifiers: [String], options: PHFetchOptions?) -> PHFetchResult { + fetchResult + } +} diff --git a/Tests/RemoteImageTests/Mocks/MockPHAssetFetchResult.swift b/Tests/RemoteImageTests/Mocks/MockPHAssetFetchResult.swift new file mode 100644 index 0000000..c82ba6d --- /dev/null +++ b/Tests/RemoteImageTests/Mocks/MockPHAssetFetchResult.swift @@ -0,0 +1,14 @@ +// +// MockPHAssetFetchResult.swift +// RemoteImageTests +// +// Created by Christian Elies on 14.12.19. +// + +import Photos + +final class MockPHAssetFetchResult: PHFetchResult { + var firstObjectToReturn: PHAsset? + + override var firstObject: PHAsset? { firstObjectToReturn } +} diff --git a/Tests/RemoteImageTests/Mocks/MockPhotoKitService.swift b/Tests/RemoteImageTests/Mocks/MockPhotoKitService.swift new file mode 100644 index 0000000..424bdd3 --- /dev/null +++ b/Tests/RemoteImageTests/Mocks/MockPhotoKitService.swift @@ -0,0 +1,17 @@ +// +// MockPhotoKitService.swift +// RemoteImageTests +// +// Created by Christian Elies on 15.12.19. +// + +import Foundation +@testable import RemoteImage + +final class MockPhotoKitService: PhotoKitServiceProtocol { + var resultToReturn: Result = .success(Data()) + + func getPhotoData(localIdentifier: String, _ completion: @escaping (Result) -> Void) { + completion(resultToReturn) + } +} diff --git a/Tests/RemoteImageTests/Mocks/MockRemoteImageServiceDependencies.swift b/Tests/RemoteImageTests/Mocks/MockRemoteImageServiceDependencies.swift new file mode 100644 index 0000000..9d22087 --- /dev/null +++ b/Tests/RemoteImageTests/Mocks/MockRemoteImageServiceDependencies.swift @@ -0,0 +1,18 @@ +// +// MockRemoteImageServiceDependencies.swift +// RemoteImageTests +// +// Created by Christian Elies on 15.12.19. +// + +@testable import RemoteImage + +struct MockRemoteImageServiceDependencies: RemoteImageServiceDependenciesProtocol { + let photoKitService: PhotoKitServiceProtocol + let remoteImageURLDataPublisher: RemoteImageURLDataPublisher + + init() { + photoKitService = MockPhotoKitService() + remoteImageURLDataPublisher = MockRemoteImageURLDataPublisher() + } +} diff --git a/Tests/RemoteImageTests/Mocks/MockRemoteImageURLDataPublisher.swift b/Tests/RemoteImageTests/Mocks/MockRemoteImageURLDataPublisher.swift new file mode 100644 index 0000000..3bc5b8f --- /dev/null +++ b/Tests/RemoteImageTests/Mocks/MockRemoteImageURLDataPublisher.swift @@ -0,0 +1,18 @@ +// +// MockRemoteImageURLDataPublisher.swift +// RemoteImageTests +// +// Created by Christian Elies on 15.12.19. +// + +import Combine +import Foundation +@testable import RemoteImage + +final class MockRemoteImageURLDataPublisher: RemoteImageURLDataPublisher { + var publisher = PassthroughSubject<(data: Data, response: URLResponse), URLError>() + + func dataPublisher(for request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { + publisher.eraseToAnyPublisher() + } +} diff --git a/Tests/RemoteImageTests/Models/PhotoKitServiceErrorTests.swift b/Tests/RemoteImageTests/Models/PhotoKitServiceErrorTests.swift new file mode 100644 index 0000000..2263384 --- /dev/null +++ b/Tests/RemoteImageTests/Models/PhotoKitServiceErrorTests.swift @@ -0,0 +1,64 @@ +// +// PhotoKitServiceErrorTests.swift +// RemoteImageTests +// +// Created by Christian Elies on 14.12.19. +// + +@testable import RemoteImage +import XCTest + +final class PhotoKitServiceErrorTests: XCTestCase { + let localIdentifier = "TestIdentifier" + + func testMissingDataErrorDescription() { + let error: PhotoKitServiceError = .missingData + let expectedErrorDescription = "The asset could not be loaded." + XCTAssertEqual(error.errorDescription, expectedErrorDescription) + } + + func testMissingDataFailureReason() { + let error: PhotoKitServiceError = .missingData + let expectedFailureReason = "The asset data could not be fetched. Maybe you are not connected to the internet." + XCTAssertEqual(error.failureReason, expectedFailureReason) + } + + func testMissingDataRecoverySuggestion() { + let error: PhotoKitServiceError = .missingData + let expectedRecoverySuggestion = "Check your internet connection or try again later." + XCTAssertEqual(error.recoverySuggestion, expectedRecoverySuggestion) + } + + func testMissingDataErrorCode() { + let error: PhotoKitServiceError = .missingData + let expectedErrorCode: Int = 0 + XCTAssertEqual(error.errorCode, expectedErrorCode) + } + + func testPhAssetNotFoundErrorDescription() { + let error: PhotoKitServiceError = .phAssetNotFound(localIdentifier: localIdentifier) + let expectedErrorDescription = "A PHAsset with the identifier \(localIdentifier) was not found." + XCTAssertEqual(error.errorDescription, expectedErrorDescription) + } + + func testPhAssetNotFoundFailureReason() { + let error: PhotoKitServiceError = .phAssetNotFound(localIdentifier: localIdentifier) + let expectedFailureReason = "An asset with the identifier \(localIdentifier) doesn't exist anymore." + XCTAssertEqual(error.failureReason, expectedFailureReason) + } + + func testPhAssetNotFoundRecoverySuggestion() { + let error: PhotoKitServiceError = .phAssetNotFound(localIdentifier: localIdentifier) + XCTAssertNil(error.recoverySuggestion) + } + + func testPhAssetNotFoundErrorCode() { + let error: PhotoKitServiceError = .phAssetNotFound(localIdentifier: localIdentifier) + let expectedErrorCode: Int = 1 + XCTAssertEqual(error.errorCode, expectedErrorCode) + } + + func testErrorDomain() { + XCTAssertEqual(PhotoKitServiceError.errorDomain, String(describing: PhotoKitService.self)) + } +} diff --git a/Tests/RemoteImageTests/Models/RemoteImageServiceErrorTests.swift b/Tests/RemoteImageTests/Models/RemoteImageServiceErrorTests.swift new file mode 100644 index 0000000..e75dca5 --- /dev/null +++ b/Tests/RemoteImageTests/Models/RemoteImageServiceErrorTests.swift @@ -0,0 +1,17 @@ +// +// RemoteImageServiceErrorTests.swift +// RemoteImageTests +// +// Created by Christian Elies on 14.12.19. +// + +@testable import RemoteImage +import XCTest + +final class RemoteImageServiceErrorTests: XCTestCase { + func testCouldNotCreateImageDescription() { + let description = RemoteImageServiceError.couldNotCreateImage.errorDescription + let expectedDescription = "Could not create image from received data" + XCTAssertEqual(description, expectedDescription) + } +} diff --git a/Tests/RemoteImageTests/RemoteImageTests.swift b/Tests/RemoteImageTests/RemoteImageTests.swift deleted file mode 100644 index 3bb3712..0000000 --- a/Tests/RemoteImageTests/RemoteImageTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import XCTest -@testable import RemoteImage - -final class RemoteImageTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual("Hello, World!", "Hello, World!") - } - - static var allTests = [ - ("testExample", testExample), - ] -} diff --git a/Tests/RemoteImageTests/Services/DefaultRemoteImageCacheTests.swift b/Tests/RemoteImageTests/Services/DefaultRemoteImageCacheTests.swift new file mode 100644 index 0000000..5c6c243 --- /dev/null +++ b/Tests/RemoteImageTests/Services/DefaultRemoteImageCacheTests.swift @@ -0,0 +1,38 @@ +// +// DefaultRemoteImageCacheTests.swift +// RemoteImageTests +// +// Created by Christian Elies on 14.12.19. +// + +@testable import RemoteImage +import XCTest + +final class DefaultRemoteImageCacheTests: XCTestCase { + let remoteImageCache = DefaultRemoteImageCache() + + override func setUp() { + remoteImageCache.cache.removeAllObjects() + } + + func testSetImage() { + let key = "Test" as NSString + let image = PlatformSpecificImageType() + remoteImageCache.setObject(image, forKey: key) + XCTAssertEqual(remoteImageCache.object(forKey: key), image) + } + + func testRemoveImage() { + let key = "Test" as NSString + let image = PlatformSpecificImageType() + remoteImageCache.setObject(image, forKey: key) + XCTAssertEqual(remoteImageCache.object(forKey: key), image) + remoteImageCache.removeObject(forKey: key) + XCTAssertNil(remoteImageCache.object(forKey: key)) + } + + static var allTests = [ + ("testSetImage", testSetImage), + ("testRemoveImage", testRemoveImage) + ] +} diff --git a/Tests/RemoteImageTests/Services/PhotoKitServiceTests.swift b/Tests/RemoteImageTests/Services/PhotoKitServiceTests.swift new file mode 100644 index 0000000..bf1e1ec --- /dev/null +++ b/Tests/RemoteImageTests/Services/PhotoKitServiceTests.swift @@ -0,0 +1,90 @@ +// +// PhotoKitServiceTests.swift +// RemoteImageTests +// +// Created by Christian Elies on 15.12.19. +// + +@testable import RemoteImage +import Photos +import XCTest + +final class PhotoKitServiceTests: XCTestCase { + let imageManager = MockImageManager() + let asset = MockPHAsset() + let service = PhotoKitService() + let localIdentifier = "TestIdentifier" + + override func setUp() { + PhotoKitService.asset = MockPHAsset.self + PhotoKitService.imageManager = imageManager + + MockPHAsset.fetchResult.firstObjectToReturn = nil + imageManager.dataToReturn = nil + imageManager.infoToReturn = nil + } + + func testPhotoDataNotFound() { + let expectation = self.expectation(description: "PhotoDataResult") + var result: Result? + service.getPhotoData(localIdentifier: localIdentifier) { res in + result = res + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + + switch result { + case .failure(let error): + guard case PhotoKitServiceError.phAssetNotFound(localIdentifier) = error else { + XCTFail("Invalid error") + return + } + default: + XCTFail("Invalid photo data result") + } + } + + func testPhotoDataFailure() { + MockPHAsset.fetchResult.firstObjectToReturn = asset + imageManager.infoToReturn = [PHImageErrorKey: PhotoKitServiceError.missingData] + + let expectation = self.expectation(description: "PhotoDataResult") + var result: Result? + service.getPhotoData(localIdentifier: localIdentifier) { res in + result = res + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + + switch result { + case .failure(let error): + XCTAssertEqual(error as? PhotoKitServiceError, .missingData) + default: + XCTFail("Invalid photo data result") + } + } + + func testPhotoDataSuccess() { + let expectedData = Data() + MockPHAsset.fetchResult.firstObjectToReturn = asset + imageManager.dataToReturn = expectedData + + let expectation = self.expectation(description: "PhotoDataResult") + var result: Result? + service.getPhotoData(localIdentifier: localIdentifier) { res in + result = res + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + + switch result { + case .success(let data): + XCTAssertEqual(data, expectedData) + default: + XCTFail("Invalid photo data result") + } + } +} diff --git a/Tests/RemoteImageTests/Services/RemoteImageServiceDependenciesTests.swift b/Tests/RemoteImageTests/Services/RemoteImageServiceDependenciesTests.swift new file mode 100644 index 0000000..ff514e7 --- /dev/null +++ b/Tests/RemoteImageTests/Services/RemoteImageServiceDependenciesTests.swift @@ -0,0 +1,18 @@ +// +// RemoteImageServiceDependenciesTests.swift +// RemoteImageTests +// +// Created by Christian Elies on 15.12.19. +// + +import Foundation +@testable import RemoteImage +import XCTest + +final class RemoteImageServiceDependenciesTests: XCTestCase { + func testInitialization() { + let dependencies = RemoteImageServiceDependencies() + XCTAssertTrue(dependencies.photoKitService is PhotoKitService) + XCTAssertTrue(dependencies.remoteImageURLDataPublisher is URLSession) + } +} diff --git a/Tests/RemoteImageTests/Services/RemoteImageServiceFactoryTests.swift b/Tests/RemoteImageTests/Services/RemoteImageServiceFactoryTests.swift new file mode 100644 index 0000000..dd5a6fb --- /dev/null +++ b/Tests/RemoteImageTests/Services/RemoteImageServiceFactoryTests.swift @@ -0,0 +1,16 @@ +// +// RemoteImageServiceFactoryTests.swift +// RemoteImageTests +// +// Created by Christian Elies on 15.12.19. +// + +@testable import RemoteImage +import XCTest + +final class RemoteImageServiceFactoryTests: XCTestCase { + func testMakeRemoteImageService() { + let service = RemoteImageServiceFactory.makeRemoteImageService() + XCTAssertEqual(service.state, .loading) + } +} diff --git a/Tests/RemoteImageTests/Services/RemoteImageServiceTests.swift b/Tests/RemoteImageTests/Services/RemoteImageServiceTests.swift new file mode 100644 index 0000000..b8748c3 --- /dev/null +++ b/Tests/RemoteImageTests/Services/RemoteImageServiceTests.swift @@ -0,0 +1,285 @@ +// +// RemoteImageServiceTests.swift +// RemoteImageTests +// +// Created by Christian Elies on 15.12.19. +// + +import Combine +@testable import RemoteImage +import XCTest + +final class RemoteImageServiceTests: XCTestCase { + private var cancellable: AnyCancellable? + + let dependencies = MockRemoteImageServiceDependencies() + lazy var photoKitService = dependencies.photoKitService as? MockPhotoKitService + lazy var remoteImageURLDataPublisher = dependencies.remoteImageURLDataPublisher as? MockRemoteImageURLDataPublisher + lazy var service = RemoteImageService(dependencies: dependencies) + + override func setUp() { + RemoteImageService.cache.removeAllObjects() + photoKitService?.resultToReturn = .success(Data()) + } + + func testFetchImageURLSuccess() { + guard let url = URL(string: "https://www.google.de") else { + XCTFail("Could not create mock URL") + return + } + + guard let data = UIImage(systemName: "paperplane.fill")?.jpegData(compressionQuality: 1) else { + XCTFail("Could not create mock data") + return + } + + let expectation = self.expectation(description: "FetchImageURL") + let response = URLResponse() + let remoteImageType: RemoteImageType = .url(url) + service.fetchImage(ofType: remoteImageType) + + // publish mock data + remoteImageURLDataPublisher?.publisher.send((data: data, response: response)) + + var state: RemoteImageState? + cancellable = service.$state.sink { st in + guard case RemoteImageState.image = st else { + return + } + state = st + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + switch state { + case .image(let image): + XCTAssertNotNil(image.imageAsset) + default: + XCTFail("Invalid fetch image URL result") + } + } + + func testFetchImageURLFailure() { + guard let url = URL(string: "https://www.google.de") else { + XCTFail("Could not create mock URL") + return + } + + let data = Data() + + let expectation = self.expectation(description: "FetchImageURL") + let response = URLResponse() + let remoteImageType: RemoteImageType = .url(url) + service.fetchImage(ofType: remoteImageType) + + // publish mock data + remoteImageURLDataPublisher?.publisher.send((data: data, response: response)) + + var state: RemoteImageState? + cancellable = service.$state.sink { st in + guard case RemoteImageState.error = st else { + return + } + state = st + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + switch state { + case .error(let error): + XCTAssertEqual(error as? RemoteImageServiceError, .couldNotCreateImage) + default: + XCTFail("Invalid fetch image URL result") + } + } + + func testFetchImageURLFailureCompletion() { + guard let url = URL(string: "https://www.google.de") else { + XCTFail("Could not create mock URL") + return + } + + let expectation = self.expectation(description: "FetchImageURLState") + + let remoteImageType: RemoteImageType = .url(url) + service.fetchImage(ofType: remoteImageType) + + // publish completion + let expectedError = URLError(.cancelled) + remoteImageURLDataPublisher?.publisher.send(completion: .failure(expectedError)) + + var state: RemoteImageState? + cancellable = service.$state.sink { st in + guard case RemoteImageState.error = st else { + return + } + state = st + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + switch state { + case .error(let error): + XCTAssertEqual(error as? URLError, expectedError) + default: + XCTFail("Invalid fetch image URL completion") + } + } + + func testFetchImageURLCached() { + guard let url = URL(string: "https://www.google.de") else { + XCTFail("Could not create mock URL") + return + } + + guard let image = PlatformSpecificImageType(systemName: "paperplane.fill") else { + XCTFail("Could not create mock image") + return + } + + let cacheKey = url as NSURL + RemoteImageService.cache.setObject(image, forKey: cacheKey) + + let expectation = self.expectation(description: "FetchImageURLCached") + let remoteImageType: RemoteImageType = .url(url) + service.fetchImage(ofType: remoteImageType) + + var state: RemoteImageState? + cancellable = service.$state.sink { st in + guard case RemoteImageState.image = st else { + return + } + state = st + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + switch state { + case .image(let image): + XCTAssertNotNil(image.imageAsset) + default: + XCTFail("Invalid fetch image URL cached result") + } + } + + func testFetchPHAssetSuccess() { + guard let data = UIImage(systemName: "paperplane.fill")?.jpegData(compressionQuality: 1) else { + XCTFail("Could not create mock data") + return + } + + let expectation = self.expectation(description: "FetchPHAsset") + photoKitService?.resultToReturn = .success(data) + let localIdentifier = "TestIdentifier" + let remoteImageType: RemoteImageType = .phAsset(localIdentifier: localIdentifier) + service.fetchImage(ofType: remoteImageType) + + var state: RemoteImageState? + cancellable = service.$state.sink { st in + guard case RemoteImageState.image = st else { + return + } + state = st + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + switch state { + case .image(let image): + XCTAssertNotNil(image.imageAsset) + default: + XCTFail("Invalid fetch ph asset result") + } + } + + func testFetchPHAccessInvalidData() { + let expectation = self.expectation(description: "FetchPHAsset") + photoKitService?.resultToReturn = .success(Data()) + let localIdentifier = "TestIdentifier" + let remoteImageType: RemoteImageType = .phAsset(localIdentifier: localIdentifier) + service.fetchImage(ofType: remoteImageType) + + var state: RemoteImageState? + cancellable = service.$state.sink { st in + guard case RemoteImageState.error = st else { + return + } + state = st + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + switch state { + case .error(let error): + XCTAssertEqual(error as? RemoteImageServiceError, .couldNotCreateImage) + default: + XCTFail("Invalid fetch ph asset result") + } + } + + func testFetchPHAccessFailure() { + let expectation = self.expectation(description: "FetchPHAsset") + let expectedError: RemoteImageServiceError = .couldNotCreateImage + photoKitService?.resultToReturn = .failure(expectedError) + let localIdentifier = "TestIdentifier" + let remoteImageType: RemoteImageType = .phAsset(localIdentifier: localIdentifier) + service.fetchImage(ofType: remoteImageType) + + var state: RemoteImageState? + cancellable = service.$state.sink { st in + guard case RemoteImageState.error = st else { + return + } + state = st + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + switch state { + case .error(let error): + XCTAssertEqual(error as? RemoteImageServiceError, expectedError) + default: + XCTFail("Invalid fetch ph asset result") + } + } + + func testFetchPHAssetCached() { + guard let image = PlatformSpecificImageType(systemName: "paperplane.fill") else { + XCTFail("Could not create mock image") + return + } + + let localIdentifier = "TestIdentifier" + let cacheKey = localIdentifier as NSString + RemoteImageService.cache.setObject(image, forKey: cacheKey) + + let expectation = self.expectation(description: "FetchPHAssetCached") + let remoteImageType: RemoteImageType = .phAsset(localIdentifier: localIdentifier) + service.fetchImage(ofType: remoteImageType) + + var state: RemoteImageState? + cancellable = service.$state.sink { st in + guard case RemoteImageState.image = st else { + return + } + state = st + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + switch state { + case .image(let image): + XCTAssertNotNil(image.imageAsset) + default: + XCTFail("Invalid fetch ph asset cached result") + } + } +} diff --git a/Tests/RemoteImageTests/XCTestManifests.swift b/Tests/RemoteImageTests/XCTestManifests.swift index 764c802..d0ebf86 100644 --- a/Tests/RemoteImageTests/XCTestManifests.swift +++ b/Tests/RemoteImageTests/XCTestManifests.swift @@ -3,7 +3,7 @@ import XCTest #if !canImport(ObjectiveC) public func allTests() -> [XCTestCaseEntry] { return [ - testCase(RemoteImageTests.allTests), + testCase(DefaultRemoteImageCacheTests.allTests) ] } #endif