diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index ff872f1..c8d51d6 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -54,14 +54,13 @@ public extension FeedImageDataLoader { typealias Publisher = AnyPublisher func loadImageDataPublisher(from url: URL) -> Publisher { - var task: FeedImageDataLoaderTask? - return Deferred { Future { completion in - task = self.loadImageData(from: url, completion: completion) + completion(Result { + try self.loadImageData(from: url) + }) } } - .handleEvents(receiveCancel: { task?.cancel() }) .eraseToAnyPublisher() } } @@ -76,7 +75,7 @@ extension Publisher where Output == Data { private extension FeedImageDataCache { func saveIgnoringResult(_ data: Data, for url: URL) { - save(data, for: url) { _ in } + try? save(data, for: url) } } @@ -85,7 +84,9 @@ public extension LocalFeedLoader { func loadPublisher() -> Publisher { Deferred { - Future(self.load) + Future { completion in + completion(Result{ try self.load() }) + } } .eraseToAnyPublisher() } @@ -109,7 +110,7 @@ extension Publisher { private extension FeedCache { func saveIgnoringResult(_ feed: [FeedImage]) { - save(feed) { _ in } + try? save(feed) } func saveIgnoringResult(_ page: Paginated) { @@ -170,3 +171,49 @@ extension DispatchQueue { } } } + +typealias AnyDispatchQueueScheduler = AnyScheduler + +extension AnyDispatchQueueScheduler { + static var immediateOnMainQueue: Self { + DispatchQueue.immediateWhenOnMainQueueScheduler.eraseToAnyScheduler() + } +} + +extension Scheduler { + func eraseToAnyScheduler() -> AnyScheduler { + AnyScheduler(self) + } +} + +struct AnyScheduler: Scheduler where SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { + private let _now: () -> SchedulerTimeType + private let _minimumTolerance: () -> SchedulerTimeType.Stride + private let _schedule: (SchedulerOptions?, @escaping () -> Void) -> Void + private let _scheduleAfter: (SchedulerTimeType, SchedulerTimeType.Stride, SchedulerOptions?, @escaping () -> Void) -> Void + private let _scheduleAfterInterval: (SchedulerTimeType, SchedulerTimeType.Stride, SchedulerTimeType.Stride, SchedulerOptions?, @escaping () -> Void) -> Cancellable + + init(_ scheduler: S) where SchedulerTimeType == S.SchedulerTimeType, SchedulerOptions == S.SchedulerOptions, S: Scheduler { + _now = { scheduler.now } + _minimumTolerance = { scheduler.minimumTolerance } + _schedule = scheduler.schedule(options:_:) + _scheduleAfter = scheduler.schedule(after:tolerance:options:_:) + _scheduleAfterInterval = scheduler.schedule(after:interval:tolerance:options:_:) + } + + var now: SchedulerTimeType { _now() } + + var minimumTolerance: SchedulerTimeType.Stride { _minimumTolerance() } + + func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { + _schedule(options, action) + } + + func schedule(after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) { + _scheduleAfter(date, tolerance, options, action) + } + + func schedule(after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) -> Cancellable { + _scheduleAfterInterval(date, interval, tolerance, options, action) + } +} diff --git a/EssentialApp/EssentialApp/NullStore.swift b/EssentialApp/EssentialApp/NullStore.swift index 2db00b1..629d313 100644 --- a/EssentialApp/EssentialApp/NullStore.swift +++ b/EssentialApp/EssentialApp/NullStore.swift @@ -6,24 +6,18 @@ import Foundation import EssentialFeed -class NullStore: FeedStore & FeedImageDataStore { - func deleteCachedFeed(completion: @escaping DeletionCompletion) { - completion(.success(())) - } - - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - completion(.success(())) - } +class NullStore {} + +extension NullStore: FeedStore { + func deleteCachedFeed() throws {} - func retrieve(completion: @escaping RetrievalCompletion) { - completion(.success(.none)) - } + func insert(_ feed: [LocalFeedImage], timestamp: Date) throws {} - func insert(_ data: Data, for url: URL, completion: @escaping (InsertionResult) -> Void) { - completion(.success(())) - } + func retrieve() throws -> CachedFeed? { .none } +} + +extension NullStore: FeedImageDataStore { + func insert(_ data: Data, for url: URL) throws {} - func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { - completion(.success(.none)) - } + func retrieve(dataForURL url: URL) throws -> Data? { .none } } diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 406cf2f..6aad13c 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -12,6 +12,12 @@ import EssentialFeed class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + private lazy var scheduler: AnyDispatchQueueScheduler = DispatchQueue( + label: "portocode.infra.queue", + qos: .userInitiated, + attributes: .concurrent + ).eraseToAnyScheduler() + private lazy var httpClient: HTTPClient = { URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) }() @@ -43,10 +49,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { imageLoader: makeLocalImageLoaderWithRemoteFallback, selection: showComments)) - convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { + convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore, scheduler: AnyDispatchQueueScheduler) { self.init() self.httpClient = httpClient self.store = store + self.scheduler = scheduler } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { @@ -62,7 +69,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func sceneWillResignActive(_ scene: UIScene) { - localFeedLoader.validateCache { _ in } + scheduler.schedule { [localFeedLoader, logger] in + do { + try localFeedLoader.validateCache() + } catch { + logger.error("Failed to validate cache with error: \(error.localizedDescription)") + } + } } private func showComments(for image: FeedImage) { @@ -82,6 +95,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher, Error> { makeRemoteFeedLoader() + .receive(on: scheduler) .caching(to: localFeedLoader) .fallback(to: localFeedLoader.loadPublisher) .map(makeFirstPage) @@ -93,8 +107,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { .zip(makeRemoteFeedLoader(after: last)) .map { (cachedItems, newItems) in (cachedItems + newItems, newItems.last) - }.map(makePage) + } + .map(makePage) + .receive(on: scheduler) .caching(to: localFeedLoader) + .subscribe(on: scheduler) + .eraseToAnyPublisher() } private func makeRemoteFeedLoader(after: FeedImage? = nil) -> AnyPublisher<[FeedImage], Error> { @@ -121,11 +139,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return localImageLoader .loadImageDataPublisher(from: url) - .fallback(to: { [httpClient] in + .fallback(to: { [httpClient, scheduler] in httpClient .getPublisher(url: url) .tryMap(FeedImageDataMapper.map) + .receive(on: scheduler) .caching(to: localImageLoader, using: url) + .eraseToAnyPublisher() }) + .subscribe(on: scheduler) + .eraseToAnyPublisher() } } diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 1cad5c2..cd60660 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -87,7 +87,7 @@ class FeedAcceptanceTests: XCTestCase { httpClient: HTTPClientStub = .offline, store: InMemoryFeedStore = .empty ) -> ListViewController { - let sut = SceneDelegate(httpClient: httpClient, store: store) + let sut = SceneDelegate(httpClient: httpClient, store: store, scheduler: .immediateOnMainQueue) sut.window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 1)) sut.configureWindow() @@ -98,7 +98,7 @@ class FeedAcceptanceTests: XCTestCase { } private func enterBackground(with store: InMemoryFeedStore) { - let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store) + let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainQueue) sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) } diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift index 41de2a1..100c4e7 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift @@ -10,7 +10,7 @@ import Combine extension FeedUIIntegrationTests { - class LoaderSpy: FeedImageDataLoader { + class LoaderSpy { // MARK: - FeedLoader @@ -65,14 +65,7 @@ extension FeedUIIntegrationTests { // MARK: - FeedImageDataLoader - private struct TaskSpy: FeedImageDataLoaderTask { - let cancelCallback: () -> Void - func cancel() { - cancelCallback() - } - } - - private var imageRequests = [(url: URL, completion: (FeedImageDataLoader.Result) -> Void)]() + private var imageRequests = [(url: URL, publisher: PassthroughSubject)]() var loadedImageURLs: [URL] { return imageRequests.map { $0.url } @@ -80,18 +73,21 @@ extension FeedUIIntegrationTests { private(set) var cancelledImageURLs = [URL]() - func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask { - imageRequests.append((url, completion)) - return TaskSpy { [weak self] in self?.cancelledImageURLs.append(url) } + func loadImageDataPublisher(from url: URL) -> AnyPublisher { + let publisher = PassthroughSubject() + imageRequests.append((url, publisher)) + return publisher.handleEvents(receiveCancel: { [weak self] in + self?.cancelledImageURLs.append(url) + }).eraseToAnyPublisher() } func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) { - imageRequests[index].completion(.success(imageData)) + imageRequests[index].publisher.send(imageData) + imageRequests[index].publisher.send(completion: .finished) } func completeImageLoadingWithError(at index: Int = 0) { - let error = NSError(domain: "an error", code: 404) - imageRequests[index].completion(.failure(error)) + imageRequests[index].publisher.send(completion: .failure(anyNSError())) } } diff --git a/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift b/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift index a0e3446..99e6250 100644 --- a/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift +++ b/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift @@ -16,29 +16,26 @@ class InMemoryFeedStore { } extension InMemoryFeedStore: FeedStore { - func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { + func deleteCachedFeed() throws { feedCache = nil - completion(.success(())) } - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { + func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { feedCache = CachedFeed(feed: feed, timestamp: timestamp) - completion(.success(())) } - func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { - completion(.success(feedCache)) + func retrieve() throws -> CachedFeed? { + feedCache } } extension InMemoryFeedStore: FeedImageDataStore { - func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) { + func insert(_ data: Data, for url: URL) throws { feedImageDataCache[url] = data - completion(.success(())) } - func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { - completion(.success(feedImageDataCache[url])) + func retrieve(dataForURL url: URL) throws -> Data? { + feedImageDataCache[url] } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedImageDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedImageDataStore.swift index eb76cad..642c178 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedImageDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedImageDataStore.swift @@ -6,9 +6,6 @@ import Foundation public protocol FeedImageDataStore { - typealias RetrievalResult = Swift.Result - typealias InsertionResult = Swift.Result - - func insert(_ data: Data, for url: URL, completion: @escaping (InsertionResult) -> Void) - func retrieve(dataForURL url: URL, completion: @escaping (RetrievalResult) -> Void) + func insert(_ data: Data, for url: URL) throws + func retrieve(dataForURL url: URL) throws -> Data? } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index fde7f39..db51e4b 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -8,24 +8,7 @@ import Foundation public typealias CachedFeed = (feed: [LocalFeedImage], timestamp: Date) public protocol FeedStore { - typealias DeletionResult = Result - typealias DeletionCompletion = (DeletionResult) -> Void - - typealias InsertionResult = Result - typealias InsertionCompletion = (InsertionResult) -> Void - - typealias RetrievalResult = Result - typealias RetrievalCompletion = (RetrievalResult) -> Void - - /// The completion handler can be invoked in any thread. - /// Clients are responsible to dispatch to appropriate threads, if needed. - func deleteCachedFeed(completion: @escaping DeletionCompletion) - - /// The completion handler can be invoked in any thread. - /// Clients are responsible to dispatch to appropriate threads, if needed. - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) - - /// The completion handler can be invoked in any thread. - /// Clients are responsible to dispatch to appropriate threads, if needed. - func retrieve(completion: @escaping RetrievalCompletion) + func deleteCachedFeed() throws + func insert(_ feed: [LocalFeedImage], timestamp: Date) throws + func retrieve() throws -> CachedFeed? } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift index 1844930..032b52b 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift @@ -7,21 +7,21 @@ import Foundation extension CoreDataFeedStore: FeedImageDataStore { - public func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) { - perform { context in - completion(Result { + public func insert(_ data: Data, for url: URL) throws { + try performSync { context in + Result { try ManagedFeedImage.first(with: url, in: context) .map { $0.data = data } .map(context.save) - }) + } } } - public func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { - perform { context in - completion(Result { + public func retrieve(dataForURL url: URL) throws -> Data? { + try performSync { context in + Result { try ManagedFeedImage.data(with: url, in: context) - }) + } } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift index a05127f..7b53624 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift @@ -7,32 +7,32 @@ import CoreData extension CoreDataFeedStore: FeedStore { - public func retrieve(completion: @escaping RetrievalCompletion) { - perform { context in - completion(Result { + public func retrieve() throws -> CachedFeed? { + try performSync { context in + Result { try ManagedCache.find(in: context).map { CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp) } - }) + } } } - public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - perform { context in - completion(Result { + public func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { + try performSync { context in + Result { let managedCache = try ManagedCache.newUniqueInstance(in: context) managedCache.timestamp = timestamp managedCache.feed = ManagedFeedImage.images(from: feed, in: context) try context.save() - }) + } } } - public func deleteCachedFeed(completion: @escaping DeletionCompletion) { - perform { context in - completion(Result { + public func deleteCachedFeed() throws { + try performSync { context in + Result { try ManagedCache.deleteCache(in: context) - }) + } } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index a85f280..d44a3c8 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -30,9 +30,11 @@ public final class CoreDataFeedStore { } } - func perform(_ action: @escaping (NSManagedObjectContext) -> Void) { + func performSync(_ action: (NSManagedObjectContext) -> Result) throws -> R { let context = self.context - context.perform { action(context) } + var result: Result! + context.performAndWait { result = action(context) } + return try result.get() } private func cleanUpReferencesToPersistentStores() { diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImageDataLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImageDataLoader.swift index 631541a..6d978c9 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImageDataLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImageDataLoader.swift @@ -14,60 +14,34 @@ public final class LocalFeedImageDataLoader { } extension LocalFeedImageDataLoader: FeedImageDataCache { - public typealias SaveResult = FeedImageDataCache.Result - public enum SaveError: Error { case failed } - public func save(_ data: Data, for url: URL, completion: @escaping (SaveResult) -> Void) { - store.insert(data, for: url) { [weak self] result in - guard self != nil else { return } - - completion(result.mapError { _ in SaveError.failed }) + public func save(_ data: Data, for url: URL) throws { + do { + try store.insert(data, for: url) + } catch { + throw SaveError.failed } } } extension LocalFeedImageDataLoader: FeedImageDataLoader { - public typealias LoadResult = FeedImageDataLoader.Result - public enum LoadError: Swift.Error { case failed case notFound } - private final class LoadImageDataTask: FeedImageDataLoaderTask { - private var completion: ((FeedImageDataLoader.Result) -> Void)? - - init(_ completion: @escaping (FeedImageDataLoader.Result) -> Void) { - self.completion = completion + public func loadImageData(from url: URL) throws -> Data { + do { + if let imageData = try store.retrieve(dataForURL: url) { + return imageData + } + } catch { + throw LoadError.failed } - func complete(with result: FeedImageDataLoader.Result) { - completion?(result) - } - - func cancel() { - preventFurtherCompletions() - } - - private func preventFurtherCompletions() { - completion = nil - } - } - - public func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask { - let task = LoadImageDataTask(completion) - store.retrieve(dataForURL: url) { [weak self] result in - guard self != nil else { return } - - task.complete(with: result - .mapError { _ in LoadError.failed } - .flatMap { data in - data.map { .success($0) } ?? .failure(LoadError.notFound) - }) - } - return task + throw LoadError.notFound } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 593db5a..794ecd4 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -16,68 +16,31 @@ public final class LocalFeedLoader { } extension LocalFeedLoader: FeedCache { - public typealias SaveResult = FeedCache.Result - - public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { - store.deleteCachedFeed { [weak self] deletionResult in - guard let self = self else { return } - - switch deletionResult { - case .success: - self.cache(feed, with: completion) - case let .failure(error): - completion(.failure(error)) - } - } - } - - private func cache(_ feed: [FeedImage], with completion: @escaping (SaveResult) -> Void) { - store.insert(feed.toLocal(), timestamp: self.currentDate()) { [weak self] error in - guard self != nil else { return } - - completion(error) - } + public func save(_ feed: [FeedImage]) throws { + try store.deleteCachedFeed() + try store.insert(feed.toLocal(), timestamp: currentDate()) } } extension LocalFeedLoader { - public typealias LoadResult = Swift.Result<[FeedImage], Error> - - public func load(completion: @escaping (LoadResult) -> Void) { - store.retrieve { [weak self] result in - guard let self = self else { return } - - switch result { - case let .failure(error): - completion(.failure(error)) - - case let .success(.some(cache)) where FeedCachePolicy.validate(cache.timestamp, against: self.currentDate()): - completion(.success(cache.feed.toModels())) - - case .success: - completion(.success([])) - } + public func load() throws -> [FeedImage] { + if let cache = try store.retrieve(), FeedCachePolicy.validate(cache.timestamp, against: currentDate()) { + return cache.feed.toModels() } + return [] } } extension LocalFeedLoader { - public typealias ValidationResult = Result + private struct InvalidCache: Error {} - public func validateCache(completion: @escaping (ValidationResult) -> Void) { - store.retrieve { [weak self] result in - guard let self = self else { return } - - switch result { - case .failure: - self.store.deleteCachedFeed(completion: completion) - - case let .success(.some(cache)) where !FeedCachePolicy.validate(cache.timestamp, against: self.currentDate()): - self.store.deleteCachedFeed(completion: completion) - - case .success: - completion(.success(())) + public func validateCache() throws { + do { + if let cache = try store.retrieve(), !FeedCachePolicy.validate(cache.timestamp, against: currentDate()) { + throw InvalidCache() } + } catch { + try store.deleteCachedFeed() } } } diff --git a/EssentialFeed/EssentialFeed/Feed Feature/FeedCache.swift b/EssentialFeed/EssentialFeed/Feed Feature/FeedCache.swift index 0b52bf0..51f33cf 100644 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedCache.swift +++ b/EssentialFeed/EssentialFeed/Feed Feature/FeedCache.swift @@ -6,7 +6,5 @@ import Foundation public protocol FeedCache { - typealias Result = Swift.Result - - func save(_ feed: [FeedImage], completion: @escaping (Result) -> Void) + func save(_ feed: [FeedImage]) throws } diff --git a/EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataCache.swift b/EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataCache.swift index 4935331..9e27406 100644 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataCache.swift +++ b/EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataCache.swift @@ -6,7 +6,5 @@ import Foundation public protocol FeedImageDataCache { - typealias Result = Swift.Result - - func save(_ data: Data, for url: URL, completion: @escaping (Result) -> Void) + func save(_ data: Data, for url: URL) throws } diff --git a/EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataLoader.swift b/EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataLoader.swift index ed53bfe..e2b375e 100644 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataLoader.swift @@ -5,12 +5,6 @@ import Foundation -public protocol FeedImageDataLoaderTask { - func cancel() -} - public protocol FeedImageDataLoader { - typealias Result = Swift.Result - - func loadImageData(from url: URL, completion: @escaping (Result) -> Void) -> FeedImageDataLoaderTask + func loadImageData(from url: URL) throws -> Data } diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index e269914..dc02121 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -64,12 +64,12 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { return receivedResult } - private func getFeedImageDataResult(file: StaticString = #file, line: UInt = #line) -> FeedImageDataLoader.Result? { + private func getFeedImageDataResult(file: StaticString = #file, line: UInt = #line) -> Result? { let client = ephemeralClient() let url = feedTestServerURL.appendingPathComponent("73A7F70C-75DA-4C2E-B5A3-EED40DC53AA6/image") let exp = expectation(description: "Wait for load completion") - var receivedResult: FeedImageDataLoader.Result? + var receivedResult: Result? client.get(from: url) { result in receivedResult = result.flatMap { (data, response) in do { diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 59a749b..a438965 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -125,71 +125,45 @@ class EssentialFeedCacheIntegrationTests: XCTestCase { } private func save(_ feed: [FeedImage], with loader: LocalFeedLoader, file: StaticString = #file, line: UInt = #line) { - let saveExp = expectation(description: "Wait for save completion") - loader.save(feed) { result in - if case let Result.failure(error) = result { - XCTFail("Expected to save feed successfully, got error: \(error)", file: file, line: line) - } - saveExp.fulfill() + do { + try loader.save(feed) + } catch { + XCTFail("Expected to save feed successfully, got error: \(error)", file: file, line: line) } - wait(for: [saveExp], timeout: 1.0) } private func validateCache(with loader: LocalFeedLoader, file: StaticString = #file, line: UInt = #line) { - let saveExp = expectation(description: "Wait for save completion") - loader.validateCache() { result in - if case let Result.failure(error) = result { - XCTFail("Expected to validate feed successfully, got error: \(error)", file: file, line: line) - } - saveExp.fulfill() + do { + try loader.validateCache() + } catch { + XCTFail("Expected to validate feed successfully, got error: \(error)", file: file, line: line) } - wait(for: [saveExp], timeout: 1.0) } private func expect(_ sut: LocalFeedLoader, toLoad expectedFeed: [FeedImage], file: StaticString = #file, line: UInt = #line) { - let exp = expectation(description: "Wait for load completion") - sut.load { result in - switch result { - case let .success(loadedFeed): - XCTAssertEqual(loadedFeed, expectedFeed, file: file, line: line) - - case let .failure(error): - XCTFail("Expected successful feed result, got \(error) instead", file: file, line: line) - - @unknown default: - XCTFail("Unknown enum case") - } - - exp.fulfill() + do { + let loadedFeed = try sut.load() + XCTAssertEqual(loadedFeed, expectedFeed, file: file, line: line) + } catch { + XCTFail("Expected successful feed result, got \(error) instead", file: file, line: line) } - wait(for: [exp], timeout: 1.0) } private func save(_ data: Data, for url: URL, with loader: LocalFeedImageDataLoader, file: StaticString = #file, line: UInt = #line) { - let saveExp = expectation(description: "Wait for save completion") - loader.save(data, for: url) { result in - if case let Result.failure(error) = result { - XCTFail("Expected to save image data successfully, got error: \(error)", file: file, line: line) - } - saveExp.fulfill() + do { + try loader.save(data, for: url) + } catch { + XCTFail("Expected to save image data successfully, got error: \(error)", file: file, line: line) } - wait(for: [saveExp], timeout: 1.0) } private func expect(_ sut: LocalFeedImageDataLoader, toLoad expectedData: Data, for url: URL, file: StaticString = #file, line: UInt = #line) { - let exp = expectation(description: "Wait for load completion") - _ = sut.loadImageData(from: url) { result in - switch result { - case let .success(loadedData): - XCTAssertEqual(loadedData, expectedData, file: file, line: line) - - case let .failure(error): - XCTFail("Expected successful image data result, got \(error) instead", file: file, line: line) - } - - exp.fulfill() + do { + let loadedData = try sut.loadImageData(from: url) + XCTAssertEqual(loadedData, expectedData, file: file, line: line) + } catch { + XCTFail("Expected successful image data result, got \(error) instead", file: file, line: line) } - wait(for: [exp], timeout: 1.0) } private func setupEmptyStoreState() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift index 00fe85e..0574377 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift @@ -19,7 +19,7 @@ class CacheFeedImageDataUseCaseTests: XCTestCase { let url = anyURL() let data = anyData() - sut.save(data, for: url) { _ in } + try? sut.save(data, for: url) XCTAssertEqual(store.receivedMessages, [.insert(data: data, for: url)]) } @@ -41,19 +41,6 @@ class CacheFeedImageDataUseCaseTests: XCTestCase { }) } - func test_saveImageDataFromURL_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { - let store = FeedImageDataStoreSpy() - var sut: LocalFeedImageDataLoader? = LocalFeedImageDataLoader(store: store) - - var received = [LocalFeedImageDataLoader.SaveResult]() - sut?.save(anyData(), for: anyURL()) { received.append($0) } - - sut = nil - store.completeInsertionSuccessfully() - - XCTAssertTrue(received.isEmpty, "Expected no received results after instance has been deallocated") - } - // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: LocalFeedImageDataLoader, store: FeedImageDataStoreSpy) { @@ -64,31 +51,26 @@ class CacheFeedImageDataUseCaseTests: XCTestCase { return (sut, store) } - private func failed() -> LocalFeedImageDataLoader.SaveResult { + private func failed() -> Result { return .failure(LocalFeedImageDataLoader.SaveError.failed) } - private func expect(_ sut: LocalFeedImageDataLoader, toCompleteWith expectedResult: LocalFeedImageDataLoader.SaveResult, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { - let exp = expectation(description: "Wait for load completion") + private func expect(_ sut: LocalFeedImageDataLoader, toCompleteWith expectedResult: Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { + action() + + let receivedResult = Result { try sut.save(anyData(), for: anyURL()) } - sut.save(anyData(), for: anyURL()) { receivedResult in - switch (receivedResult, expectedResult) { - case (.success, .success): - break - - case (.failure(let receivedError as LocalFeedImageDataLoader.SaveError), - .failure(let expectedError as LocalFeedImageDataLoader.SaveError)): - XCTAssertEqual(receivedError, expectedError, file: file, line: line) - - default: - XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) - } + switch (receivedResult, expectedResult) { + case (.success, .success): + break + + case (.failure(let receivedError as LocalFeedImageDataLoader.SaveError), + .failure(let expectedError as LocalFeedImageDataLoader.SaveError)): + XCTAssertEqual(receivedError, expectedError, file: file, line: line) - exp.fulfill() + default: + XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) } - - action() - wait(for: [exp], timeout: 1.0) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index e41aab6..22d8012 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -14,21 +14,13 @@ class CacheFeedUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, []) } - func test_save_requestsCacheDeletion() { - let (sut, store) = makeSUT() - - sut.save(uniqueImageFeed().models) { _ in } - - XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) - } - func test_save_doesNotRequestCacheInsertionOnDeletionError() { let (sut, store) = makeSUT() let deletionError = anyNSError() - - sut.save(uniqueImageFeed().models) { _ in } store.completeDeletion(with: deletionError) + try? sut.save(uniqueImageFeed().models) + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) } @@ -36,10 +28,10 @@ class CacheFeedUseCaseTests: XCTestCase { let timestamp = Date() let feed = uniqueImageFeed() let (sut, store) = makeSUT(currentDate: { timestamp }) - - sut.save(feed.models) { _ in } store.completeDeletionSuccessfully() + try? sut.save(feed.models) + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(feed.local, timestamp)]) } @@ -71,33 +63,6 @@ class CacheFeedUseCaseTests: XCTestCase { }) } - func test_save_doesNotDeliverDeletionErrorAfterSUTInstanceHasBeenDeallocated() { - let store = FeedStoreSpy() - var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) - - var receivedResults = [LocalFeedLoader.SaveResult]() - sut?.save(uniqueImageFeed().models) { receivedResults.append($0) } - - sut = nil - store.completeDeletion(with: anyNSError()) - - XCTAssertTrue(receivedResults.isEmpty) - } - - func test_save_doesNotDeliverInsertionErrorAfterSUTInstanceHasBeenDeallocated() { - let store = FeedStoreSpy() - var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) - - var receivedResults = [LocalFeedLoader.SaveResult]() - sut?.save(uniqueImageFeed().models) { receivedResults.append($0) } - - store.completeDeletionSuccessfully() - sut = nil - store.completeInsertion(with: anyNSError()) - - XCTAssertTrue(receivedResults.isEmpty) - } - // MARK: - Helpers private func makeSUT(currentDate: @escaping () -> Date = Date.init, file: StaticString = #filePath, line: UInt = #line) -> (sut: LocalFeedLoader, store: FeedStoreSpy) { @@ -109,18 +74,17 @@ class CacheFeedUseCaseTests: XCTestCase { } private func expect(_ sut: LocalFeedLoader, toCompleteWithError expectedError: NSError?, when action: () -> Void, file: StaticString = #filePath, line: UInt = #line) { - let exp = expectation(description: "Wait for save completion") + action() - var receivedError: Error? - sut.save(uniqueImageFeed().models) { result in - if case let Result.failure(error) = result { receivedError = error } - exp.fulfill() - } + var receivedError: NSError? - action() - wait(for: [exp], timeout: 1.0) + do { + try sut.save(uniqueImageFeed().models) + } catch { + receivedError = error as NSError? + } - XCTAssertEqual(receivedError as NSError?, expectedError, file: file, line: line) + XCTAssertEqual(receivedError, expectedError, file: file, line: line) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift index c8d87ef..56798b2 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift @@ -46,22 +46,6 @@ class CoreDataFeedImageDataStoreTests: XCTestCase { expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: url) } - func test_sideEffects_runSerially() { - let sut = makeSUT() - let url = anyURL() - - let op1 = expectation(description: "Operation 1") - sut.insert([localImage(url: url)], timestamp: Date()) { _ in op1.fulfill() } - - let op2 = expectation(description: "Operation 2") - sut.insert(anyData(), for: url) { _ in op2.fulfill() } - - let op3 = expectation(description: "Operation 3") - sut.insert(anyData(), for: url) { _ in op3.fulfill() } - - wait(for: [op1, op2, op3], timeout: 5.0, enforceOrder: true) - } - // - MARK: Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> CoreDataFeedStore { @@ -71,11 +55,11 @@ class CoreDataFeedImageDataStoreTests: XCTestCase { return sut } - private func notFound() -> FeedImageDataStore.RetrievalResult { + private func notFound() -> Result { return .success(.none) } - private func found(_ data: Data) -> FeedImageDataStore.RetrievalResult { + private func found(_ data: Data) -> Result { return .success(data) } @@ -83,39 +67,26 @@ class CoreDataFeedImageDataStoreTests: XCTestCase { return LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) } - private func expect(_ sut: CoreDataFeedStore, toCompleteRetrievalWith expectedResult: FeedImageDataStore.RetrievalResult, for url: URL, file: StaticString = #file, line: UInt = #line) { - let exp = expectation(description: "Wait for load completion") - sut.retrieve(dataForURL: url) { receivedResult in - switch (receivedResult, expectedResult) { - case let (.success( receivedData), .success(expectedData)): - XCTAssertEqual(receivedData, expectedData, file: file, line: line) - - default: - XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) - } - exp.fulfill() + private func expect(_ sut: CoreDataFeedStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #file, line: UInt = #line) { + let receivedResult = Result { try sut.retrieve(dataForURL: url) } + + switch (receivedResult, expectedResult) { + case let (.success( receivedData), .success(expectedData)): + XCTAssertEqual(receivedData, expectedData, file: file, line: line) + + default: + XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) } - wait(for: [exp], timeout: 1.0) } private func insert(_ data: Data, for url: URL, into sut: CoreDataFeedStore, file: StaticString = #file, line: UInt = #line) { - let exp = expectation(description: "Wait for cache insertion") - let image = localImage(url: url) - sut.insert([image], timestamp: Date()) { result in - switch result { - case let .failure(error): - XCTFail("Failed to save \(image) with error \(error)", file: file, line: line) - exp.fulfill() - - case .success: - sut.insert(data, for: url) { result in - if case let Result.failure(error) = result { - XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) - } - exp.fulfill() - } - } + do { + let image = localImage(url: url) + try sut.insert([image], timestamp: Date()) + try sut.insert(data, for: url) + } catch { + XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) } - wait(for: [exp], timeout: 1.0) } + } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift index a48d110..cb094d8 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift @@ -74,11 +74,6 @@ class CoreDataFeedStoreTests: XCTestCase, FeedStoreSpecs { assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) } - func test_storeSideEffects_runSerially() throws { - let sut = try makeSUT() - - assertThatSideEffectsRunSerially(on: sut) - } // MARK: - Helpers diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/FeedStoreSpecs.swift index eb2352f..5dad022 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/FeedStoreSpecs.swift @@ -19,8 +19,6 @@ protocol FeedStoreSpecs { func test_delete_hasNoSideEffectsOnEmptyCache() throws func test_delete_deliversNoErrorOnNonEmptyCache() throws func test_delete_emptiesPreviouslyInsertedCache() throws - - func test_storeSideEffects_runSerially() throws } protocol FailableRetrieveFeedStoreSpecs: FeedStoreSpecs { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FeedStoreSpecs.swift index 5093c8e..55436b3 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FeedStoreSpecs.swift @@ -87,84 +87,48 @@ extension FeedStoreSpecs where Self: XCTestCase { expect(sut, toRetrieve: .success(.none), file: file, line: line) } - func assertThatSideEffectsRunSerially(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { - var completedOperationsInOrder = [XCTestExpectation]() - - let op1 = expectation(description: "Operation 1") - sut.insert(uniqueImageFeed().local, timestamp: Date()) { _ in - completedOperationsInOrder.append(op1) - op1.fulfill() - } - - let op2 = expectation(description: "Operation 2") - sut.deleteCachedFeed { _ in - completedOperationsInOrder.append(op2) - op2.fulfill() - } - - let op3 = expectation(description: "Operation 3") - sut.insert(uniqueImageFeed().local, timestamp: Date()) { _ in - completedOperationsInOrder.append(op3) - op3.fulfill() - } - - waitForExpectations(timeout: 5.0) - - XCTAssertEqual(completedOperationsInOrder, [op1, op2, op3], "Expected side-effects to run serially but operations finished in the wrong order", file: file, line: line) - } - } extension FeedStoreSpecs where Self: XCTestCase { @discardableResult func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: FeedStore) -> Error? { - let exp = expectation(description: "Wait for cache insertion") - var insertionError: Error? - sut.insert(cache.feed, timestamp: cache.timestamp) { result in - if case let Result.failure(error) = result { insertionError = error } - exp.fulfill() + do { + try sut.insert(cache.feed, timestamp: cache.timestamp) + return nil + } catch { + return error } - wait(for: [exp], timeout: 1.0) - return insertionError } @discardableResult func deleteCache(from sut: FeedStore) -> Error? { - let exp = expectation(description: "Wait for cache deletion") - var deletionError: Error? - sut.deleteCachedFeed { result in - if case let Result.failure(error) = result { deletionError = error } - exp.fulfill() + do { + try sut.deleteCachedFeed() + return nil + } catch { + return error } - wait(for: [exp], timeout: 1.0) - return deletionError } - func expect(_ sut: FeedStore, toRetrieveTwice expectedResult: FeedStore.RetrievalResult, file: StaticString = #file, line: UInt = #line) { + func expect(_ sut: FeedStore, toRetrieveTwice expectedResult: Result, file: StaticString = #file, line: UInt = #line) { expect(sut, toRetrieve: expectedResult, file: file, line: line) expect(sut, toRetrieve: expectedResult, file: file, line: line) } - func expect(_ sut: FeedStore, toRetrieve expectedResult: FeedStore.RetrievalResult, file: StaticString = #file, line: UInt = #line) { - let exp = expectation(description: "Wait for cache retrieval") - - sut.retrieve { retrievedResult in - switch (expectedResult, retrievedResult) { - case (.success(.none), .success(.none)), - (.failure, .failure): - break - - case let (.success(.some(expected)), .success(.some(retrieved))): - XCTAssertEqual(retrieved.feed, expected.feed, file: file, line: line) - XCTAssertEqual(retrieved.timestamp, expected.timestamp, file: file, line: line) - - default: - XCTFail("Expected to retrieve \(expectedResult), got \(retrievedResult) instead", file: file, line: line) - } + func expect(_ sut: FeedStore, toRetrieve expectedResult: Result, file: StaticString = #file, line: UInt = #line) { + let retrievedResult = Result { try sut.retrieve() } + + switch (expectedResult, retrievedResult) { + case (.success(.none), .success(.none)), + (.failure, .failure): + break + + case let (.success(.some(expected)), .success(.some(retrieved))): + XCTAssertEqual(retrieved.feed, expected.feed, file: file, line: line) + XCTAssertEqual(retrieved.timestamp, expected.timestamp, file: file, line: line) - exp.fulfill() + default: + XCTFail("Expected to retrieve \(expectedResult), got \(retrievedResult) instead", file: file, line: line) } - - wait(for: [exp], timeout: 1.0) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedImageDataStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedImageDataStoreSpy.swift index 9b9cb12..0f91d29 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedImageDataStoreSpy.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedImageDataStoreSpy.swift @@ -13,32 +13,32 @@ class FeedImageDataStoreSpy: FeedImageDataStore { } private(set) var receivedMessages = [Message]() - private var retrievalCompletions = [(FeedImageDataStore.RetrievalResult) -> Void]() - private var insertionCompletions = [(FeedImageDataStore.InsertionResult) -> Void]() + private var retrievalResult: Result? + private var insertionResult: Result? - func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) { + func insert(_ data: Data, for url: URL) throws { receivedMessages.append(.insert(data: data, for: url)) - insertionCompletions.append(completion) + try insertionResult?.get() } - func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { + func retrieve(dataForURL url: URL) throws -> Data? { receivedMessages.append(.retrieve(dataFor: url)) - retrievalCompletions.append(completion) + return try retrievalResult?.get() } - func completeRetrieval(with error: Error, at index: Int = 0) { - retrievalCompletions[index](.failure(error)) + func completeRetrieval(with error: Error) { + retrievalResult = .failure(error) } - func completeRetrieval(with data: Data?, at index: Int = 0) { - retrievalCompletions[index](.success(data)) + func completeRetrieval(with data: Data?) { + retrievalResult = .success(data) } - func completeInsertion(with error: Error, at index: Int = 0) { - insertionCompletions[index](.failure(error)) + func completeInsertion(with error: Error) { + insertionResult = .failure(error) } - func completeInsertionSuccessfully(at index: Int = 0) { - insertionCompletions[index](.success(())) + func completeInsertionSuccessfully() { + insertionResult = .success(()) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift index c00197f..b7edad2 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -15,50 +15,50 @@ class FeedStoreSpy: FeedStore { private(set) var receivedMessages = [ReceivedMessage]() - private var deletionCompletions = [DeletionCompletion]() - private var insertionCompletions = [InsertionCompletion]() - private var retrievalCompletions = [RetrievalCompletion]() + private var deletionResult: Result? + private var insertionResult: Result? + private var retrievalResult: Result? - func deleteCachedFeed(completion: @escaping DeletionCompletion) { - deletionCompletions.append(completion) + func deleteCachedFeed() throws { receivedMessages.append(.deleteCachedFeed) + try deletionResult?.get() } - func completeDeletion(with error: Error, at index: Int = 0) { - deletionCompletions[index](.failure(error)) + func completeDeletion(with error: Error) { + deletionResult = .failure(error) } - func completeDeletionSuccessfully(at index: Int = 0) { - deletionCompletions[index](.success(())) + func completeDeletionSuccessfully() { + deletionResult = .success(()) } - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - insertionCompletions.append(completion) + func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { receivedMessages.append(.insert(feed, timestamp)) + try insertionResult?.get() } - func completeInsertion(with error: Error, at index: Int = 0) { - insertionCompletions[index](.failure(error)) + func completeInsertion(with error: Error) { + insertionResult = .failure(error) } - func completeInsertionSuccessfully(at index: Int = 0) { - insertionCompletions[index](.success(())) + func completeInsertionSuccessfully() { + insertionResult = .success(()) } - func retrieve(completion: @escaping RetrievalCompletion) { - retrievalCompletions.append(completion) + func retrieve() throws -> CachedFeed? { receivedMessages.append(.retrieve) + return try retrievalResult?.get() } - func completeRetrieval(with error: Error, at index: Int = 0) { - retrievalCompletions[index](.failure(error)) + func completeRetrieval(with error: Error) { + retrievalResult = .failure(error) } - func completeRetrievalWithEmptyCache(at index: Int = 0) { - retrievalCompletions[index](.success(.none)) + func completeRetrievalWithEmptyCache() { + retrievalResult = .success(.none) } - func completeRetrieval(with feed: [LocalFeedImage], timestamp: Date, at index: Int = 0) { - retrievalCompletions[index](.success(CachedFeed(feed: feed, timestamp: timestamp))) + func completeRetrieval(with feed: [LocalFeedImage], timestamp: Date) { + retrievalResult = .success(CachedFeed(feed: feed, timestamp: timestamp)) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 0971ad2..fab03c7 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -17,7 +17,7 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { func test_load_requestsCacheRetrieval() { let (sut, store) = makeSUT() - sut.load() { _ in } + _ = try? sut.load() XCTAssertEqual(store.receivedMessages, [.retrieve]) } @@ -74,19 +74,19 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { func test_load_hasNoSideEffectsOnRetrievalError() { let (sut, store) = makeSUT() - - sut.load { _ in } store.completeRetrieval(with: anyNSError()) + _ = try? sut.load() + XCTAssertEqual(store.receivedMessages, [.retrieve]) } func test_load_hasNoSideEffectsOnEmptyCache() { let (sut, store) = makeSUT() - - sut.load { _ in } store.completeRetrievalWithEmptyCache() + _ = try? sut.load() + XCTAssertEqual(store.receivedMessages, [.retrieve]) } @@ -95,10 +95,10 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { let fixedCurrentDate = Date() let nonExpiredTimestamp = fixedCurrentDate.minusFeedCacheMaxAge().adding(seconds: 1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate } ) - - sut.load { _ in } store.completeRetrieval(with: feed.local, timestamp: nonExpiredTimestamp) + _ = try? sut.load() + XCTAssertEqual(store.receivedMessages, [.retrieve]) } @@ -107,10 +107,10 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { let fixedCurrentDate = Date() let expirationTimestamp = fixedCurrentDate.minusFeedCacheMaxAge() let (sut, store) = makeSUT(currentDate: { fixedCurrentDate } ) - - sut.load { _ in } store.completeRetrieval(with: feed.local, timestamp: expirationTimestamp) + _ = try? sut.load() + XCTAssertEqual(store.receivedMessages, [.retrieve]) } @@ -119,24 +119,11 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { let fixedCurrentDate = Date() let expiredTimestamp = fixedCurrentDate.minusFeedCacheMaxAge().adding(seconds: -1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate } ) - - sut.load { _ in } store.completeRetrieval(with: feed.local, timestamp: expiredTimestamp) - XCTAssertEqual(store.receivedMessages, [.retrieve]) - } - - func test_load_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { - let store = FeedStoreSpy() - var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) - - var receivedResults = [LocalFeedLoader.LoadResult]() - sut?.load { receivedResults.append($0) } - - sut = nil - store.completeRetrievalWithEmptyCache() + _ = try? sut.load() - XCTAssertTrue(receivedResults.isEmpty) + XCTAssertEqual(store.receivedMessages, [.retrieve]) } // MARK: - Helpers @@ -149,26 +136,21 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { return (sut, store) } - private func expect(_ sut: LocalFeedLoader, toCompletion expectedResult: LocalFeedLoader.LoadResult, when action: () -> Void, file: StaticString = #filePath, line: UInt = #line) { - let exp = expectation(description: "Wait for load completion") - - sut.load { receivedResult in - switch (receivedResult, expectedResult) { - case let (.success(receivedImages), .success(expectedImages)): - XCTAssertEqual(receivedImages, expectedImages, file: file, line: line) - - case let (.failure(receivedError as NSError), .failure(expectedError as NSError)): - XCTAssertEqual(receivedError, expectedError, file: file, line: line) - - default: - XCTFail("Expected result \(expectedResult), got \(receivedResult) instead") - } + private func expect(_ sut: LocalFeedLoader, toCompletion expectedResult: Result<[FeedImage], Error>, when action: () -> Void, file: StaticString = #filePath, line: UInt = #line) { + action() + + let receivedResult = Result { try sut.load() } + + switch (receivedResult, expectedResult) { + case let (.success(receivedImages), .success(expectedImages)): + XCTAssertEqual(receivedImages, expectedImages, file: file, line: line) + + case let (.failure(receivedError as NSError), .failure(expectedError as NSError)): + XCTAssertEqual(receivedError, expectedError, file: file, line: line) - exp.fulfill() + default: + XCTFail("Expected result \(expectedResult), got \(receivedResult) instead") } - - action() - wait(for: [exp], timeout: 1.0) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift index 61733e0..6cf4aea 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift @@ -18,7 +18,7 @@ class LoadFeedImageDataFromCacheUseCaseTests: XCTestCase { let (sut, store) = makeSUT() let url = anyURL() - _ = sut.loadImageData(from: url) { _ in } + _ = try? sut.loadImageData(from: url) XCTAssertEqual(store.receivedMessages, [.retrieve(dataFor: url)]) } @@ -49,34 +49,6 @@ class LoadFeedImageDataFromCacheUseCaseTests: XCTestCase { }) } - func test_loadImageDataFromURL_doesNotDeliverResultAfterCancellingTask() { - let (sut, store) = makeSUT() - let foundData = anyData() - - var received = [FeedImageDataLoader.Result]() - let task = sut.loadImageData(from: anyURL()) { received.append($0) } - task.cancel() - - store.completeRetrieval(with: foundData) - store.completeRetrieval(with: .none) - store.completeRetrieval(with: anyNSError()) - - XCTAssertTrue(received.isEmpty, "Expected no received results after cancelling task") - } - - func test_loadImageDataFromURL_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { - let store = FeedImageDataStoreSpy() - var sut: LocalFeedImageDataLoader? = LocalFeedImageDataLoader(store: store) - - var received = [FeedImageDataLoader.Result]() - _ = sut?.loadImageData(from: anyURL()) { received.append($0) } - - sut = nil - store.completeRetrieval(with: anyData()) - - XCTAssertTrue(received.isEmpty, "Expected no received results after instance has been deallocated") - } - // MARK: - Helpers private func makeSUT(currentDate: @escaping () -> Date = Date.init, file: StaticString = #file, line: UInt = #line) -> (sut: LocalFeedImageDataLoader, store: FeedImageDataStoreSpy) { @@ -87,35 +59,30 @@ class LoadFeedImageDataFromCacheUseCaseTests: XCTestCase { return (sut, store) } - private func failed() -> FeedImageDataLoader.Result { + private func failed() -> Result { return .failure(LocalFeedImageDataLoader.LoadError.failed) } - private func notFound() -> FeedImageDataLoader.Result { + private func notFound() -> Result { return .failure(LocalFeedImageDataLoader.LoadError.notFound) } - private func expect(_ sut: LocalFeedImageDataLoader, toCompleteWith expectedResult: FeedImageDataLoader.Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { - let exp = expectation(description: "Wait for load completion") + private func expect(_ sut: LocalFeedImageDataLoader, toCompleteWith expectedResult: Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { + action() - _ = sut.loadImageData(from: anyURL()) { receivedResult in - switch (receivedResult, expectedResult) { - case let (.success(receivedData), .success(expectedData)): - XCTAssertEqual(receivedData, expectedData, file: file, line: line) - - case (.failure(let receivedError as LocalFeedImageDataLoader.LoadError), - .failure(let expectedError as LocalFeedImageDataLoader.LoadError)): - XCTAssertEqual(receivedError, expectedError, file: file, line: line) - - default: - XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) - } + let receivedResult = Result { try sut.loadImageData(from: anyURL()) } + + switch (receivedResult, expectedResult) { + case let (.success(receivedData), .success(expectedData)): + XCTAssertEqual(receivedData, expectedData, file: file, line: line) + + case (.failure(let receivedError as LocalFeedImageDataLoader.LoadError), + .failure(let expectedError as LocalFeedImageDataLoader.LoadError)): + XCTAssertEqual(receivedError, expectedError, file: file, line: line) - exp.fulfill() + default: + XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) } - - action() - wait(for: [exp], timeout: 1.0) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift index 085affb..c81a4b3 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -16,19 +16,19 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { func test_validateCache_deletesCacheOnRetrievalError() { let (sut, store) = makeSUT() - - sut.validateCache { _ in } store.completeRetrieval(with: anyNSError()) + try? sut.validateCache() + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) } func test_validateCache_doesNotDeleteCacheOnEmptyCache() { let (sut, store) = makeSUT() - - sut.validateCache { _ in } store.completeRetrievalWithEmptyCache() + try? sut.validateCache() + XCTAssertEqual(store.receivedMessages, [.retrieve]) } @@ -37,10 +37,10 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { let fixedCurrentDate = Date() let nonExpiredTimestamp = fixedCurrentDate.minusFeedCacheMaxAge().adding(seconds: 1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate } ) - - sut.validateCache { _ in } store.completeRetrieval(with: feed.local, timestamp: nonExpiredTimestamp) + try? sut.validateCache() + XCTAssertEqual(store.receivedMessages, [.retrieve]) } @@ -49,10 +49,10 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { let fixedCurrentDate = Date() let expirationTimestamp = fixedCurrentDate.minusFeedCacheMaxAge() let (sut, store) = makeSUT(currentDate: { fixedCurrentDate } ) - - sut.validateCache { _ in } store.completeRetrieval(with: feed.local, timestamp: expirationTimestamp) + try? sut.validateCache() + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) } @@ -61,10 +61,10 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { let fixedCurrentDate = Date() let expiredTimestamp = fixedCurrentDate.minusFeedCacheMaxAge().adding(seconds: -1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate } ) - - sut.validateCache { _ in } store.completeRetrieval(with: feed.local, timestamp: expiredTimestamp) + try? sut.validateCache() + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) } @@ -131,18 +131,6 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { }) } - func test_validateCache_doesNotDeleteInvalidCacheAfterSUTInstanceHasBeenDeallocated() { - let store = FeedStoreSpy() - var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) - - sut?.validateCache{ _ in } - - sut = nil - store.completeRetrieval(with: anyNSError()) - - XCTAssertEqual(store.receivedMessages, [.retrieve]) - } - // MARK: - Helpers private func makeSUT(currentDate: @escaping () -> Date = Date.init, file: StaticString = #filePath, line: UInt = #line) -> (sut: LocalFeedLoader, store: FeedStoreSpy) { @@ -153,26 +141,21 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { return (sut, store) } - private func expect(_ sut: LocalFeedLoader, toCompleteWith expectedResult: LocalFeedLoader.ValidationResult, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { - let exp = expectation(description: "Wait for load completion") - - sut.validateCache { receivedResult in - switch (receivedResult, expectedResult) { - case (.success, .success): - break - - case let (.failure(receivedError as NSError), .failure(expectedError as NSError)): - XCTAssertEqual(receivedError, expectedError, file: file, line: line) - - default: - XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) - } + private func expect(_ sut: LocalFeedLoader, toCompleteWith expectedResult: Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { + action() + + let receivedResult = Result { try sut.validateCache() } + + switch (receivedResult, expectedResult) { + case (.success, .success): + break - exp.fulfill() + case let (.failure(receivedError as NSError), .failure(expectedError as NSError)): + XCTAssertEqual(receivedError, expectedError, file: file, line: line) + + default: + XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) } - - action() - wait(for: [exp], timeout: 1.0) } }