From 9c45e66b11bb4c97a60688edae6ffbbace83a71c Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 08:13:01 -0300 Subject: [PATCH 01/22] Duplicate RemoteFeedLoader as RemoteImageCommentsLoader --- .../EssentialFeed.xcodeproj/project.pbxproj | 12 ++ .../Feed API/ImageCommentsMapper.swift | 20 +++ .../Feed API/RemoteImageCommentsLoader.swift | 51 ++++++ ...dImageCommentsFromRemoteUseCaseTests.swift | 166 ++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift create mode 100644 EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift create mode 100644 EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 2bb0667..0fba7dc 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -37,6 +37,9 @@ 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 /* LoadImageCommentsFromRemoteUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349112D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift */; }; + 5B7349142D81A17E007F7D5D /* RemoteImageCommentsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */; }; + 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.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 */; }; @@ -181,6 +184,9 @@ 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 /* LoadImageCommentsFromRemoteUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadImageCommentsFromRemoteUseCaseTests.swift; sourceTree = ""; }; + 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageCommentsLoader.swift; sourceTree = ""; }; + 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsMapper.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 = ""; }; @@ -323,6 +329,7 @@ 5B7AB8D62BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift */, 5B304EE92BFF582400AF431F /* URLSessionHTTPClientTests.swift */, 5B8829122D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift */, + 5B7349112D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift */, ); path = "Feed API"; sourceTree = ""; @@ -485,6 +492,8 @@ 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */, 5B0E220C2BFE3135009FC3EB /* FeedItemsMapper.swift */, 5B034B362C9BD23300FB65F8 /* RemoteFeedItem.swift */, + 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */, + 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */, 5B0E220A2BFE2FEA009FC3EB /* HTTPClient.swift */, 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */, 5B8829182D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift */, @@ -856,6 +865,7 @@ 5B107E152BF5BF1400927709 /* FeedLoader.swift in Sources */, 5B034B372C9BD23300FB65F8 /* RemoteFeedItem.swift in Sources */, 5B8829112D6A964F006E0BD7 /* FeedImageViewModel.swift in Sources */, + 5B7349142D81A17E007F7D5D /* RemoteImageCommentsLoader.swift in Sources */, 5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */, 5B8829192D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift in Sources */, 5B7BDE022BFA81B70002E7F8 /* RemoteFeedLoader.swift in Sources */, @@ -865,6 +875,7 @@ 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 */, @@ -901,6 +912,7 @@ 5B304EEA2BFF582400AF431F /* URLSessionHTTPClientTests.swift in Sources */, 5B8829202D6BE22C006E0BD7 /* LoadFeedImageDataFromCacheUseCaseTests.swift in Sources */, 5B8829172D6BCD50006E0BD7 /* HTTPClientSpy.swift in Sources */, + 5B7349122D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.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/ImageCommentsMapper.swift b/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift new file mode 100644 index 0000000..bec7a9b --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift @@ -0,0 +1,20 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +enum ImageCommentsMapper { + private struct Root: Decodable { + let items: [RemoteFeedItem] + } + + static func map(_ data: Data, from response: HTTPURLResponse) throws -> [RemoteFeedItem] { + guard response.isOK, let root = try? JSONDecoder().decode(Root.self, from: data) else { + throw RemoteImageCommentsLoader.Error.invalidData + } + + return root.items + } +} diff --git a/EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift b/EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift new file mode 100644 index 0000000..e1c6c0f --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift @@ -0,0 +1,51 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public final class RemoteImageCommentsLoader: 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(RemoteImageCommentsLoader.map(data, from: response)) + case .failure: + completion(.failure(Error.connectivity)) + } + } + } + + private static func map(_ data: Data, from response: HTTPURLResponse) -> Result { + do { + let items = try ImageCommentsMapper.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/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift new file mode 100644 index 0000000..ee3349a --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift @@ -0,0 +1,166 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class LoadImageCommentsFromRemoteUseCaseTests: XCTestCase { + + func test_init_doesNotRequestDataFromURL() { + let (_, client) = makeSUT() + + XCTAssertTrue(client.requestedURLs.isEmpty) + } + + func test_load_requestsDataFromURL() { + let url = URL(string: "https://a-given-url.com")! + let (sut, client) = makeSUT(url: url) + + sut.load { _ in } + + 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: RemoteImageCommentsLoader? = RemoteImageCommentsLoader(url: url, client: client) + + var capturedResults = [RemoteImageCommentsLoader.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: RemoteImageCommentsLoader, client: HTTPClientSpy) { + let client = HTTPClientSpy() + let sut = RemoteImageCommentsLoader(url: url, client: client) + trackForMemoryLeaks(sut, file: file, line: line) + trackForMemoryLeaks(client, file: file, line: line) + return (sut, client) + } + + private func failure(_ error: RemoteImageCommentsLoader.Error) -> RemoteImageCommentsLoader.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: RemoteImageCommentsLoader, toCompleteWith expectedResult: RemoteImageCommentsLoader.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 RemoteImageCommentsLoader.Error), .failure(expectedError as RemoteImageCommentsLoader.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) + } + +} From c44604c6d159b59bc4d47685a2a52d19f96fad96 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 20:07:21 -0300 Subject: [PATCH 02/22] Delivers proper results on 2xx response --- .../Feed API/ImageCommentsMapper.swift | 6 ++- ...dImageCommentsFromRemoteUseCaseTests.swift | 46 ++++++++++++------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift b/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift index bec7a9b..5a35f91 100644 --- a/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift +++ b/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift @@ -11,10 +11,14 @@ enum ImageCommentsMapper { } static func map(_ data: Data, from response: HTTPURLResponse) throws -> [RemoteFeedItem] { - guard response.isOK, let root = try? JSONDecoder().decode(Root.self, from: data) else { + guard isOK(response), let root = try? JSONDecoder().decode(Root.self, from: data) else { throw RemoteImageCommentsLoader.Error.invalidData } return root.items } + + private static func isOK(_ response: HTTPURLResponse) -> Bool { + (200...299).contains(response.statusCode) + } } diff --git a/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift index ee3349a..8f22a93 100644 --- a/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift @@ -42,10 +42,10 @@ class LoadImageCommentsFromRemoteUseCaseTests: XCTestCase { }) } - func test_load_deliversErrorOnNon200HTTPResponse() { + func test_load_deliversErrorOnNon2xxHTTPResponse() { let (sut, client) = makeSUT() - let samples = [199, 201, 300, 400, 500] + let samples = [150, 199, 300, 400, 500] samples.enumerated().forEach { index, code in expect(sut, toCompleteWith: failure(.invalidData), when: { @@ -55,25 +55,33 @@ class LoadImageCommentsFromRemoteUseCaseTests: XCTestCase { } } - func test_load_deliversErrorOn200HTTPResponseWithInvalidJSON() { + func test_load_deliversErrorOn2xxHTTPResponseWithInvalidJSON() { let (sut, client) = makeSUT() - expect(sut, toCompleteWith: failure(.invalidData), when: { - let invalidJSON = Data("invalid json".utf8) - client.complete(withStatusCode: 200, data: invalidJSON) - }) + let samples = [200, 201, 250, 280, 299] + + samples.enumerated().forEach { index, code in + expect(sut, toCompleteWith: failure(.invalidData), when: { + let invalidJSON = Data("invalid json".utf8) + client.complete(withStatusCode: code, data: invalidJSON, at: index) + }) + } } - func test_load_deliversNoItemsOn200HTTPResponseWithEmptyJSONList() { + func test_load_deliversNoItemsOn2xxHTTPResponseWithEmptyJSONList() { let (sut, client) = makeSUT() - expect(sut, toCompleteWith: .success([]), when: { - let emptyListJSON = makeItemsJSON([]) - client.complete(withStatusCode: 200, data: emptyListJSON) - }) + let samples = [200, 201, 250, 280, 299] + + samples.enumerated().forEach { index, code in + expect(sut, toCompleteWith: .success([]), when: { + let emptyListJSON = makeItemsJSON([]) + client.complete(withStatusCode: code, data: emptyListJSON, at: index) + }) + } } - func test_load_deliversItemsOn200HTTPResponseWithJSONItems() { + func test_load_deliversItemsOn2xxHTTPResponseWithJSONItems() { let (sut, client) = makeSUT() let item1 = makeItem( @@ -88,10 +96,14 @@ class LoadImageCommentsFromRemoteUseCaseTests: XCTestCase { 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) - }) + let samples = [200, 201, 250, 280, 299] + + samples.enumerated().forEach { index, code in + expect(sut, toCompleteWith: .success(items), when: { + let json = makeItemsJSON([item1.json, item2.json]) + client.complete(withStatusCode: code, data: json, at: index) + }) + } } func test_load_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { From 7299a47b0773497ab7d38d1c79a42e910bc31283 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 20:16:56 -0300 Subject: [PATCH 03/22] Add ImageComment data model --- .../EssentialFeed.xcodeproj/project.pbxproj | 12 +++++++++++ .../Feed Comments Feature/ImageComment.swift | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 EssentialFeed/EssentialFeed/Feed Comments Feature/ImageComment.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 0fba7dc..5c6fff3 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 5B7349122D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349112D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift */; }; 5B7349142D81A17E007F7D5D /* RemoteImageCommentsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */; }; 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */; }; + 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349182D824CC8007F7D5D /* ImageComment.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 */; }; @@ -187,6 +188,7 @@ 5B7349112D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadImageCommentsFromRemoteUseCaseTests.swift; sourceTree = ""; }; 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageCommentsLoader.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 = ""; }; 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 = ""; }; @@ -369,6 +371,7 @@ 5B034B312C9A801400FB65F8 /* Feed Cache */, 5B7BDE002BFA81830002E7F8 /* Feed API */, 5B107E172BF5C12800927709 /* Feed Feature */, + 5B7349172D824BF6007F7D5D /* Feed Comments Feature */, 5B8829012D6A73D5006E0BD7 /* Feed Presentation */, ); path = EssentialFeed; @@ -463,6 +466,14 @@ path = "Feed UI"; sourceTree = ""; }; + 5B7349172D824BF6007F7D5D /* Feed Comments Feature */ = { + isa = PBXGroup; + children = ( + 5B7349182D824CC8007F7D5D /* ImageComment.swift */, + ); + path = "Feed Comments Feature"; + sourceTree = ""; + }; 5B74FD1B2D64A6DE007478DC /* Helpers */ = { isa = PBXGroup; children = ( @@ -868,6 +879,7 @@ 5B7349142D81A17E007F7D5D /* RemoteImageCommentsLoader.swift in Sources */, 5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */, 5B8829192D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift in Sources */, + 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */, 5B7BDE022BFA81B70002E7F8 /* RemoteFeedLoader.swift in Sources */, 5BF9F30D2CDAD64700C8DB96 /* FeedStore.xcdatamodeld in Sources */, 5BDE3C672D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed Comments Feature/ImageComment.swift b/EssentialFeed/EssentialFeed/Feed Comments Feature/ImageComment.swift new file mode 100644 index 0000000..8bf255f --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed 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 + } +} From c4169b0a63172186db4b103f41e9a0fed029939c Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 20:43:35 -0300 Subject: [PATCH 04/22] Implement ImageComment mapping --- .../Feed API/ImageCommentsMapper.swift | 26 +++++++++++++--- .../Feed API/RemoteImageCommentsLoader.swift | 12 ++------ ...dImageCommentsFromRemoteUseCaseTests.swift | 30 +++++++++++-------- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift b/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift index 5a35f91..9092387 100644 --- a/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift +++ b/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift @@ -7,15 +7,33 @@ import Foundation enum ImageCommentsMapper { private struct Root: Decodable { - let items: [RemoteFeedItem] + 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) } + } } - static func map(_ data: Data, from response: HTTPURLResponse) throws -> [RemoteFeedItem] { - guard isOK(response), let root = try? JSONDecoder().decode(Root.self, from: data) else { + 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 RemoteImageCommentsLoader.Error.invalidData } - return root.items + return root.comments } private static func isOK(_ response: HTTPURLResponse) -> Bool { diff --git a/EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift b/EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift index e1c6c0f..ae4067c 100644 --- a/EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift @@ -5,7 +5,7 @@ import Foundation -public final class RemoteImageCommentsLoader: FeedLoader { +public final class RemoteImageCommentsLoader { private let url: URL private let client: HTTPClient @@ -14,7 +14,7 @@ public final class RemoteImageCommentsLoader: FeedLoader { case invalidData } - public typealias Result = FeedLoader.Result + public typealias Result = Swift.Result<[ImageComment], Swift.Error> public init(url: URL, client: HTTPClient) { self.url = url @@ -37,15 +37,9 @@ public final class RemoteImageCommentsLoader: FeedLoader { private static func map(_ data: Data, from response: HTTPURLResponse) -> Result { do { let items = try ImageCommentsMapper.map(data, from: response) - return .success(items.toModels()) + return .success(items) } 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/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift index 8f22a93..e40942e 100644 --- a/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift @@ -86,13 +86,15 @@ class LoadImageCommentsFromRemoteUseCaseTests: XCTestCase { let item1 = makeItem( id: UUID(), - imageURL: URL(string: "http://a-url.com")!) + message: "a message", + createdAt: (Date(timeIntervalSince1970: 1598627222), "2020-08-28T15:07:02+00:00"), + username: "a username") let item2 = makeItem( id: UUID(), - description: "a description", - location: "a location", - imageURL: URL(string: "http://b-url.com")!) + message: "another message", + createdAt: (Date(timeIntervalSince1970: 1577881882), "2020-01-01T12:31:22+00:00"), + username: "another username") let items = [item1.model, item2.model] @@ -134,15 +136,17 @@ class LoadImageCommentsFromRemoteUseCaseTests: XCTestCase { 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 } + 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) } From 2053a1fb75128219184e50ad531d9eafa853395d Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 21:02:31 -0300 Subject: [PATCH 05/22] Move HTTPClient protocol and implementation to standalone folders representing modules --- .../EssentialFeed.xcodeproj/project.pbxproj | 58 ++++++++++++++++--- .../URLSessionHTTPClient.swift | 0 .../{Feed API => Shared API}/HTTPClient.swift | 0 .../Helpers/URLProtocolStub.swift | 0 .../URLSessionHTTPClientTests.swift | 0 .../Helpers/HTTPClientSpy.swift | 0 6 files changed, 49 insertions(+), 9 deletions(-) rename EssentialFeed/EssentialFeed/{Feed API => Shared API Infra}/URLSessionHTTPClient.swift (100%) rename EssentialFeed/EssentialFeed/{Feed API => Shared API}/HTTPClient.swift (100%) rename EssentialFeed/EssentialFeedTests/{Feed API => Shared API Infra}/Helpers/URLProtocolStub.swift (100%) rename EssentialFeed/EssentialFeedTests/{Feed API => Shared API Infra}/URLSessionHTTPClientTests.swift (100%) rename EssentialFeed/EssentialFeedTests/{Feed API => Shared API}/Helpers/HTTPClientSpy.swift (100%) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 5c6fff3..dd2162e 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -327,9 +327,7 @@ 5B0E220E2BFE404F009FC3EB /* Feed API */ = { isa = PBXGroup; children = ( - 5B8829152D6BCD43006E0BD7 /* Helpers */, 5B7AB8D62BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift */, - 5B304EE92BFF582400AF431F /* URLSessionHTTPClientTests.swift */, 5B8829122D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift */, 5B7349112D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift */, ); @@ -368,10 +366,12 @@ isa = PBXGroup; children = ( 5B107DFB2BF5BB2100927709 /* EssentialFeed.h */, - 5B034B312C9A801400FB65F8 /* Feed Cache */, - 5B7BDE002BFA81830002E7F8 /* Feed API */, - 5B107E172BF5C12800927709 /* Feed Feature */, + 5B73491A2D8255FF007F7D5D /* Shared API */, + 5B73491B2D825610007F7D5D /* Shared API Infra */, 5B7349172D824BF6007F7D5D /* Feed Comments Feature */, + 5B107E172BF5C12800927709 /* Feed Feature */, + 5B7BDE002BFA81830002E7F8 /* Feed API */, + 5B034B312C9A801400FB65F8 /* Feed Cache */, 5B8829012D6A73D5006E0BD7 /* Feed Presentation */, ); path = EssentialFeed; @@ -382,8 +382,10 @@ children = ( 5B1C4F9C2C056BB6003F0429 /* EssentialFeed.xctestplan */, 5B8BB9972C02714D00D40D42 /* Helpers */, - 5B8BD18C2C37985A00CCA870 /* Feed Cache */, + 5B73491C2D825693007F7D5D /* Shared API */, + 5B73491E2D825711007F7D5D /* Shared API Infra */, 5B0E220E2BFE404F009FC3EB /* Feed API */, + 5B8BD18C2C37985A00CCA870 /* Feed Cache */, 5B74FD202D6A5F07007478DC /* Feed Presentation */, ); path = EssentialFeedTests; @@ -474,6 +476,47 @@ path = "Feed 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 = ""; + }; + 5B73491C2D825693007F7D5D /* Shared API */ = { + isa = PBXGroup; + children = ( + 5B73491D2D8256BC007F7D5D /* Helpers */, + ); + path = "Shared API"; + sourceTree = ""; + }; + 5B73491D2D8256BC007F7D5D /* Helpers */ = { + isa = PBXGroup; + children = ( + 5B8829162D6BCD50006E0BD7 /* HTTPClientSpy.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 5B73491E2D825711007F7D5D /* Shared API Infra */ = { + isa = PBXGroup; + children = ( + 5B8829152D6BCD43006E0BD7 /* Helpers */, + 5B304EE92BFF582400AF431F /* URLSessionHTTPClientTests.swift */, + ); + path = "Shared API Infra"; + sourceTree = ""; + }; 5B74FD1B2D64A6DE007478DC /* Helpers */ = { isa = PBXGroup; children = ( @@ -505,8 +548,6 @@ 5B034B362C9BD23300FB65F8 /* RemoteFeedItem.swift */, 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */, 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */, - 5B0E220A2BFE2FEA009FC3EB /* HTTPClient.swift */, - 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */, 5B8829182D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift */, ); path = "Feed API"; @@ -529,7 +570,6 @@ 5B8829152D6BCD43006E0BD7 /* Helpers */ = { isa = PBXGroup; children = ( - 5B8829162D6BCD50006E0BD7 /* HTTPClientSpy.swift */, 5B88291D2D6BD7BE006E0BD7 /* URLProtocolStub.swift */, ); path = Helpers; 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/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 diff --git a/EssentialFeed/EssentialFeedTests/Feed API/Helpers/HTTPClientSpy.swift b/EssentialFeed/EssentialFeedTests/Shared API/Helpers/HTTPClientSpy.swift similarity index 100% rename from EssentialFeed/EssentialFeedTests/Feed API/Helpers/HTTPClientSpy.swift rename to EssentialFeed/EssentialFeedTests/Shared API/Helpers/HTTPClientSpy.swift From 1e2b9aff872c0e67f0b1e68a64bc6f3cc0ea7fc6 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 21:07:04 -0300 Subject: [PATCH 06/22] Move Image Comments API to standalone folders representing modules --- .../EssentialFeed.xcodeproj/project.pbxproj | 28 +++++++++++++++---- .../ImageCommentsMapper.swift | 0 .../RemoteImageCommentsLoader.swift | 0 .../ImageComment.swift | 0 ...dImageCommentsFromRemoteUseCaseTests.swift | 0 5 files changed, 22 insertions(+), 6 deletions(-) rename EssentialFeed/EssentialFeed/{Feed API => Image Comments API}/ImageCommentsMapper.swift (100%) rename EssentialFeed/EssentialFeed/{Feed API => Image Comments API}/RemoteImageCommentsLoader.swift (100%) rename EssentialFeed/EssentialFeed/{Feed Comments Feature => Image Comments Feature}/ImageComment.swift (100%) rename EssentialFeed/EssentialFeedTests/{Feed API => Image Comments API}/LoadImageCommentsFromRemoteUseCaseTests.swift (100%) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index dd2162e..e104dca 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -329,7 +329,6 @@ children = ( 5B7AB8D62BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift */, 5B8829122D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift */, - 5B7349112D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift */, ); path = "Feed API"; sourceTree = ""; @@ -368,7 +367,8 @@ 5B107DFB2BF5BB2100927709 /* EssentialFeed.h */, 5B73491A2D8255FF007F7D5D /* Shared API */, 5B73491B2D825610007F7D5D /* Shared API Infra */, - 5B7349172D824BF6007F7D5D /* Feed Comments Feature */, + 5B7349172D824BF6007F7D5D /* Image Comments Feature */, + 5B7349212D825864007F7D5D /* Image Comments API */, 5B107E172BF5C12800927709 /* Feed Feature */, 5B7BDE002BFA81830002E7F8 /* Feed API */, 5B034B312C9A801400FB65F8 /* Feed Cache */, @@ -384,6 +384,7 @@ 5B8BB9972C02714D00D40D42 /* Helpers */, 5B73491C2D825693007F7D5D /* Shared API */, 5B73491E2D825711007F7D5D /* Shared API Infra */, + 5B7349222D8258A5007F7D5D /* Image Comments API */, 5B0E220E2BFE404F009FC3EB /* Feed API */, 5B8BD18C2C37985A00CCA870 /* Feed Cache */, 5B74FD202D6A5F07007478DC /* Feed Presentation */, @@ -468,12 +469,12 @@ path = "Feed UI"; sourceTree = ""; }; - 5B7349172D824BF6007F7D5D /* Feed Comments Feature */ = { + 5B7349172D824BF6007F7D5D /* Image Comments Feature */ = { isa = PBXGroup; children = ( 5B7349182D824CC8007F7D5D /* ImageComment.swift */, ); - path = "Feed Comments Feature"; + path = "Image Comments Feature"; sourceTree = ""; }; 5B73491A2D8255FF007F7D5D /* Shared API */ = { @@ -517,6 +518,23 @@ path = "Shared API Infra"; sourceTree = ""; }; + 5B7349212D825864007F7D5D /* Image Comments API */ = { + isa = PBXGroup; + children = ( + 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */, + 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */, + ); + path = "Image Comments API"; + sourceTree = ""; + }; + 5B7349222D8258A5007F7D5D /* Image Comments API */ = { + isa = PBXGroup; + children = ( + 5B7349112D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift */, + ); + path = "Image Comments API"; + sourceTree = ""; + }; 5B74FD1B2D64A6DE007478DC /* Helpers */ = { isa = PBXGroup; children = ( @@ -546,8 +564,6 @@ 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */, 5B0E220C2BFE3135009FC3EB /* FeedItemsMapper.swift */, 5B034B362C9BD23300FB65F8 /* RemoteFeedItem.swift */, - 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */, - 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */, 5B8829182D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift */, ); path = "Feed API"; diff --git a/EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift b/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift similarity index 100% rename from EssentialFeed/EssentialFeed/Feed API/ImageCommentsMapper.swift rename to EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift diff --git a/EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift b/EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift similarity index 100% rename from EssentialFeed/EssentialFeed/Feed API/RemoteImageCommentsLoader.swift rename to EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift diff --git a/EssentialFeed/EssentialFeed/Feed Comments Feature/ImageComment.swift b/EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift similarity index 100% rename from EssentialFeed/EssentialFeed/Feed Comments Feature/ImageComment.swift rename to EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift diff --git a/EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift similarity index 100% rename from EssentialFeed/EssentialFeedTests/Feed API/LoadImageCommentsFromRemoteUseCaseTests.swift rename to EssentialFeed/EssentialFeedTests/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift From 94108e405f13a24160b890be0849f6cd86bdcdd4 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 21:19:56 -0300 Subject: [PATCH 07/22] Move FeedImage mapping to the FeedItemsMapper --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 ---- .../Feed API/FeedItemsMapper.swift | 17 ++++++++++++++--- .../EssentialFeed/Feed API/RemoteFeedItem.swift | 13 ------------- .../Feed API/RemoteFeedLoader.swift | 8 +------- 4 files changed, 15 insertions(+), 27 deletions(-) delete mode 100644 EssentialFeed/EssentialFeed/Feed API/RemoteFeedItem.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index e104dca..dc1bb86 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 */; }; @@ -157,7 +156,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 = ""; }; @@ -563,7 +561,6 @@ 5B88291A2D6BD697006E0BD7 /* Helpers */, 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */, 5B0E220C2BFE3135009FC3EB /* FeedItemsMapper.swift */, - 5B034B362C9BD23300FB65F8 /* RemoteFeedItem.swift */, 5B8829182D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift */, ); path = "Feed API"; @@ -930,7 +927,6 @@ 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 */, 5B7349142D81A17E007F7D5D /* RemoteImageCommentsLoader.swift in Sources */, 5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift b/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift index 8b2e830..c74547a 100644 --- a/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift +++ b/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift @@ -7,14 +7,25 @@ import Foundation 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) } + } } - static func map(_ data: Data, from response: HTTPURLResponse) throws -> [RemoteFeedItem] { + 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 } - return root.items + return root.images } } 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 index ff59790..7b39b9b 100644 --- a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift @@ -37,15 +37,9 @@ public final class RemoteFeedLoader: FeedLoader { private static func map(_ data: Data, from response: HTTPURLResponse) -> Result { do { let items = try FeedItemsMapper.map(data, from: response) - return .success(items.toModels()) + return .success(items) } 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) } - } -} From d802731cf4fc49461b68b94cec07f1d099aebe37 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 22:29:01 -0300 Subject: [PATCH 08/22] Duplicate RemoteFeedLoader as RemoteLoader --- .../EssentialFeed.xcodeproj/project.pbxproj | 8 + .../Shared API/RemoteLoader.swift | 46 +++++ .../Shared API/RemoteLoaderTests.swift | 166 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift create mode 100644 EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index dc1bb86..aaf5673 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -40,6 +40,8 @@ 5B7349142D81A17E007F7D5D /* RemoteImageCommentsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */; }; 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */; }; 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349182D824CC8007F7D5D /* ImageComment.swift */; }; + 5B7349242D826B60007F7D5D /* RemoteLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349232D826B60007F7D5D /* RemoteLoaderTests.swift */; }; + 5B7349262D826BFA007F7D5D /* RemoteLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349252D826BFA007F7D5D /* RemoteLoader.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 */; }; @@ -187,6 +189,8 @@ 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageCommentsLoader.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 = ""; }; + 5B7349232D826B60007F7D5D /* RemoteLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoaderTests.swift; sourceTree = ""; }; + 5B7349252D826BFA007F7D5D /* RemoteLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoader.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 = ""; }; @@ -479,6 +483,7 @@ isa = PBXGroup; children = ( 5B0E220A2BFE2FEA009FC3EB /* HTTPClient.swift */, + 5B7349252D826BFA007F7D5D /* RemoteLoader.swift */, ); path = "Shared API"; sourceTree = ""; @@ -495,6 +500,7 @@ isa = PBXGroup; children = ( 5B73491D2D8256BC007F7D5D /* Helpers */, + 5B7349232D826B60007F7D5D /* RemoteLoaderTests.swift */, ); path = "Shared API"; sourceTree = ""; @@ -947,6 +953,7 @@ 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */, 5B1C4F9B2C0556ED003F0429 /* URLSessionHTTPClient.swift in Sources */, 5B88291C2D6BD6C6006E0BD7 /* HTTPURLResponse+StatusCode.swift in Sources */, + 5B7349262D826BFA007F7D5D /* RemoteLoader.swift in Sources */, 5BC4F6CD2CDAF1B30002D4CF /* ManagedCache.swift in Sources */, 5BF9F30A2CDAD24D00C8DB96 /* CoreDataFeedStore.swift in Sources */, 5B88290A2D6A7B76006E0BD7 /* FeedErrorViewModel.swift in Sources */, @@ -985,6 +992,7 @@ 5BF9F3032CD9A1C600C8DB96 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift in Sources */, 5BF9F2FF2CD99FF300C8DB96 /* XCTestCase+FailableRetrieveFeedStoreSpecs.swift in Sources */, 5B034B3B2CA0B09F00FB65F8 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, + 5B7349242D826B60007F7D5D /* RemoteLoaderTests.swift in Sources */, 5BF9F2F92CD9961400C8DB96 /* FeedStoreSpecs.swift in Sources */, 5B8BB9992C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift in Sources */, 5BF9F3082CDAD1B600C8DB96 /* CoreDataFeedStoreTests.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift b/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift new file mode 100644 index 0000000..9999556 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift @@ -0,0 +1,46 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public final class RemoteLoader: 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(RemoteLoader.map(data, from: response)) + + case .failure: + completion(.failure(Error.connectivity)) + } + } + } + + private static func map(_ data: Data, from response: HTTPURLResponse) -> Result { + do { + let items = try FeedItemsMapper.map(data, from: response) + return .success(items) + } catch { + return .failure(Error.invalidData) + } + } +} diff --git a/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift b/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift new file mode 100644 index 0000000..0277c7f --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift @@ -0,0 +1,166 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class RemoteLoaderTests: XCTestCase { + + func test_init_doesNotRequestDataFromURL() { + let (_, client) = makeSUT() + + XCTAssertTrue(client.requestedURLs.isEmpty) + } + + func test_load_requestsDataFromURL() { + let url = URL(string: "https://a-given-url.com")! + let (sut, client) = makeSUT(url: url) + + sut.load { _ in } + + 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://another-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: RemoteLoader? = RemoteLoader(url: url, client: client) + + var capturedResults = [RemoteLoader.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 = #file, line: UInt = #line) -> (sut: RemoteLoader, client: HTTPClientSpy) { + let client = HTTPClientSpy() + let sut = RemoteLoader(url: url, client: client) + trackForMemoryLeaks(sut, file: file, line: line) + trackForMemoryLeaks(client, file: file, line: line) + return (sut, client) + } + + private func failure(_ error: RemoteLoader.Error) -> RemoteLoader.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": id.uuidString, + "description": description, + "location": location, + "image": imageURL.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: RemoteLoader, toCompleteWith expectedResult: RemoteLoader.Result, when action: () -> Void, file: StaticString = #file, 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 RemoteLoader.Error), .failure(expectedError as RemoteLoader.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) + } + +} From 2340e6ea713d2817840280f10c548efcc589fc04 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 22:55:12 -0300 Subject: [PATCH 09/22] Implement generic RemoteLoader --- .../Shared API/RemoteLoader.swift | 18 ++--- .../Shared API/RemoteLoaderTests.swift | 72 ++++++------------- 2 files changed, 33 insertions(+), 57 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift b/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift index 9999556..ca43773 100644 --- a/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift +++ b/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift @@ -5,29 +5,32 @@ import Foundation -public final class RemoteLoader: FeedLoader { +public final class RemoteLoader { private let url: URL private let client: HTTPClient + private let mapper: Mapper public enum Error: Swift.Error { case connectivity case invalidData } - public typealias Result = FeedLoader.Result + public typealias Result = Swift.Result + public typealias Mapper = (Data, HTTPURLResponse) throws -> Resource - public init(url: URL, client: HTTPClient) { + public init(url: URL, client: HTTPClient, mapper: @escaping Mapper) { self.url = url self.client = client + self.mapper = mapper } public func load(completion: @escaping (Result) -> Void) { client.get(from: url) { [weak self] result in - guard self != nil else { return } + guard let self = self else { return } switch result { case let .success((data, response)): - completion(RemoteLoader.map(data, from: response)) + completion(self.map(data, from: response)) case .failure: completion(.failure(Error.connectivity)) @@ -35,10 +38,9 @@ public final class RemoteLoader: FeedLoader { } } - private static func map(_ data: Data, from response: HTTPURLResponse) -> Result { + private func map(_ data: Data, from response: HTTPURLResponse) -> Result { do { - let items = try FeedItemsMapper.map(data, from: response) - return .success(items) + return .success(try mapper(data, response)) } catch { return .failure(Error.invalidData) } diff --git a/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift b/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift index 0277c7f..b8fa6db 100644 --- a/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift @@ -42,64 +42,33 @@ class RemoteLoaderTests: XCTestCase { }) } - 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() + func test_load_deliversErrorOnMapperError() { + let (sut, client) = makeSUT(mapper: { _, _ in + throw anyNSError() + }) expect(sut, toCompleteWith: failure(.invalidData), when: { - let invalidJSON = Data("invalid json".utf8) - client.complete(withStatusCode: 200, data: invalidJSON) + client.complete(withStatusCode: 200, data: anyData()) }) } - 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_deliversMappedResource() { + let resource = "a resource" + let (sut, client) = makeSUT(mapper: { data, _ in + String(data: data, encoding: .utf8)! }) - } - - 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://another-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) + expect(sut, toCompleteWith: .success(resource), when: { + client.complete(withStatusCode: 200, data: Data(resource.utf8)) }) } func test_load_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { let url = URL(string: "http://any-url.com")! let client = HTTPClientSpy() - var sut: RemoteLoader? = RemoteLoader(url: url, client: client) + var sut: RemoteLoader? = RemoteLoader(url: url, client: client, mapper: { _, _ in "any" }) - var capturedResults = [RemoteLoader.Result]() + var capturedResults = [RemoteLoader.Result]() sut?.load { capturedResults.append($0) } sut = nil @@ -110,15 +79,20 @@ class RemoteLoaderTests: XCTestCase { // MARK: - Helpers - private func makeSUT(url: URL = URL(string: "https://a-url.com")!, file: StaticString = #file, line: UInt = #line) -> (sut: RemoteLoader, client: HTTPClientSpy) { + private func makeSUT( + url: URL = URL(string: "https://a-url.com")!, + mapper: @escaping RemoteLoader.Mapper = { _, _ in "any" }, + file: StaticString = #file, + line: UInt = #line + ) -> (sut: RemoteLoader, client: HTTPClientSpy) { let client = HTTPClientSpy() - let sut = RemoteLoader(url: url, client: client) + let sut = RemoteLoader(url: url, client: client, mapper: mapper) trackForMemoryLeaks(sut, file: file, line: line) trackForMemoryLeaks(client, file: file, line: line) return (sut, client) } - private func failure(_ error: RemoteLoader.Error) -> RemoteLoader.Result { + private func failure(_ error: RemoteLoader.Error) -> RemoteLoader.Result { return .failure(error) } @@ -140,7 +114,7 @@ class RemoteLoaderTests: XCTestCase { return try! JSONSerialization.data(withJSONObject: json) } - private func expect(_ sut: RemoteLoader, toCompleteWith expectedResult: RemoteLoader.Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { + private func expect(_ sut: RemoteLoader, toCompleteWith expectedResult: RemoteLoader.Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { let exp = expectation(description: "Wait for load completion") sut.load { receivedResult in @@ -148,7 +122,7 @@ class RemoteLoaderTests: XCTestCase { case let (.success(receivedItems), .success(expectedItems)): XCTAssertEqual(receivedItems, expectedItems, file: file, line: line) - case let (.failure(receivedError as RemoteLoader.Error), .failure(expectedError as RemoteLoader.Error)): + case let (.failure(receivedError as RemoteLoader.Error), .failure(expectedError as RemoteLoader.Error)): XCTAssertEqual(receivedError, expectedError, file: file, line: line) default: From f6275d25c2af349e4d44964f21a48723ba04a9cc Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 23:06:04 -0300 Subject: [PATCH 10/22] Replace RemoteImageCommentsLoader with generic RemoteLoader to remove duplication --- .../RemoteImageCommentsLoader.swift | 41 ++-------------- ...dImageCommentsFromRemoteUseCaseTests.swift | 48 ------------------- 2 files changed, 5 insertions(+), 84 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift b/EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift index ae4067c..7bfdcac 100644 --- a/EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift +++ b/EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift @@ -5,41 +5,10 @@ import Foundation -public final class RemoteImageCommentsLoader { - private let url: URL - private let client: HTTPClient - - public enum Error: Swift.Error { - case connectivity - case invalidData - } - - public typealias Result = Swift.Result<[ImageComment], Swift.Error> - - 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(RemoteImageCommentsLoader.map(data, from: response)) - case .failure: - completion(.failure(Error.connectivity)) - } - } - } - - private static func map(_ data: Data, from response: HTTPURLResponse) -> Result { - do { - let items = try ImageCommentsMapper.map(data, from: response) - return .success(items) - } catch { - return .failure(error) - } +public typealias RemoteImageCommentsLoader = RemoteLoader<[ImageComment]> + +public extension RemoteImageCommentsLoader { + convenience init(url: URL, client: HTTPClient) { + self.init(url: url, client: client, mapper: ImageCommentsMapper.map) } } diff --git a/EssentialFeed/EssentialFeedTests/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift index e40942e..bbf7e79 100644 --- a/EssentialFeed/EssentialFeedTests/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift @@ -8,40 +8,6 @@ import EssentialFeed class LoadImageCommentsFromRemoteUseCaseTests: XCTestCase { - func test_init_doesNotRequestDataFromURL() { - let (_, client) = makeSUT() - - XCTAssertTrue(client.requestedURLs.isEmpty) - } - - func test_load_requestsDataFromURL() { - let url = URL(string: "https://a-given-url.com")! - let (sut, client) = makeSUT(url: url) - - sut.load { _ in } - - 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_deliversErrorOnNon2xxHTTPResponse() { let (sut, client) = makeSUT() @@ -108,20 +74,6 @@ class LoadImageCommentsFromRemoteUseCaseTests: XCTestCase { } } - func test_load_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { - let url = URL(string: "http://any-url.com")! - let client = HTTPClientSpy() - var sut: RemoteImageCommentsLoader? = RemoteImageCommentsLoader(url: url, client: client) - - var capturedResults = [RemoteImageCommentsLoader.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: RemoteImageCommentsLoader, client: HTTPClientSpy) { From 8ca3c386bb4a54173087be02563db6b18b83dd4c Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 23:15:41 -0300 Subject: [PATCH 11/22] Replace RemoteFeedLoader with generic RemoteLoader to remove duplication --- .../Feed API/RemoteFeedLoader.swift | 41 ++------------- .../LoadFeedFromRemoteUseCaseTests.swift | 51 ------------------- 2 files changed, 5 insertions(+), 87 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift index 7b39b9b..2c8d3eb 100644 --- a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift @@ -5,41 +5,10 @@ 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) - } catch { - return .failure(error) - } +public typealias RemoteFeedLoader = RemoteLoader<[FeedImage]> + +public extension RemoteFeedLoader { + convenience init(url: URL, client: HTTPClient) { + self.init(url: url, client: client, mapper: FeedItemsMapper.map) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift index b735672..07fba29 100644 --- a/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift @@ -8,43 +8,6 @@ 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() @@ -97,20 +60,6 @@ class LoadFeedFromRemoteUseCaseTests: XCTestCase { }) } - 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) { From 73a04dbe68d5dc14e22b09a36c8abe40fb7aeb58 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 23:23:28 -0300 Subject: [PATCH 12/22] Make RemoteLoader conform to FeedLoader in the Composition Root --- EssentialApp/EssentialApp/SceneDelegate.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 8a16d1b..aaac931 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -76,3 +76,5 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { }) } } + +extension RemoteLoader: @retroactive FeedLoader where Resource == [FeedImage] {} From 379aca1eb29f756763fbccd2e72b21d8e1dde85b Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 12 Mar 2025 23:58:21 -0300 Subject: [PATCH 13/22] Test FeedItemsMapper in isolation --- .../EssentialFeed.xcodeproj/project.pbxproj | 8 +- .../Feed API/FeedItemsMapper.swift | 4 +- .../Feed API/FeedItemsMapperTests.swift | 82 ++++++++++++ .../LoadFeedFromRemoteUseCaseTests.swift | 118 ------------------ 4 files changed, 88 insertions(+), 124 deletions(-) create mode 100644 EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift delete mode 100644 EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index aaf5673..bbed29a 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -45,7 +45,7 @@ 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 */; }; + 5B7AB8D72BF8BCE60034C68B /* FeedItemsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */; }; 5B7BDE022BFA81B70002E7F8 /* RemoteFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */; }; 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */; }; 5B8829042D6A7527006E0BD7 /* Feed.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */; }; @@ -194,7 +194,7 @@ 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 = ""; }; + 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapperTests.swift; sourceTree = ""; }; 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeedLoader.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 = ""; }; @@ -329,7 +329,7 @@ 5B0E220E2BFE404F009FC3EB /* Feed API */ = { isa = PBXGroup; children = ( - 5B7AB8D62BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift */, + 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */, 5B8829122D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift */, ); path = "Feed API"; @@ -975,7 +975,7 @@ 5B034B412CA371CB00FB65F8 /* ValidateFeedCacheUseCaseTests.swift in Sources */, 5B74FD222D6A5F39007478DC /* FeedPresenterTests.swift in Sources */, 5B8829132D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift in Sources */, - 5B7AB8D72BF8BCE60034C68B /* LoadFeedFromRemoteUseCaseTests.swift in Sources */, + 5B7AB8D72BF8BCE60034C68B /* FeedItemsMapperTests.swift in Sources */, 5B88290D2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift in Sources */, 5BF9F3012CD9A10500C8DB96 /* XCTestCase+FailableInsertFeedStoreSpecs.swift in Sources */, 5B8829292D6BF226006E0BD7 /* CacheFeedImageDataUseCaseTests.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift b/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift index c74547a..311ae17 100644 --- a/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift +++ b/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift @@ -5,7 +5,7 @@ import Foundation -enum FeedItemsMapper { +public enum FeedItemsMapper { private struct Root: Decodable { private let items: [RemoteFeedItem] @@ -21,7 +21,7 @@ enum FeedItemsMapper { } } - static func map(_ data: Data, from response: HTTPURLResponse) throws -> [FeedImage] { + 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 } diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift new file mode 100644 index 0000000..e26c762 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift @@ -0,0 +1,82 @@ +// +// 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) + } + + private func makeItemsJSON(_ items: [[String: Any]]) -> Data { + let json = ["items": items] + return try! JSONSerialization.data(withJSONObject: json) + } + +} + +private extension HTTPURLResponse { + convenience init(statusCode: Int) { + self.init(url: anyURL(), statusCode: statusCode, httpVersion: nil, headerFields: nil)! + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift deleted file mode 100644 index 07fba29..0000000 --- a/EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2024 PortoCode. All Rights Reserved. -// - -import XCTest -import EssentialFeed - -class LoadFeedFromRemoteUseCaseTests: XCTestCase { - - 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) - }) - } - - // 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) - } - -} From 44c27c6dce044f1bc76e1d776cd98a0a1eb45c77 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 13 Mar 2025 00:00:27 -0300 Subject: [PATCH 14/22] Move test helpers to shared scope --- .../Feed API/FeedItemsMapperTests.swift | 11 ----------- .../Helpers/SharedTestHelpers.swift | 11 +++++++++++ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift index e26c762..18507a7 100644 --- a/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift @@ -68,15 +68,4 @@ class FeedItemsMapperTests: XCTestCase { return (item, json) } - private func makeItemsJSON(_ items: [[String: Any]]) -> Data { - let json = ["items": items] - return try! JSONSerialization.data(withJSONObject: json) - } - -} - -private extension HTTPURLResponse { - convenience init(statusCode: Int) { - self.init(url: anyURL(), statusCode: statusCode, httpVersion: nil, headerFields: nil)! - } } 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)! + } +} From e19e98ba4da0f345fe6a6ab22b640c994ccfdc6c Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 13 Mar 2025 00:04:04 -0300 Subject: [PATCH 15/22] Test ImageCommentsMapper in isolation --- .../EssentialFeed.xcodeproj/project.pbxproj | 8 +- .../ImageCommentsMapper.swift | 4 +- .../ImageCommentsMapperTests.swift | 84 +++++++++++ ...dImageCommentsFromRemoteUseCaseTests.swift | 134 ------------------ 4 files changed, 90 insertions(+), 140 deletions(-) create mode 100644 EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift delete mode 100644 EssentialFeed/EssentialFeedTests/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index bbed29a..11cf183 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -36,7 +36,7 @@ 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 /* LoadImageCommentsFromRemoteUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349112D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift */; }; + 5B7349122D819FC7007F7D5D /* ImageCommentsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349112D819FC7007F7D5D /* ImageCommentsMapperTests.swift */; }; 5B7349142D81A17E007F7D5D /* RemoteImageCommentsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */; }; 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */; }; 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349182D824CC8007F7D5D /* ImageComment.swift */; }; @@ -185,7 +185,7 @@ 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 /* LoadImageCommentsFromRemoteUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadImageCommentsFromRemoteUseCaseTests.swift; sourceTree = ""; }; + 5B7349112D819FC7007F7D5D /* ImageCommentsMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsMapperTests.swift; sourceTree = ""; }; 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageCommentsLoader.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 = ""; }; @@ -534,7 +534,7 @@ 5B7349222D8258A5007F7D5D /* Image Comments API */ = { isa = PBXGroup; children = ( - 5B7349112D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.swift */, + 5B7349112D819FC7007F7D5D /* ImageCommentsMapperTests.swift */, ); path = "Image Comments API"; sourceTree = ""; @@ -983,7 +983,7 @@ 5B304EEA2BFF582400AF431F /* URLSessionHTTPClientTests.swift in Sources */, 5B8829202D6BE22C006E0BD7 /* LoadFeedImageDataFromCacheUseCaseTests.swift in Sources */, 5B8829172D6BCD50006E0BD7 /* HTTPClientSpy.swift in Sources */, - 5B7349122D819FC7007F7D5D /* LoadImageCommentsFromRemoteUseCaseTests.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/Image Comments API/ImageCommentsMapper.swift b/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift index 9092387..f7e2cfb 100644 --- a/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift +++ b/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift @@ -5,7 +5,7 @@ import Foundation -enum ImageCommentsMapper { +public enum ImageCommentsMapper { private struct Root: Decodable { private let items: [Item] @@ -25,7 +25,7 @@ enum ImageCommentsMapper { } } - static func map(_ data: Data, from response: HTTPURLResponse) throws -> [ImageComment] { + public static func map(_ data: Data, from response: HTTPURLResponse) throws -> [ImageComment] { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 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/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift deleted file mode 100644 index bbf7e79..0000000 --- a/EssentialFeed/EssentialFeedTests/Image Comments API/LoadImageCommentsFromRemoteUseCaseTests.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import XCTest -import EssentialFeed - -class LoadImageCommentsFromRemoteUseCaseTests: XCTestCase { - - func test_load_deliversErrorOnNon2xxHTTPResponse() { - let (sut, client) = makeSUT() - - let samples = [150, 199, 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_deliversErrorOn2xxHTTPResponseWithInvalidJSON() { - let (sut, client) = makeSUT() - - let samples = [200, 201, 250, 280, 299] - - samples.enumerated().forEach { index, code in - expect(sut, toCompleteWith: failure(.invalidData), when: { - let invalidJSON = Data("invalid json".utf8) - client.complete(withStatusCode: code, data: invalidJSON, at: index) - }) - } - } - - func test_load_deliversNoItemsOn2xxHTTPResponseWithEmptyJSONList() { - let (sut, client) = makeSUT() - - let samples = [200, 201, 250, 280, 299] - - samples.enumerated().forEach { index, code in - expect(sut, toCompleteWith: .success([]), when: { - let emptyListJSON = makeItemsJSON([]) - client.complete(withStatusCode: code, data: emptyListJSON, at: index) - }) - } - } - - func test_load_deliversItemsOn2xxHTTPResponseWithJSONItems() { - let (sut, client) = makeSUT() - - 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 items = [item1.model, item2.model] - - let samples = [200, 201, 250, 280, 299] - - samples.enumerated().forEach { index, code in - expect(sut, toCompleteWith: .success(items), when: { - let json = makeItemsJSON([item1.json, item2.json]) - client.complete(withStatusCode: code, data: json, at: index) - }) - } - } - - // MARK: - Helpers - - private func makeSUT(url: URL = URL(string: "https://a-url.com")!, file: StaticString = #filePath, line: UInt = #line) -> (sut: RemoteImageCommentsLoader, client: HTTPClientSpy) { - let client = HTTPClientSpy() - let sut = RemoteImageCommentsLoader(url: url, client: client) - trackForMemoryLeaks(sut, file: file, line: line) - trackForMemoryLeaks(client, file: file, line: line) - return (sut, client) - } - - private func failure(_ error: RemoteImageCommentsLoader.Error) -> RemoteImageCommentsLoader.Result { - return .failure(error) - } - - 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) - } - - private func makeItemsJSON(_ items: [[String: Any]]) -> Data { - let json = ["items": items] - return try! JSONSerialization.data(withJSONObject: json) - } - - private func expect(_ sut: RemoteImageCommentsLoader, toCompleteWith expectedResult: RemoteImageCommentsLoader.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 RemoteImageCommentsLoader.Error), .failure(expectedError as RemoteImageCommentsLoader.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) - } - -} From 40020bf0431d90714ebac7ff2ae412c126c3be14 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 13 Mar 2025 00:36:14 -0300 Subject: [PATCH 16/22] Move RemoteLoader composition to the Composition Root --- EssentialApp/EssentialApp/SceneDelegate.swift | 2 +- .../EssentialFeed.xcodeproj/project.pbxproj | 8 -------- .../EssentialFeed/Feed API/FeedItemsMapper.swift | 6 +++++- .../EssentialFeed/Feed API/RemoteFeedLoader.swift | 14 -------------- .../Image Comments API/ImageCommentsMapper.swift | 6 +++++- .../RemoteImageCommentsLoader.swift | 14 -------------- .../EssentialFeedAPIEndToEndTests.swift | 2 +- 7 files changed, 12 insertions(+), 40 deletions(-) delete mode 100644 EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift delete mode 100644 EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index aaac931..e1edfeb 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -28,7 +28,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private let url = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed/v1/feed")! - private lazy var remoteFeedLoader = RemoteFeedLoader(url: url, client: httpClient) + private lazy var remoteFeedLoader = RemoteLoader(url: url, client: httpClient, mapper: FeedItemsMapper.map) convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { self.init() diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 11cf183..950c327 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -37,7 +37,6 @@ 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 */; }; - 5B7349142D81A17E007F7D5D /* RemoteImageCommentsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */; }; 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */; }; 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349182D824CC8007F7D5D /* ImageComment.swift */; }; 5B7349242D826B60007F7D5D /* RemoteLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349232D826B60007F7D5D /* RemoteLoaderTests.swift */; }; @@ -46,7 +45,6 @@ 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 /* FeedItemsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */; }; - 5B7BDE022BFA81B70002E7F8 /* RemoteFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.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 */; }; @@ -186,7 +184,6 @@ 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 = ""; }; - 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageCommentsLoader.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 = ""; }; 5B7349232D826B60007F7D5D /* RemoteLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoaderTests.swift; sourceTree = ""; }; @@ -195,7 +192,6 @@ 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 /* FeedItemsMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapperTests.swift; sourceTree = ""; }; - 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeedLoader.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 = ""; }; @@ -525,7 +521,6 @@ 5B7349212D825864007F7D5D /* Image Comments API */ = { isa = PBXGroup; children = ( - 5B7349132D81A17E007F7D5D /* RemoteImageCommentsLoader.swift */, 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */, ); path = "Image Comments API"; @@ -565,7 +560,6 @@ isa = PBXGroup; children = ( 5B88291A2D6BD697006E0BD7 /* Helpers */, - 5B7BDE012BFA81B70002E7F8 /* RemoteFeedLoader.swift */, 5B0E220C2BFE3135009FC3EB /* FeedItemsMapper.swift */, 5B8829182D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift */, ); @@ -934,11 +928,9 @@ 5B0E220D2BFE3135009FC3EB /* FeedItemsMapper.swift in Sources */, 5B107E152BF5BF1400927709 /* FeedLoader.swift in Sources */, 5B8829112D6A964F006E0BD7 /* FeedImageViewModel.swift in Sources */, - 5B7349142D81A17E007F7D5D /* RemoteImageCommentsLoader.swift in Sources */, 5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */, 5B8829192D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift in Sources */, 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */, - 5B7BDE022BFA81B70002E7F8 /* RemoteFeedLoader.swift in Sources */, 5BF9F30D2CDAD64700C8DB96 /* FeedStore.xcdatamodeld in Sources */, 5BDE3C672D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift in Sources */, 5B034B392C9BD2C000FB65F8 /* LocalFeedImage.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift b/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift index 311ae17..84dbcc2 100644 --- a/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift +++ b/EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift @@ -21,9 +21,13 @@ public enum FeedItemsMapper { } } + public enum Error: Swift.Error { + case invalidData + } + 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.images diff --git a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift deleted file mode 100644 index 2c8d3eb..0000000 --- a/EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2024 PortoCode. All Rights Reserved. -// - -import Foundation - -public typealias RemoteFeedLoader = RemoteLoader<[FeedImage]> - -public extension RemoteFeedLoader { - convenience init(url: URL, client: HTTPClient) { - self.init(url: url, client: client, mapper: FeedItemsMapper.map) - } -} diff --git a/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift b/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift index f7e2cfb..37819c7 100644 --- a/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift +++ b/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift @@ -25,12 +25,16 @@ public enum ImageCommentsMapper { } } + 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 RemoteImageCommentsLoader.Error.invalidData + throw Error.invalidData } return root.comments diff --git a/EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift b/EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift deleted file mode 100644 index 7bfdcac..0000000 --- a/EssentialFeed/EssentialFeed/Image Comments API/RemoteImageCommentsLoader.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import Foundation - -public typealias RemoteImageCommentsLoader = RemoteLoader<[ImageComment]> - -public extension RemoteImageCommentsLoader { - convenience init(url: URL, client: HTTPClient) { - self.init(url: url, client: client, mapper: ImageCommentsMapper.map) - } -} diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index 87b3a36..cc14cd9 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -45,7 +45,7 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { // MARK: - Helpers private func getFeedResult(file: StaticString = #filePath, line: UInt = #line) -> FeedLoader.Result? { - let loader = RemoteFeedLoader(url: feedTestServerURL, client: ephemeralClient()) + let loader = RemoteLoader(url: feedTestServerURL, client: ephemeralClient(), mapper: FeedItemsMapper.map) trackForMemoryLeaks(loader, file: file, line: line) let exp = expectation(description: "Wait for load completion") From f48e83a94683fa88be3116fdb429c8faeb435fbf Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 13 Mar 2025 01:04:54 -0300 Subject: [PATCH 17/22] Replace RemoteLoader composition with HTTPClient publisher composed with FeedItemsMapper --- EssentialApp/EssentialApp/CombineHelpers.swift | 16 ++++++++++++++++ EssentialApp/EssentialApp/SceneDelegate.swift | 9 ++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index f1b975b..6df568d 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 diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index e1edfeb..a1fc47a 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 = RemoteLoader(url: url, client: httpClient, mapper: FeedItemsMapper.map) + private let remoteURL = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed/v1/feed")! convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { self.init() @@ -57,8 +55,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } private func makeRemoteFeedLoaderWithLocalFallback() -> FeedLoader.Publisher { - return remoteFeedLoader - .loadPublisher() + return httpClient + .getPublisher(url: remoteURL) + .tryMap(FeedItemsMapper.map) .caching(to: localFeedLoader) .fallback(to: localFeedLoader.loadPublisher) } From 4b8e8216c9350e0c6d45698ee8b0fec90524f1f5 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 13 Mar 2025 01:16:41 -0300 Subject: [PATCH 18/22] Remove unused RemoteLoader --- EssentialApp/EssentialApp/SceneDelegate.swift | 2 - .../EssentialFeed.xcodeproj/project.pbxproj | 8 - .../Shared API/RemoteLoader.swift | 48 ------ .../EssentialFeedAPIEndToEndTests.swift | 14 +- .../Shared API/RemoteLoaderTests.swift | 140 ------------------ 5 files changed, 9 insertions(+), 203 deletions(-) delete mode 100644 EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift delete mode 100644 EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index a1fc47a..6e723cf 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -75,5 +75,3 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { }) } } - -extension RemoteLoader: @retroactive FeedLoader where Resource == [FeedImage] {} diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 950c327..c6e39f8 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -39,8 +39,6 @@ 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 */; }; - 5B7349242D826B60007F7D5D /* RemoteLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349232D826B60007F7D5D /* RemoteLoaderTests.swift */; }; - 5B7349262D826BFA007F7D5D /* RemoteLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349252D826BFA007F7D5D /* RemoteLoader.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 */; }; @@ -186,8 +184,6 @@ 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 = ""; }; - 5B7349232D826B60007F7D5D /* RemoteLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoaderTests.swift; sourceTree = ""; }; - 5B7349252D826BFA007F7D5D /* RemoteLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoader.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 = ""; }; @@ -479,7 +475,6 @@ isa = PBXGroup; children = ( 5B0E220A2BFE2FEA009FC3EB /* HTTPClient.swift */, - 5B7349252D826BFA007F7D5D /* RemoteLoader.swift */, ); path = "Shared API"; sourceTree = ""; @@ -496,7 +491,6 @@ isa = PBXGroup; children = ( 5B73491D2D8256BC007F7D5D /* Helpers */, - 5B7349232D826B60007F7D5D /* RemoteLoaderTests.swift */, ); path = "Shared API"; sourceTree = ""; @@ -945,7 +939,6 @@ 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */, 5B1C4F9B2C0556ED003F0429 /* URLSessionHTTPClient.swift in Sources */, 5B88291C2D6BD6C6006E0BD7 /* HTTPURLResponse+StatusCode.swift in Sources */, - 5B7349262D826BFA007F7D5D /* RemoteLoader.swift in Sources */, 5BC4F6CD2CDAF1B30002D4CF /* ManagedCache.swift in Sources */, 5BF9F30A2CDAD24D00C8DB96 /* CoreDataFeedStore.swift in Sources */, 5B88290A2D6A7B76006E0BD7 /* FeedErrorViewModel.swift in Sources */, @@ -984,7 +977,6 @@ 5BF9F3032CD9A1C600C8DB96 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift in Sources */, 5BF9F2FF2CD99FF300C8DB96 /* XCTestCase+FailableRetrieveFeedStoreSpecs.swift in Sources */, 5B034B3B2CA0B09F00FB65F8 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, - 5B7349242D826B60007F7D5D /* RemoteLoaderTests.swift in Sources */, 5BF9F2F92CD9961400C8DB96 /* FeedStoreSpecs.swift in Sources */, 5B8BB9992C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift in Sources */, 5BF9F3082CDAD1B600C8DB96 /* CoreDataFeedStoreTests.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift b/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift deleted file mode 100644 index ca43773..0000000 --- a/EssentialFeed/EssentialFeed/Shared API/RemoteLoader.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import Foundation - -public final class RemoteLoader { - private let url: URL - private let client: HTTPClient - private let mapper: Mapper - - public enum Error: Swift.Error { - case connectivity - case invalidData - } - - public typealias Result = Swift.Result - public typealias Mapper = (Data, HTTPURLResponse) throws -> Resource - - public init(url: URL, client: HTTPClient, mapper: @escaping Mapper) { - self.url = url - self.client = client - self.mapper = mapper - } - - public func load(completion: @escaping (Result) -> Void) { - client.get(from: url) { [weak self] result in - guard let self = self else { return } - - switch result { - case let .success((data, response)): - completion(self.map(data, from: response)) - - case .failure: - completion(.failure(Error.connectivity)) - } - } - } - - private func map(_ data: Data, from response: HTTPURLResponse) -> Result { - do { - return .success(try mapper(data, response)) - } catch { - return .failure(Error.invalidData) - } - } -} diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index cc14cd9..9cdf539 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -45,14 +45,18 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { // MARK: - Helpers private func getFeedResult(file: StaticString = #filePath, line: UInt = #line) -> FeedLoader.Result? { - let loader = RemoteLoader(url: feedTestServerURL, client: ephemeralClient(), mapper: FeedItemsMapper.map) - trackForMemoryLeaks(loader, file: file, line: line) - + let client = ephemeralClient() let exp = expectation(description: "Wait for load completion") var receivedResult: FeedLoader.Result? - loader.load { result in - receivedResult = result + 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) diff --git a/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift b/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift deleted file mode 100644 index b8fa6db..0000000 --- a/EssentialFeed/EssentialFeedTests/Shared API/RemoteLoaderTests.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import XCTest -import EssentialFeed - -class RemoteLoaderTests: XCTestCase { - - func test_init_doesNotRequestDataFromURL() { - let (_, client) = makeSUT() - - XCTAssertTrue(client.requestedURLs.isEmpty) - } - - func test_load_requestsDataFromURL() { - let url = URL(string: "https://a-given-url.com")! - let (sut, client) = makeSUT(url: url) - - sut.load { _ in } - - 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_deliversErrorOnMapperError() { - let (sut, client) = makeSUT(mapper: { _, _ in - throw anyNSError() - }) - - expect(sut, toCompleteWith: failure(.invalidData), when: { - client.complete(withStatusCode: 200, data: anyData()) - }) - } - - func test_load_deliversMappedResource() { - let resource = "a resource" - let (sut, client) = makeSUT(mapper: { data, _ in - String(data: data, encoding: .utf8)! - }) - - expect(sut, toCompleteWith: .success(resource), when: { - client.complete(withStatusCode: 200, data: Data(resource.utf8)) - }) - } - - func test_load_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { - let url = URL(string: "http://any-url.com")! - let client = HTTPClientSpy() - var sut: RemoteLoader? = RemoteLoader(url: url, client: client, mapper: { _, _ in "any" }) - - var capturedResults = [RemoteLoader.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")!, - mapper: @escaping RemoteLoader.Mapper = { _, _ in "any" }, - file: StaticString = #file, - line: UInt = #line - ) -> (sut: RemoteLoader, client: HTTPClientSpy) { - let client = HTTPClientSpy() - let sut = RemoteLoader(url: url, client: client, mapper: mapper) - trackForMemoryLeaks(sut, file: file, line: line) - trackForMemoryLeaks(client, file: file, line: line) - return (sut, client) - } - - private func failure(_ error: RemoteLoader.Error) -> RemoteLoader.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": id.uuidString, - "description": description, - "location": location, - "image": imageURL.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: RemoteLoader, toCompleteWith expectedResult: RemoteLoader.Result, when action: () -> Void, file: StaticString = #file, 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 RemoteLoader.Error), .failure(expectedError as RemoteLoader.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) - } - -} From c3f7f1feefde6152be42c761491fa0dfa49bcde9 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 13 Mar 2025 01:33:35 -0300 Subject: [PATCH 19/22] Remove FeedLoader protocol as we don't need it anymore - we're composing the types with universal abstractions provided by the Combine framework --- EssentialApp/EssentialApp/CombineHelpers.swift | 2 +- .../FeedLoaderPresentationAdapter.swift | 4 ++-- EssentialApp/EssentialApp/FeedUIComposer.swift | 2 +- EssentialApp/EssentialApp/SceneDelegate.swift | 2 +- .../FeedUIIntegrationTests+LoaderSpy.swift | 15 +++++++++------ .../EssentialFeed.xcodeproj/project.pbxproj | 4 ---- .../Feed Cache/LocalFeedLoader.swift | 4 ++-- .../EssentialFeed/Feed Feature/FeedLoader.swift | 12 ------------ .../EssentialFeedAPIEndToEndTests.swift | 4 ++-- 9 files changed, 18 insertions(+), 31 deletions(-) delete mode 100644 EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index 6df568d..518eefc 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -53,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 6e723cf..3e56e0f 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -54,7 +54,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { localFeedLoader.validateCache { _ in } } - private func makeRemoteFeedLoaderWithLocalFallback() -> FeedLoader.Publisher { + private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<[FeedImage], Error> { return httpClient .getPublisher(url: remoteURL) .tryMap(FeedItemsMapper.map) 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 c6e39f8..477f2dc 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -22,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 */; }; @@ -167,7 +166,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; }; @@ -390,7 +388,6 @@ isa = PBXGroup; children = ( 5B107E122BF5BB4200927709 /* FeedImage.swift */, - 5B107E142BF5BF1400927709 /* FeedLoader.swift */, 5BBDA00D2D6FCCF000D68DF0 /* FeedCache.swift */, 5B6992FA2D09353200DD47E9 /* FeedImageDataLoader.swift */, 5BBDA0192D6FF5F100D68DF0 /* FeedImageDataCache.swift */, @@ -920,7 +917,6 @@ 5B8829222D6BEB4E006E0BD7 /* LocalFeedImageDataLoader.swift in Sources */, 5BC4F6CB2CDAF0B20002D4CF /* CoreDataHelpers.swift in Sources */, 5B0E220D2BFE3135009FC3EB /* FeedItemsMapper.swift in Sources */, - 5B107E152BF5BF1400927709 /* FeedLoader.swift in Sources */, 5B8829112D6A964F006E0BD7 /* FeedImageViewModel.swift in Sources */, 5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */, 5B8829192D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift in Sources */, 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/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index 9cdf539..7543213 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -44,11 +44,11 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { // MARK: - Helpers - private func getFeedResult(file: StaticString = #filePath, line: UInt = #line) -> FeedLoader.Result? { + 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? + var receivedResult: Swift.Result<[FeedImage], Error>? client.get(from: feedTestServerURL) { result in receivedResult = result.flatMap { (data, response) in do { From 8db30c065e697b4604a117a3b71caf60ce947c7a Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 13 Mar 2025 01:53:21 -0300 Subject: [PATCH 20/22] Replace RemoteFeedImageDataLoader with HTTPClient publisher composed with FeedImageDataMapper --- EssentialApp/EssentialApp/SceneDelegate.swift | 8 +- .../EssentialFeed.xcodeproj/project.pbxproj | 16 +- .../Feed API/FeedImageDataMapper.swift | 20 +++ .../Feed API/RemoteFeedImageDataLoader.swift | 57 ------- .../EssentialFeedAPIEndToEndTests.swift | 16 +- .../Feed API/FeedImageDataMapperTests.swift | 39 +++++ ...dFeedImageDataFromRemoteUseCaseTests.swift | 155 ------------------ 7 files changed, 81 insertions(+), 230 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed API/FeedImageDataMapper.swift delete mode 100644 EssentialFeed/EssentialFeed/Feed API/RemoteFeedImageDataLoader.swift create mode 100644 EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift delete mode 100644 EssentialFeed/EssentialFeedTests/Feed API/LoadFeedImageDataFromRemoteUseCaseTests.swift diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 3e56e0f..aa90cda 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -63,14 +63,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } 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/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 477f2dc..e989f58 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 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 */; }; @@ -51,10 +52,9 @@ 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 */; }; @@ -182,6 +182,7 @@ 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 = ""; }; @@ -194,9 +195,8 @@ 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 = ""; }; + 5B8829122D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataMapperTests.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 = ""; }; 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 = ""; }; @@ -320,7 +320,7 @@ isa = PBXGroup; children = ( 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */, - 5B8829122D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift */, + 5B8829122D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift */, ); path = "Feed API"; sourceTree = ""; @@ -552,7 +552,7 @@ children = ( 5B88291A2D6BD697006E0BD7 /* Helpers */, 5B0E220C2BFE3135009FC3EB /* FeedItemsMapper.swift */, - 5B8829182D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift */, + 5B7349272D829960007F7D5D /* FeedImageDataMapper.swift */, ); path = "Feed API"; sourceTree = ""; @@ -918,8 +918,8 @@ 5BC4F6CB2CDAF0B20002D4CF /* CoreDataHelpers.swift in Sources */, 5B0E220D2BFE3135009FC3EB /* FeedItemsMapper.swift in Sources */, 5B8829112D6A964F006E0BD7 /* FeedImageViewModel.swift in Sources */, + 5B7349282D829960007F7D5D /* FeedImageDataMapper.swift in Sources */, 5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */, - 5B8829192D6BCE5D006E0BD7 /* RemoteFeedImageDataLoader.swift in Sources */, 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */, 5BF9F30D2CDAD64700C8DB96 /* FeedStore.xcdatamodeld in Sources */, 5BDE3C672D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift in Sources */, @@ -955,7 +955,7 @@ 5B88291E2D6BD7BE006E0BD7 /* URLProtocolStub.swift in Sources */, 5B034B412CA371CB00FB65F8 /* ValidateFeedCacheUseCaseTests.swift in Sources */, 5B74FD222D6A5F39007478DC /* FeedPresenterTests.swift in Sources */, - 5B8829132D6BAA6C006E0BD7 /* LoadFeedImageDataFromRemoteUseCaseTests.swift in Sources */, + 5B8829132D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift in Sources */, 5B7AB8D72BF8BCE60034C68B /* FeedItemsMapperTests.swift in Sources */, 5B88290D2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift in Sources */, 5BF9F3012CD9A10500C8DB96 /* XCTestCase+FailableInsertFeedStoreSpecs.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/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/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index 7543213..e269914 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -65,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..aaf06f3 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift @@ -0,0 +1,39 @@ +// +// 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/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) - } - -} From a0bf944d296752e5c39423f89c612ce9f5ea06ad Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 13 Mar 2025 01:55:07 -0300 Subject: [PATCH 21/22] Remove unused test spy --- .../EssentialFeed.xcodeproj/project.pbxproj | 20 --------- .../Shared API/Helpers/HTTPClientSpy.swift | 44 ------------------- 2 files changed, 64 deletions(-) delete mode 100644 EssentialFeed/EssentialFeedTests/Shared API/Helpers/HTTPClientSpy.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index e989f58..717b06b 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -54,7 +54,6 @@ 5B8829112D6A964F006E0BD7 /* FeedImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829102D6A964F006E0BD7 /* FeedImageViewModel.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 */; }; 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 */; }; @@ -196,7 +195,6 @@ 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 /* FeedImageDataMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataMapperTests.swift; sourceTree = ""; }; - 5B8829162D6BCD50006E0BD7 /* HTTPClientSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClientSpy.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 = ""; }; @@ -374,7 +372,6 @@ children = ( 5B1C4F9C2C056BB6003F0429 /* EssentialFeed.xctestplan */, 5B8BB9972C02714D00D40D42 /* Helpers */, - 5B73491C2D825693007F7D5D /* Shared API */, 5B73491E2D825711007F7D5D /* Shared API Infra */, 5B7349222D8258A5007F7D5D /* Image Comments API */, 5B0E220E2BFE404F009FC3EB /* Feed API */, @@ -484,22 +481,6 @@ path = "Shared API Infra"; sourceTree = ""; }; - 5B73491C2D825693007F7D5D /* Shared API */ = { - isa = PBXGroup; - children = ( - 5B73491D2D8256BC007F7D5D /* Helpers */, - ); - path = "Shared API"; - sourceTree = ""; - }; - 5B73491D2D8256BC007F7D5D /* Helpers */ = { - isa = PBXGroup; - children = ( - 5B8829162D6BCD50006E0BD7 /* HTTPClientSpy.swift */, - ); - path = Helpers; - sourceTree = ""; - }; 5B73491E2D825711007F7D5D /* Shared API Infra */ = { isa = PBXGroup; children = ( @@ -963,7 +944,6 @@ 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 */, diff --git a/EssentialFeed/EssentialFeedTests/Shared API/Helpers/HTTPClientSpy.swift b/EssentialFeed/EssentialFeedTests/Shared API/Helpers/HTTPClientSpy.swift deleted file mode 100644 index 4d16034..0000000 --- a/EssentialFeed/EssentialFeedTests/Shared 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))) - } -} From 83e1d2fffc60f8bd5e0c7299a5d1d2c0e3f06bc2 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 13 Mar 2025 02:00:12 -0300 Subject: [PATCH 22/22] Remove blank spaces --- .../EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift index aaf06f3..8ab5a91 100644 --- a/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift @@ -26,8 +26,6 @@ class FeedImageDataMapperTests: XCTestCase { ) } - - func test_map_deliversReceivedNonEmptyDataOn200HTTPResponse() throws { let nonEmptyData = Data("non-empty data".utf8)