diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index f1b975b..518eefc 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -7,6 +7,22 @@ import Foundation import Combine import EssentialFeed +public extension HTTPClient { + typealias Publisher = AnyPublisher<(Data, HTTPURLResponse), Error> + + func getPublisher(url: URL) -> Publisher { + var task: HTTPClientTask? + + return Deferred { + Future { completion in + task = self.get(from: url, completion: completion) + } + } + .handleEvents(receiveCancel: { task?.cancel() }) + .eraseToAnyPublisher() + } +} + public extension FeedImageDataLoader { typealias Publisher = AnyPublisher @@ -37,7 +53,7 @@ private extension FeedImageDataCache { } } -public extension FeedLoader { +public extension LocalFeedLoader { typealias Publisher = AnyPublisher<[FeedImage], Error> func loadPublisher() -> Publisher { diff --git a/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift b/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift index 403b661..0b5e9f2 100644 --- a/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift +++ b/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift @@ -8,11 +8,11 @@ import EssentialFeed import EssentialFeediOS final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate { - private let feedLoader: () -> FeedLoader.Publisher + private let feedLoader: () -> AnyPublisher<[FeedImage], Error> private var cancellable: Cancellable? var presenter: FeedPresenter? - init(feedLoader: @escaping () -> FeedLoader.Publisher) { + init(feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>) { self.feedLoader = feedLoader } diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index 3141b8a..0dc27f1 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -12,7 +12,7 @@ public final class FeedUIComposer { private init() {} public static func feedComposedWith( - feedLoader: @escaping () -> FeedLoader.Publisher, + feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher ) -> FeedViewController { let presentationAdapter = FeedLoaderPresentationAdapter(feedLoader: feedLoader) diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 8a16d1b..aa90cda 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -26,9 +26,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { LocalFeedLoader(store: store, currentDate: Date.init) }() - private let url = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed/v1/feed")! - - private lazy var remoteFeedLoader = RemoteFeedLoader(url: url, client: httpClient) + private let remoteURL = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed/v1/feed")! convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { self.init() @@ -56,22 +54,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { localFeedLoader.validateCache { _ in } } - private func makeRemoteFeedLoaderWithLocalFallback() -> FeedLoader.Publisher { - return remoteFeedLoader - .loadPublisher() + private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<[FeedImage], Error> { + return httpClient + .getPublisher(url: remoteURL) + .tryMap(FeedItemsMapper.map) .caching(to: localFeedLoader) .fallback(to: localFeedLoader.loadPublisher) } private func makeLocalImageLoaderWithRemoteFallback(url: URL) -> FeedImageDataLoader.Publisher { - let remoteImageLoader = RemoteFeedImageDataLoader(client: httpClient) let localImageLoader = LocalFeedImageDataLoader(store: store) return localImageLoader .loadImageDataPublisher(from: url) - .fallback(to: { - remoteImageLoader - .loadImageDataPublisher(from: url) + .fallback(to: { [httpClient] in + httpClient + .getPublisher(url: url) + .tryMap(FeedImageDataMapper.map) .caching(to: localImageLoader, using: url) }) } diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift index 5d49ea0..05fc370 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift @@ -6,30 +6,33 @@ import Foundation import EssentialFeed import EssentialFeediOS +import Combine extension FeedUIIntegrationTests { - class LoaderSpy: FeedLoader, FeedImageDataLoader { + class LoaderSpy: FeedImageDataLoader { // MARK: - FeedLoader - private var feedRequests = [(FeedLoader.Result) -> Void]() + private var feedRequests = [PassthroughSubject<[FeedImage], Error>]() var loadFeedCallCount: Int { return feedRequests.count } - func load(completion: @escaping (FeedLoader.Result) -> Void) { - feedRequests.append(completion) + func loadPublisher() -> AnyPublisher<[FeedImage], Error> { + let publisher = PassthroughSubject<[FeedImage], Error>() + feedRequests.append(publisher) + return publisher.eraseToAnyPublisher() } func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { - feedRequests[index](.success(feed)) + feedRequests[index].send(feed) } func completeFeedLoadingWithError(at index: Int = 0) { let error = NSError(domain: "an error", code: 404) - feedRequests[index](.failure(error)) + feedRequests[index].send(completion: .failure(error)) } // MARK: - FeedImageDataLoader diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 2bb0667..717b06b 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 5B034B332C9A804C00FB65F8 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B322C9A804100FB65F8 /* LocalFeedLoader.swift */; }; 5B034B352C9A819900FB65F8 /* FeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B342C9A819900FB65F8 /* FeedStore.swift */; }; - 5B034B372C9BD23300FB65F8 /* RemoteFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B362C9BD23300FB65F8 /* RemoteFeedItem.swift */; }; 5B034B392C9BD2C000FB65F8 /* LocalFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B382C9BD2C000FB65F8 /* LocalFeedImage.swift */; }; 5B034B3B2CA0B09F00FB65F8 /* LoadFeedFromCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B3A2CA0B09F00FB65F8 /* LoadFeedFromCacheUseCaseTests.swift */; }; 5B034B3F2CA0BAA500FB65F8 /* FeedStoreSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B3E2CA0BAA500FB65F8 /* FeedStoreSpy.swift */; }; @@ -23,7 +22,6 @@ 5B107E032BF5BB2100927709 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; }; 5B107E092BF5BB2100927709 /* EssentialFeed.h in Headers */ = {isa = PBXBuildFile; fileRef = 5B107DFB2BF5BB2100927709 /* EssentialFeed.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5B107E132BF5BB4200927709 /* FeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B107E122BF5BB4200927709 /* FeedImage.swift */; }; - 5B107E152BF5BF1400927709 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B107E142BF5BF1400927709 /* FeedLoader.swift */; }; 5B1C4F9B2C0556ED003F0429 /* URLSessionHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */; }; 5B1C4FC92C057236003F0429 /* EssentialFeedAPIEndToEndTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1C4FC82C057236003F0429 /* EssentialFeedAPIEndToEndTests.swift */; }; 5B1C4FCA2C057236003F0429 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; }; @@ -37,11 +35,14 @@ 5B6992AD2D012C7200DD47E9 /* FeedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992AC2D012C7200DD47E9 /* FeedViewController.swift */; }; 5B6992D72D03F23700DD47E9 /* FeedImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992D62D03F23700DD47E9 /* FeedImageCell.swift */; }; 5B6992D92D0662B200DD47E9 /* UIView+Shimmering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992D82D0662B200DD47E9 /* UIView+Shimmering.swift */; }; + 5B7349122D819FC7007F7D5D /* ImageCommentsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349112D819FC7007F7D5D /* ImageCommentsMapperTests.swift */; }; + 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */; }; + 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349182D824CC8007F7D5D /* ImageComment.swift */; }; + 5B7349282D829960007F7D5D /* FeedImageDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349272D829960007F7D5D /* FeedImageDataMapper.swift */; }; 5B74FD1A2D649D0E007478DC /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD192D649D0D007478DC /* ErrorView.swift */; }; 5B74FD1F2D64A821007478DC /* UIRefreshControl+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */; }; 5B74FD222D6A5F39007478DC /* FeedPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD212D6A5F39007478DC /* FeedPresenterTests.swift */; }; - 5B7AB8D72BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7AB8D62BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift */; }; - 5B7BDE022BFA81B70002E7F8 /* RemoteFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */; }; + 5B7AB8D72BF8BCE60034C68B /* FeedItemsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */; }; 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */; }; 5B8829042D6A7527006E0BD7 /* Feed.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */; }; 5B8829062D6A7A9A006E0BD7 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */; }; @@ -51,10 +52,8 @@ 5B88290D2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B88290C2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift */; }; 5B88290F2D6A94C3006E0BD7 /* FeedImagePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B88290E2D6A94C3006E0BD7 /* FeedImagePresenter.swift */; }; 5B8829112D6A964F006E0BD7 /* FeedImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829102D6A964F006E0BD7 /* FeedImageViewModel.swift */; }; - 5B8829132D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829122D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift */; }; + 5B8829132D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829122D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift */; }; 5B8829142D6BAE59006E0BD7 /* FeedImageDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992FA2D09353200DD47E9 /* FeedImageDataLoader.swift */; }; - 5B8829172D6BCD50006E0BD7 /* HTTPClientSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829162D6BCD50006E0BD7 /* HTTPClientSpy.swift */; }; - 5B8829192D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829182D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift */; }; 5B88291C2D6BD6C6006E0BD7 /* HTTPURLResponse+StatusCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B88291B2D6BD6C6006E0BD7 /* HTTPURLResponse+StatusCode.swift */; }; 5B88291E2D6BD7BE006E0BD7 /* URLProtocolStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B88291D2D6BD7BE006E0BD7 /* URLProtocolStub.swift */; }; 5B8829202D6BE22C006E0BD7 /* LoadFeedImageDataFromCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B88291F2D6BE22C006E0BD7 /* LoadFeedImageDataFromCacheUseCaseTests.swift */; }; @@ -153,7 +152,6 @@ /* Begin PBXFileReference section */ 5B034B322C9A804100FB65F8 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; 5B034B342C9A819900FB65F8 /* FeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStore.swift; sourceTree = ""; }; - 5B034B362C9BD23300FB65F8 /* RemoteFeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeedItem.swift; sourceTree = ""; }; 5B034B382C9BD2C000FB65F8 /* LocalFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedImage.swift; sourceTree = ""; }; 5B034B3A2CA0B09F00FB65F8 /* LoadFeedFromCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFeedFromCacheUseCaseTests.swift; sourceTree = ""; }; 5B034B3E2CA0BAA500FB65F8 /* FeedStoreSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpy.swift; sourceTree = ""; }; @@ -167,7 +165,6 @@ 5B107DFB2BF5BB2100927709 /* EssentialFeed.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EssentialFeed.h; sourceTree = ""; }; 5B107E022BF5BB2100927709 /* EssentialFeedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B107E122BF5BB4200927709 /* FeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImage.swift; sourceTree = ""; }; - 5B107E142BF5BF1400927709 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionHTTPClient.swift; sourceTree = ""; }; 5B1C4F9C2C056BB6003F0429 /* EssentialFeed.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = EssentialFeed.xctestplan; sourceTree = ""; }; 5B1C4FC62C057236003F0429 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -181,11 +178,14 @@ 5B6992D62D03F23700DD47E9 /* FeedImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageCell.swift; sourceTree = ""; }; 5B6992D82D0662B200DD47E9 /* UIView+Shimmering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Shimmering.swift"; sourceTree = ""; }; 5B6992FA2D09353200DD47E9 /* FeedImageDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataLoader.swift; sourceTree = ""; }; + 5B7349112D819FC7007F7D5D /* ImageCommentsMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsMapperTests.swift; sourceTree = ""; }; + 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsMapper.swift; sourceTree = ""; }; + 5B7349182D824CC8007F7D5D /* ImageComment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComment.swift; sourceTree = ""; }; + 5B7349272D829960007F7D5D /* FeedImageDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataMapper.swift; sourceTree = ""; }; 5B74FD192D649D0D007478DC /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIRefreshControl+Helpers.swift"; sourceTree = ""; }; 5B74FD212D6A5F39007478DC /* FeedPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresenterTests.swift; sourceTree = ""; }; - 5B7AB8D62BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFeedFromRemoteUseCaseTests.swift; sourceTree = ""; }; - 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeedLoader.swift; sourceTree = ""; }; + 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapperTests.swift; sourceTree = ""; }; 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feed.xcstrings; sourceTree = ""; }; 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresenter.swift; sourceTree = ""; }; 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = ""; }; @@ -194,9 +194,7 @@ 5B88290C2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImagePresenterTests.swift; sourceTree = ""; }; 5B88290E2D6A94C3006E0BD7 /* FeedImagePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImagePresenter.swift; sourceTree = ""; }; 5B8829102D6A964F006E0BD7 /* FeedImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageViewModel.swift; sourceTree = ""; }; - 5B8829122D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFeedImageDataFromRemoteUseCaseTests.swift; sourceTree = ""; }; - 5B8829162D6BCD50006E0BD7 /* HTTPClientSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClientSpy.swift; sourceTree = ""; }; - 5B8829182D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeedImageDataLoader.swift; sourceTree = ""; }; + 5B8829122D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataMapperTests.swift; sourceTree = ""; }; 5B88291B2D6BD6C6006E0BD7 /* HTTPURLResponse+StatusCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPURLResponse+StatusCode.swift"; sourceTree = ""; }; 5B88291D2D6BD7BE006E0BD7 /* URLProtocolStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocolStub.swift; sourceTree = ""; }; 5B88291F2D6BE22C006E0BD7 /* LoadFeedImageDataFromCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFeedImageDataFromCacheUseCaseTests.swift; sourceTree = ""; }; @@ -319,10 +317,8 @@ 5B0E220E2BFE404F009FC3EB /* Feed API */ = { isa = PBXGroup; children = ( - 5B8829152D6BCD43006E0BD7 /* Helpers */, - 5B7AB8D62BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift */, - 5B304EE92BFF582400AF431F /* URLSessionHTTPClientTests.swift */, - 5B8829122D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift */, + 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */, + 5B8829122D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift */, ); path = "Feed API"; sourceTree = ""; @@ -359,9 +355,13 @@ isa = PBXGroup; children = ( 5B107DFB2BF5BB2100927709 /* EssentialFeed.h */, - 5B034B312C9A801400FB65F8 /* Feed Cache */, - 5B7BDE002BFA81830002E7F8 /* Feed API */, + 5B73491A2D8255FF007F7D5D /* Shared API */, + 5B73491B2D825610007F7D5D /* Shared API Infra */, + 5B7349172D824BF6007F7D5D /* Image Comments Feature */, + 5B7349212D825864007F7D5D /* Image Comments API */, 5B107E172BF5C12800927709 /* Feed Feature */, + 5B7BDE002BFA81830002E7F8 /* Feed API */, + 5B034B312C9A801400FB65F8 /* Feed Cache */, 5B8829012D6A73D5006E0BD7 /* Feed Presentation */, ); path = EssentialFeed; @@ -372,8 +372,10 @@ children = ( 5B1C4F9C2C056BB6003F0429 /* EssentialFeed.xctestplan */, 5B8BB9972C02714D00D40D42 /* Helpers */, - 5B8BD18C2C37985A00CCA870 /* Feed Cache */, + 5B73491E2D825711007F7D5D /* Shared API Infra */, + 5B7349222D8258A5007F7D5D /* Image Comments API */, 5B0E220E2BFE404F009FC3EB /* Feed API */, + 5B8BD18C2C37985A00CCA870 /* Feed Cache */, 5B74FD202D6A5F07007478DC /* Feed Presentation */, ); path = EssentialFeedTests; @@ -383,7 +385,6 @@ isa = PBXGroup; children = ( 5B107E122BF5BB4200927709 /* FeedImage.swift */, - 5B107E142BF5BF1400927709 /* FeedLoader.swift */, 5BBDA00D2D6FCCF000D68DF0 /* FeedCache.swift */, 5B6992FA2D09353200DD47E9 /* FeedImageDataLoader.swift */, 5BBDA0192D6FF5F100D68DF0 /* FeedImageDataCache.swift */, @@ -456,6 +457,55 @@ path = "Feed UI"; sourceTree = ""; }; + 5B7349172D824BF6007F7D5D /* Image Comments Feature */ = { + isa = PBXGroup; + children = ( + 5B7349182D824CC8007F7D5D /* ImageComment.swift */, + ); + path = "Image Comments Feature"; + sourceTree = ""; + }; + 5B73491A2D8255FF007F7D5D /* Shared API */ = { + isa = PBXGroup; + children = ( + 5B0E220A2BFE2FEA009FC3EB /* HTTPClient.swift */, + ); + path = "Shared API"; + sourceTree = ""; + }; + 5B73491B2D825610007F7D5D /* Shared API Infra */ = { + isa = PBXGroup; + children = ( + 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */, + ); + path = "Shared API Infra"; + sourceTree = ""; + }; + 5B73491E2D825711007F7D5D /* Shared API Infra */ = { + isa = PBXGroup; + children = ( + 5B8829152D6BCD43006E0BD7 /* Helpers */, + 5B304EE92BFF582400AF431F /* URLSessionHTTPClientTests.swift */, + ); + path = "Shared API Infra"; + sourceTree = ""; + }; + 5B7349212D825864007F7D5D /* Image Comments API */ = { + isa = PBXGroup; + children = ( + 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */, + ); + path = "Image Comments API"; + sourceTree = ""; + }; + 5B7349222D8258A5007F7D5D /* Image Comments API */ = { + isa = PBXGroup; + children = ( + 5B7349112D819FC7007F7D5D /* ImageCommentsMapperTests.swift */, + ); + path = "Image Comments API"; + sourceTree = ""; + }; 5B74FD1B2D64A6DE007478DC /* Helpers */ = { isa = PBXGroup; children = ( @@ -482,12 +532,8 @@ isa = PBXGroup; children = ( 5B88291A2D6BD697006E0BD7 /* Helpers */, - 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */, 5B0E220C2BFE3135009FC3EB /* FeedItemsMapper.swift */, - 5B034B362C9BD23300FB65F8 /* RemoteFeedItem.swift */, - 5B0E220A2BFE2FEA009FC3EB /* HTTPClient.swift */, - 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */, - 5B8829182D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift */, + 5B7349272D829960007F7D5D /* FeedImageDataMapper.swift */, ); path = "Feed API"; sourceTree = ""; @@ -509,7 +555,6 @@ 5B8829152D6BCD43006E0BD7 /* Helpers */ = { isa = PBXGroup; children = ( - 5B8829162D6BCD50006E0BD7 /* HTTPClientSpy.swift */, 5B88291D2D6BD7BE006E0BD7 /* URLProtocolStub.swift */, ); path = Helpers; @@ -853,18 +898,17 @@ 5B8829222D6BEB4E006E0BD7 /* LocalFeedImageDataLoader.swift in Sources */, 5BC4F6CB2CDAF0B20002D4CF /* CoreDataHelpers.swift in Sources */, 5B0E220D2BFE3135009FC3EB /* FeedItemsMapper.swift in Sources */, - 5B107E152BF5BF1400927709 /* FeedLoader.swift in Sources */, - 5B034B372C9BD23300FB65F8 /* RemoteFeedItem.swift in Sources */, 5B8829112D6A964F006E0BD7 /* FeedImageViewModel.swift in Sources */, + 5B7349282D829960007F7D5D /* FeedImageDataMapper.swift in Sources */, 5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */, - 5B8829192D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift in Sources */, - 5B7BDE022BFA81B70002E7F8 /* RemoteFeedLoader.swift in Sources */, + 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */, 5BF9F30D2CDAD64700C8DB96 /* FeedStore.xcdatamodeld in Sources */, 5BDE3C672D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift in Sources */, 5B034B392C9BD2C000FB65F8 /* LocalFeedImage.swift in Sources */, 5B8829062D6A7A9A006E0BD7 /* FeedViewModel.swift in Sources */, 5B034B352C9A819900FB65F8 /* FeedStore.swift in Sources */, 5B034B332C9A804C00FB65F8 /* LocalFeedLoader.swift in Sources */, + 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */, 5B0E220B2BFE2FEA009FC3EB /* HTTPClient.swift in Sources */, 5B107E132BF5BB4200927709 /* FeedImage.swift in Sources */, 5BBDA01A2D6FF5F100D68DF0 /* FeedImageDataCache.swift in Sources */, @@ -892,15 +936,15 @@ 5B88291E2D6BD7BE006E0BD7 /* URLProtocolStub.swift in Sources */, 5B034B412CA371CB00FB65F8 /* ValidateFeedCacheUseCaseTests.swift in Sources */, 5B74FD222D6A5F39007478DC /* FeedPresenterTests.swift in Sources */, - 5B8829132D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift in Sources */, - 5B7AB8D72BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift in Sources */, + 5B8829132D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift in Sources */, + 5B7AB8D72BF8BCE60034C68B /* FeedItemsMapperTests.swift in Sources */, 5B88290D2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift in Sources */, 5BF9F3012CD9A10500C8DB96 /* XCTestCase+FailableInsertFeedStoreSpecs.swift in Sources */, 5B8829292D6BF226006E0BD7 /* CacheFeedImageDataUseCaseTests.swift in Sources */, 5B88290B2D6A8133006E0BD7 /* FeedLocalizationTests.swift in Sources */, 5B304EEA2BFF582400AF431F /* URLSessionHTTPClientTests.swift in Sources */, 5B8829202D6BE22C006E0BD7 /* LoadFeedImageDataFromCacheUseCaseTests.swift in Sources */, - 5B8829172D6BCD50006E0BD7 /* HTTPClientSpy.swift in Sources */, + 5B7349122D819FC7007F7D5D /* ImageCommentsMapperTests.swift in Sources */, 5B034B3F2CA0BAA500FB65F8 /* FeedStoreSpy.swift in Sources */, 5B034B452CA3A1A100FB65F8 /* SharedTestHelpers.swift in Sources */, 5B8BD18F2C3798D400CCA870 /* CacheFeedUseCaseTests.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed API/FeedImageDataMapper.swift b/EssentialFeed/EssentialFeed/Feed API/FeedImageDataMapper.swift new file mode 100644 index 0000000..1f93e33 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed API/FeedImageDataMapper.swift @@ -0,0 +1,20 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public final class FeedImageDataMapper { + public enum Error: Swift.Error { + case invalidData + } + + public static func map(_ data: Data, from response: HTTPURLResponse) throws -> Data { + guard response.isOK, !data.isEmpty else { + throw Error.invalidData + } + + return data + } +} diff --git a/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift b/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift index 8b2e830..84dbcc2 100644 --- a/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift +++ b/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift @@ -5,16 +5,31 @@ import Foundation -enum FeedItemsMapper { +public enum FeedItemsMapper { private struct Root: Decodable { - let items: [RemoteFeedItem] + private let items: [RemoteFeedItem] + + private struct RemoteFeedItem: Decodable { + let id: UUID + let description: String? + let location: String? + let image: URL + } + + var images: [FeedImage] { + items.map { FeedImage(id: $0.id, description: $0.description, location: $0.location, url: $0.image) } + } + } + + public enum Error: Swift.Error { + case invalidData } - static func map(_ data: Data, from response: HTTPURLResponse) throws -> [RemoteFeedItem] { + public static func map(_ data: Data, from response: HTTPURLResponse) throws -> [FeedImage] { guard response.isOK, let root = try? JSONDecoder().decode(Root.self, from: data) else { - throw RemoteFeedLoader.Error.invalidData + throw Error.invalidData } - return root.items + return root.images } } diff --git a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedImageDataLoader.swift b/EssentialFeed/EssentialFeed/Feed API/RemoteFeedImageDataLoader.swift deleted file mode 100644 index 7c8f783..0000000 --- a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedImageDataLoader.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import Foundation - -public final class RemoteFeedImageDataLoader: FeedImageDataLoader { - private let client: HTTPClient - - public init(client: HTTPClient) { - self.client = client - } - - public enum Error: Swift.Error { - case connectivity - case invalidData - } - - private final class HTTPClientTaskWrapper: FeedImageDataLoaderTask { - private var completion: ((FeedImageDataLoader.Result) -> Void)? - - var wrapped: HTTPClientTask? - - init(_ completion: @escaping (FeedImageDataLoader.Result) -> Void) { - self.completion = completion - } - - func complete(with result: FeedImageDataLoader.Result) { - completion?(result) - } - - func cancel() { - preventFurtherCompletions() - wrapped?.cancel() - } - - private func preventFurtherCompletions() { - completion = nil - } - } - - public func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask { - let task = HTTPClientTaskWrapper(completion) - task.wrapped = client.get(from: url) { [weak self] result in - guard self != nil else { return } - - task.complete(with: result - .mapError { _ in Error.connectivity } - .flatMap { (data, response) in - let isValidResponse = response.isOK && !data.isEmpty - return isValidResponse ? .success(data) : .failure(Error.invalidData) - }) - } - return task - } -} diff --git a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedItem.swift b/EssentialFeed/EssentialFeed/Feed API/RemoteFeedItem.swift deleted file mode 100644 index 71fd3d1..0000000 --- a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedItem.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2024 PortoCode. All Rights Reserved. -// - -import Foundation - -struct RemoteFeedItem: Decodable { - let id: UUID - let description: String? - let location: String? - let image: URL -} diff --git a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift deleted file mode 100644 index ff59790..0000000 --- a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2024 PortoCode. All Rights Reserved. -// - -import Foundation - -public final class RemoteFeedLoader: FeedLoader { - private let url: URL - private let client: HTTPClient - - public enum Error: Swift.Error { - case connectivity - case invalidData - } - - public typealias Result = FeedLoader.Result - - public init(url: URL, client: HTTPClient) { - self.url = url - self.client = client - } - - public func load(completion: @escaping (Result) -> Void) { - client.get(from: url) { [weak self] result in - guard self != nil else { return } - - switch result { - case let .success((data, response)): - completion(RemoteFeedLoader.map(data, from: response)) - case .failure: - completion(.failure(RemoteFeedLoader.Error.connectivity)) - } - } - } - - private static func map(_ data: Data, from response: HTTPURLResponse) -> Result { - do { - let items = try FeedItemsMapper.map(data, from: response) - return .success(items.toModels()) - } catch { - return .failure(error) - } - } -} - -private extension Array where Element == RemoteFeedItem { - func toModels() -> [FeedImage] { - return map { FeedImage(id: $0.id, description: $0.description, location: $0.location, url: $0.image) } - } -} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 8bfa5a5..593db5a 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -40,8 +40,8 @@ extension LocalFeedLoader: FeedCache { } } -extension LocalFeedLoader: FeedLoader { - public typealias LoadResult = FeedLoader.Result +extension LocalFeedLoader { + public typealias LoadResult = Swift.Result<[FeedImage], Error> public func load(completion: @escaping (LoadResult) -> Void) { store.retrieve { [weak self] result in diff --git a/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift deleted file mode 100644 index ebe6541..0000000 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2024 PortoCode. All Rights Reserved. -// - -import Foundation - -public protocol FeedLoader { - typealias Result = Swift.Result<[FeedImage], Error> - - func load(completion: @escaping (Result) -> Void) -} diff --git a/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift b/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift new file mode 100644 index 0000000..37819c7 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift @@ -0,0 +1,46 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public enum ImageCommentsMapper { + private struct Root: Decodable { + private let items: [Item] + + private struct Item: Decodable { + let id: UUID + let message: String + let created_at: Date + let author: Author + } + + private struct Author: Decodable { + let username: String + } + + var comments: [ImageComment] { + items.map { ImageComment(id: $0.id, message: $0.message, createdAt: $0.created_at, username: $0.author.username) } + } + } + + public enum Error: Swift.Error { + case invalidData + } + + public static func map(_ data: Data, from response: HTTPURLResponse) throws -> [ImageComment] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + guard isOK(response), let root = try? decoder.decode(Root.self, from: data) else { + throw Error.invalidData + } + + return root.comments + } + + private static func isOK(_ response: HTTPURLResponse) -> Bool { + (200...299).contains(response.statusCode) + } +} diff --git a/EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift b/EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift new file mode 100644 index 0000000..8bf255f --- /dev/null +++ b/EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift @@ -0,0 +1,20 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public struct ImageComment: Equatable { + public let id: UUID + public let message: String + public let createdAt: Date + public let username: String + + public init(id: UUID, message: String, createdAt: Date, username: String) { + self.id = id + self.message = message + self.createdAt = createdAt + self.username = username + } +} diff --git a/EssentialFeed/EssentialFeed/Feed API/URLSessionHTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift similarity index 100% rename from EssentialFeed/EssentialFeed/Feed API/URLSessionHTTPClient.swift rename to EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift diff --git a/EssentialFeed/EssentialFeed/Feed API/HTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift similarity index 100% rename from EssentialFeed/EssentialFeed/Feed API/HTTPClient.swift rename to EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index 87b3a36..e269914 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -44,15 +44,19 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { // MARK: - Helpers - private func getFeedResult(file: StaticString = #filePath, line: UInt = #line) -> FeedLoader.Result? { - let loader = RemoteFeedLoader(url: feedTestServerURL, client: ephemeralClient()) - trackForMemoryLeaks(loader, file: file, line: line) - + private func getFeedResult(file: StaticString = #filePath, line: UInt = #line) -> Swift.Result<[FeedImage], Error>? { + let client = ephemeralClient() let exp = expectation(description: "Wait for load completion") - var receivedResult: FeedLoader.Result? - loader.load { result in - receivedResult = result + var receivedResult: Swift.Result<[FeedImage], Error>? + client.get(from: feedTestServerURL) { result in + receivedResult = result.flatMap { (data, response) in + do { + return .success(try FeedItemsMapper.map(data, from: response)) + } catch { + return .failure(error) + } + } exp.fulfill() } wait(for: [exp], timeout: 5.0) @@ -61,15 +65,19 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { } private func getFeedImageDataResult(file: StaticString = #file, line: UInt = #line) -> FeedImageDataLoader.Result? { - let loader = RemoteFeedImageDataLoader(client: ephemeralClient()) - trackForMemoryLeaks(loader, file: file, line: line) - - let exp = expectation(description: "Wait for load completion") + 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? - _ = loader.loadImageData(from: url) { result in - receivedResult = result + client.get(from: url) { result in + receivedResult = result.flatMap { (data, response) in + do { + return .success(try FeedImageDataMapper.map(data, from: response)) + } catch { + return .failure(error) + } + } exp.fulfill() } wait(for: [exp], timeout: 5.0) diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift new file mode 100644 index 0000000..8ab5a91 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift @@ -0,0 +1,37 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class FeedImageDataMapperTests: XCTestCase { + + func test_map_throwsErrorOnNon200HTTPResponse() throws { + let samples = [199, 201, 300, 400, 500] + + try samples.forEach { code in + XCTAssertThrowsError( + try FeedImageDataMapper.map(anyData(), from: HTTPURLResponse(statusCode: code)) + ) + } + } + + func test_map_deliversInvalidDataErrorOn200HTTPResponseWithEmptyData() { + let emptyData = Data() + + XCTAssertThrowsError( + try FeedImageDataMapper.map(emptyData, from: HTTPURLResponse(statusCode: 200)) + ) + } + + func test_map_deliversReceivedNonEmptyDataOn200HTTPResponse() throws { + let nonEmptyData = Data("non-empty data".utf8) + + let result = try FeedImageDataMapper.map(nonEmptyData, from: HTTPURLResponse(statusCode: 200)) + + XCTAssertEqual(result, nonEmptyData) + } + +} diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift new file mode 100644 index 0000000..18507a7 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift @@ -0,0 +1,71 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class FeedItemsMapperTests: XCTestCase { + + func test_map_throwsErrorOnNon200HTTPResponse() throws { + let json = makeItemsJSON([]) + let samples = [199, 201, 300, 400, 500] + + try samples.forEach { code in + XCTAssertThrowsError( + try FeedItemsMapper.map(json, from: HTTPURLResponse(statusCode: code)) + ) + } + } + + func test_map_throwsErrorOn200HTTPResponseWithInvalidJSON() { + let invalidJSON = Data("invalid json".utf8) + + XCTAssertThrowsError( + try FeedItemsMapper.map(invalidJSON, from: HTTPURLResponse(statusCode: 200)) + ) + } + + func test_map_deliversNoItemsOn200HTTPResponseWithEmptyJSONList() throws { + let emptyListJSON = makeItemsJSON([]) + + let result = try FeedItemsMapper.map(emptyListJSON, from: HTTPURLResponse(statusCode: 200)) + + XCTAssertEqual(result, []) + } + + func test_map_deliversItemsOn200HTTPResponseWithJSONItems() throws { + let item1 = makeItem( + id: UUID(), + imageURL: URL(string: "http://a-url.com")!) + + let item2 = makeItem( + id: UUID(), + description: "a description", + location: "a location", + imageURL: URL(string: "http://another-url.com")!) + + let json = makeItemsJSON([item1.json, item2.json]) + + let result = try FeedItemsMapper.map(json, from: HTTPURLResponse(statusCode: 200)) + + XCTAssertEqual(result, [item1.model, item2.model]) + } + + // MARK: - Helpers + + private func makeItem(id: UUID, description: String? = nil, location: String? = nil, imageURL: URL) -> (model: FeedImage, json: [String: Any]) { + let item = FeedImage(id: id, description: description, location: location, url: imageURL) + + let json = [ + "id": item.id.uuidString, + "description": item.description, + "location": item.location, + "image": item.url.absoluteString + ].compactMapValues { $0 } + + return (item, json) + } + +} diff --git a/EssentialFeed/EssentialFeedTests/Feed API/Helpers/HTTPClientSpy.swift b/EssentialFeed/EssentialFeedTests/Feed API/Helpers/HTTPClientSpy.swift deleted file mode 100644 index 4d16034..0000000 --- a/EssentialFeed/EssentialFeedTests/Feed API/Helpers/HTTPClientSpy.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import Foundation -import EssentialFeed - -class HTTPClientSpy: HTTPClient { - private struct Task: HTTPClientTask { - let callback: () -> Void - - - func cancel() { callback() } - } - - private var messages = [(url: URL, completion: (HTTPClient.Result) -> Void)]() - private(set) var cancelledURLs = [URL]() - - var requestedURLs: [URL] { - return messages.map { $0.url } - } - - func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { - messages.append((url, completion)) - return Task { [weak self] in - self?.cancelledURLs.append(url) - } - } - - func complete(with error: Error, at index: Int = 0) { - messages[index].completion(.failure(error)) - } - - func complete(withStatusCode code: Int, data: Data, at index: Int = 0) { - let response = HTTPURLResponse( - url: requestedURLs[index], - statusCode: code, - httpVersion: nil, - headerFields: nil - )! - messages[index].completion(.success((data, response))) - } -} diff --git a/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift deleted file mode 100644 index b735672..0000000 --- a/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2024 PortoCode. All Rights Reserved. -// - -import XCTest -import EssentialFeed - -class LoadFeedFromRemoteUseCaseTests: XCTestCase { - - func test_init_doesNotRequestDataFromURL() { - let (_, client) = makeSUT() - - XCTAssertTrue(client.requestedURLs.isEmpty) - } - - func test_load_requestsDataFromURL() { - // given - let url = URL(string: "https://a-given-url.com")! - let (sut, client) = makeSUT(url: url) - - // when - sut.load { _ in } - - // then - XCTAssertEqual(client.requestedURLs, [url]) - } - - func test_loadTwice_requestsDataFromURLTwice() { - let url = URL(string: "https://a-given-url.com")! - let (sut, client) = makeSUT(url: url) - - sut.load { _ in } - sut.load { _ in } - - XCTAssertEqual(client.requestedURLs, [url, url]) - } - - func test_load_deliversErrorOnClientError() { - let (sut, client) = makeSUT() - - expect(sut, toCompleteWith: failure(.connectivity), when: { - let clientError = NSError(domain: "Test", code: 0) - client.complete(with: clientError) - }) - } - - func test_load_deliversErrorOnNon200HTTPResponse() { - let (sut, client) = makeSUT() - - let samples = [199, 201, 300, 400, 500] - - samples.enumerated().forEach { index, code in - expect(sut, toCompleteWith: failure(.invalidData), when: { - let json = makeItemsJSON([]) - client.complete(withStatusCode: code, data: json, at: index) - }) - } - } - - func test_load_deliversErrorOn200HTTPResponseWithInvalidJSON() { - let (sut, client) = makeSUT() - - expect(sut, toCompleteWith: failure(.invalidData), when: { - let invalidJSON = Data("invalid json".utf8) - client.complete(withStatusCode: 200, data: invalidJSON) - }) - } - - func test_load_deliversNoItemsOn200HTTPResponseWithEmptyJSONList() { - let (sut, client) = makeSUT() - - expect(sut, toCompleteWith: .success([]), when: { - let emptyListJSON = makeItemsJSON([]) - client.complete(withStatusCode: 200, data: emptyListJSON) - }) - } - - func test_load_deliversItemsOn200HTTPResponseWithJSONItems() { - let (sut, client) = makeSUT() - - let item1 = makeItem( - id: UUID(), - imageURL: URL(string: "http://a-url.com")!) - - let item2 = makeItem( - id: UUID(), - description: "a description", - location: "a location", - imageURL: URL(string: "http://b-url.com")!) - - let items = [item1.model, item2.model] - - expect(sut, toCompleteWith: .success(items), when: { - let json = makeItemsJSON([item1.json, item2.json]) - client.complete(withStatusCode: 200, data: json) - }) - } - - func test_load_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { - let url = URL(string: "http://any-url.com")! - let client = HTTPClientSpy() - var sut: RemoteFeedLoader? = RemoteFeedLoader(url: url, client: client) - - var capturedResults = [RemoteFeedLoader.Result]() - sut?.load { capturedResults.append($0) } - - sut = nil - client.complete(withStatusCode: 200, data: makeItemsJSON([])) - - XCTAssertTrue(capturedResults.isEmpty) - } - - // MARK: - Helpers - - private func makeSUT(url: URL = URL(string: "https://a-url.com")!, file: StaticString = #filePath, line: UInt = #line) -> (sut: RemoteFeedLoader, client: HTTPClientSpy) { - let client = HTTPClientSpy() - let sut = RemoteFeedLoader(url: url, client: client) - trackForMemoryLeaks(sut, file: file, line: line) - trackForMemoryLeaks(client, file: file, line: line) - return (sut, client) - } - - private func failure(_ error: RemoteFeedLoader.Error) -> RemoteFeedLoader.Result { - return .failure(error) - } - - private func makeItem(id: UUID, description: String? = nil, location: String? = nil, imageURL: URL) -> (model: FeedImage, json: [String: Any]) { - let item = FeedImage(id: id, description: description, location: location, url: imageURL) - - let json = [ - "id": item.id.uuidString, - "description": item.description, - "location": item.location, - "image": item.url.absoluteString - ].compactMapValues { $0 } - - return (item, json) - } - - private func makeItemsJSON(_ items: [[String: Any]]) -> Data { - let json = ["items": items] - return try! JSONSerialization.data(withJSONObject: json) - } - - private func expect(_ sut: RemoteFeedLoader, toCompleteWith expectedResult: RemoteFeedLoader.Result, 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(receivedItems), .success(expectedItems)): - XCTAssertEqual(receivedItems, expectedItems, file: file, line: line) - - case let (.failure(receivedError as RemoteFeedLoader.Error), .failure(expectedError as RemoteFeedLoader.Error)): - XCTAssertEqual(receivedError, expectedError, file: file, line: line) - - default: - XCTFail("Expected result \(expectedResult) got \(receivedResult) instead", file: file, line: line) - } - - exp.fulfill() - } - - action() - - wait(for: [exp], timeout: 1.0) - } - -} diff --git a/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedImageDataFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedImageDataFromRemoteUseCaseTests.swift deleted file mode 100644 index 22d6815..0000000 --- a/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedImageDataFromRemoteUseCaseTests.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import XCTest -import EssentialFeed - -class LoadFeedImageDataFromRemoteUseCaseTests: XCTestCase { - - func test_init_doesNotPerformAnyURLRequest() { - let (_, client) = makeSUT() - - XCTAssertTrue(client.requestedURLs.isEmpty) - } - - func test_loadImageDataFromURL_requestsDataFromURL() { - let url = URL(string: "https://a-given-url.com")! - let (sut, client) = makeSUT(url: url) - - _ = sut.loadImageData(from: url) { _ in } - - XCTAssertEqual(client.requestedURLs, [url]) - } - - func test_loadImageDataFromURLTwice_requestsDataFromURLTwice() { - let url = URL(string: "https://a-given-url.com")! - let (sut, client) = makeSUT(url: url) - - _ = sut.loadImageData(from: url) { _ in } - _ = sut.loadImageData(from: url) { _ in } - - XCTAssertEqual(client.requestedURLs, [url, url]) - } - - func test_loadImageDataFromURL_deliversConnectivityErrorOnClientError() { - let (sut, client) = makeSUT() - let clientError = NSError(domain: "a client error", code: 0) - - expect(sut, toCompleteWith: failure(.connectivity), when: { - client.complete(with: clientError) - }) - } - - func test_loadImageDataFromURL_deliversInvalidDataErrorOnNon200HTTPResponse() { - let (sut, client) = makeSUT() - - let samples = [199, 201, 300, 400, 500] - - samples.enumerated().forEach { index, code in - expect(sut, toCompleteWith: failure(.invalidData), when: { - client.complete(withStatusCode: code, data: anyData(), at: index) - }) - } - } - - func test_loadImageDataFromURL_deliversInvalidDataErrorOn200HTTPResponseWithEmptyData() { - let (sut, client) = makeSUT() - - expect(sut, toCompleteWith: failure(.invalidData), when: { - let emptyData = Data() - client.complete(withStatusCode: 200, data: emptyData) - }) - } - - func test_loadImageDataFromURL_deliversReceivedNonEmptyDataOn200HTTPResponse() { - let (sut, client) = makeSUT() - let nonEmptyData = Data("non-empty data".utf8) - - expect(sut, toCompleteWith: .success(nonEmptyData), when: { - client.complete(withStatusCode: 200, data: nonEmptyData) - }) - } - - func test_cancelLoadImageDataURLTask_cancelsClientURLRequest() { - let (sut, client) = makeSUT() - let url = URL(string: "https://a-given-url.com")! - - let task = sut.loadImageData(from: url) { _ in } - XCTAssertTrue(client.cancelledURLs.isEmpty, "Expected no cancelled URL request until task is cancelled") - - task.cancel() - XCTAssertEqual(client.cancelledURLs, [url], "Expected cancelled URL request after task is cancelled") - } - - func test_loadImageDataFromURL_doesNotDeliverResultAfterCancellingTask() { - let (sut, client) = makeSUT() - let nonEmptyData = Data("non-empty data".utf8) - - var received = [FeedImageDataLoader.Result]() - let task = sut.loadImageData(from: anyURL()) { received.append($0) } - task.cancel() - - client.complete(withStatusCode: 404, data: anyData()) - client.complete(withStatusCode: 200, data: nonEmptyData) - client.complete(with: anyNSError()) - - XCTAssertTrue(received.isEmpty, "Expected no received results after cancelling task") - } - - func test_loadImageDataFromURL_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { - let client = HTTPClientSpy() - var sut: RemoteFeedImageDataLoader? = RemoteFeedImageDataLoader(client: client) - - var capturedResults = [FeedImageDataLoader.Result]() - _ = sut?.loadImageData(from: anyURL()) { capturedResults.append($0) } - - sut = nil - client.complete(withStatusCode: 200, data: anyData()) - - XCTAssertTrue(capturedResults.isEmpty) - } - - // MARK: - Helpers - - private func makeSUT(url: URL = anyURL(), file: StaticString = #file, line: UInt = #line) -> (sut: RemoteFeedImageDataLoader, client: HTTPClientSpy) { - let client = HTTPClientSpy() - let sut = RemoteFeedImageDataLoader(client: client) - trackForMemoryLeaks(sut, file: file, line: line) - trackForMemoryLeaks(client, file: file, line: line) - return (sut, client) - } - - private func failure(_ error: RemoteFeedImageDataLoader.Error) -> FeedImageDataLoader.Result { - return .failure(error) - } - - private func expect(_ sut: RemoteFeedImageDataLoader, toCompleteWith expectedResult: FeedImageDataLoader.Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { - let url = URL(string: "https://a-given-url.com")! - let exp = expectation(description: "Wait for load completion") - - _ = sut.loadImageData(from: url) { receivedResult in - switch (receivedResult, expectedResult) { - case let (.success(receivedData), .success(expectedData)): - XCTAssertEqual(receivedData, expectedData, file: file, line: line) - - case let (.failure(receivedError as RemoteFeedImageDataLoader.Error), .failure(expectedError as RemoteFeedImageDataLoader.Error)): - XCTAssertEqual(receivedError, expectedError, 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", file: file, line: line) - } - - exp.fulfill() - } - - action() - - wait(for: [exp], timeout: 1.0) - } - -} diff --git a/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift index e9290d0..e3dd654 100644 --- a/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift +++ b/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift @@ -16,3 +16,14 @@ func anyURL() -> URL { func anyData() -> Data { return Data("any data".utf8) } + +func makeItemsJSON(_ items: [[String: Any]]) -> Data { + let json = ["items": items] + return try! JSONSerialization.data(withJSONObject: json) +} + +extension HTTPURLResponse { + convenience init(statusCode: Int) { + self.init(url: anyURL(), statusCode: statusCode, httpVersion: nil, headerFields: nil)! + } +} diff --git a/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift new file mode 100644 index 0000000..6e495f2 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift @@ -0,0 +1,84 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class ImageCommentsMapperTests: XCTestCase { + + func test_map_throwsErrorOnNon2xxHTTPResponse() throws { + let json = makeItemsJSON([]) + let samples = [199, 150, 300, 400, 500] + + try samples.forEach { code in + XCTAssertThrowsError( + try ImageCommentsMapper.map(json, from: HTTPURLResponse(statusCode: code)) + ) + } + } + + func test_map_throwsErrorOn2xxHTTPResponseWithInvalidJSON() throws { + let invalidJSON = Data("invalid json".utf8) + let samples = [200, 201, 250, 280, 299] + + try samples.forEach { code in + XCTAssertThrowsError( + try ImageCommentsMapper.map(invalidJSON, from: HTTPURLResponse(statusCode: code)) + ) + } + } + + func test_map_deliversNoItemsOn2xxHTTPResponseWithEmptyJSONList() throws { + let emptyListJSON = makeItemsJSON([]) + let samples = [200, 201, 250, 280, 299] + + try samples.forEach { code in + let result = try ImageCommentsMapper.map(emptyListJSON, from: HTTPURLResponse(statusCode: code)) + + XCTAssertEqual(result, []) + } + } + + func test_map_deliversItemsOn2xxHTTPResponseWithJSONItems() throws { + let item1 = makeItem( + id: UUID(), + message: "a message", + createdAt: (Date(timeIntervalSince1970: 1598627222), "2020-08-28T15:07:02+00:00"), + username: "a username") + + let item2 = makeItem( + id: UUID(), + message: "another message", + createdAt: (Date(timeIntervalSince1970: 1577881882), "2020-01-01T12:31:22+00:00"), + username: "another username") + + let json = makeItemsJSON([item1.json, item2.json]) + let samples = [200, 201, 250, 280, 299] + + try samples.forEach { code in + let result = try ImageCommentsMapper.map(json, from: HTTPURLResponse(statusCode: code)) + + XCTAssertEqual(result, [item1.model, item2.model]) + } + } + + // MARK: - Helpers + + private func makeItem(id: UUID, message: String, createdAt: (date: Date, iso8601String: String), username: String) -> (model: ImageComment, json: [String: Any]) { + let item = ImageComment(id: id, message: message, createdAt: createdAt.date, username: username) + + let json: [String: Any] = [ + "id": id.uuidString, + "message": message, + "created_at": createdAt.iso8601String, + "author": [ + "username": username + ] + ] + + return (item, json) + } + +} diff --git a/EssentialFeed/EssentialFeedTests/Feed API/Helpers/URLProtocolStub.swift b/EssentialFeed/EssentialFeedTests/Shared API Infra/Helpers/URLProtocolStub.swift similarity index 100% rename from EssentialFeed/EssentialFeedTests/Feed API/Helpers/URLProtocolStub.swift rename to EssentialFeed/EssentialFeedTests/Shared API Infra/Helpers/URLProtocolStub.swift diff --git a/EssentialFeed/EssentialFeedTests/Feed API/URLSessionHTTPClientTests.swift b/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift similarity index 100% rename from EssentialFeed/EssentialFeedTests/Feed API/URLSessionHTTPClientTests.swift rename to EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift