diff --git a/EssentialApp/EssentialApp/NullStore.swift b/EssentialApp/EssentialApp/NullStore.swift deleted file mode 100644 index 629d313..0000000 --- a/EssentialApp/EssentialApp/NullStore.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import Foundation -import EssentialFeed - -class NullStore {} - -extension NullStore: FeedStore { - func deleteCachedFeed() throws {} - - func insert(_ feed: [LocalFeedImage], timestamp: Date) throws {} - - func retrieve() throws -> CachedFeed? { .none } -} - -extension NullStore: FeedImageDataStore { - func insert(_ data: Data, for url: URL) throws {} - - func retrieve(dataForURL url: URL) throws -> Data? { .none } -} diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 9969e31..6bc5c59 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -19,8 +19,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return DispatchQueue( label: "com.essentialdeveloper.infra.queue", - qos: .userInitiated, - attributes: .concurrent + qos: .userInitiated ).eraseToAnyScheduler() }() @@ -39,7 +38,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } catch { assertionFailure("Failed to instantiate CoreData store with error: \(error.localizedDescription)") logger.fault("Failed to instantiate CoreData store with error: \(error.localizedDescription)") - return NullStore() + return InMemoryFeedStore() } }() diff --git a/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift b/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift deleted file mode 100644 index 99e6250..0000000 --- a/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import Foundation -import EssentialFeed - -class InMemoryFeedStore { - private(set) var feedCache: CachedFeed? - private var feedImageDataCache: [URL: Data] = [:] - - private init(feedCache: CachedFeed? = nil) { - self.feedCache = feedCache - } -} - -extension InMemoryFeedStore: FeedStore { - func deleteCachedFeed() throws { - feedCache = nil - } - - func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { - feedCache = CachedFeed(feed: feed, timestamp: timestamp) - } - - func retrieve() throws -> CachedFeed? { - feedCache - } -} - -extension InMemoryFeedStore: FeedImageDataStore { - func insert(_ data: Data, for url: URL) throws { - feedImageDataCache[url] = data - } - - func retrieve(dataForURL url: URL) throws -> Data? { - feedImageDataCache[url] - } -} - -extension InMemoryFeedStore { - static var empty: InMemoryFeedStore { - InMemoryFeedStore() - } - - static var withExpiredFeedCache: InMemoryFeedStore { - InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date.distantPast)) - } - - static var withNonExpiredFeedCache: InMemoryFeedStore { - InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date())) - } -} diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 2d7d020..f900410 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -80,6 +80,11 @@ 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 */; }; + 5B75A21B2D9CD20800C1849D /* XCTestCase+FeedImageDataStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B75A21A2D9CD20800C1849D /* XCTestCase+FeedImageDataStoreSpecs.swift */; }; + 5B75A21E2D9CD2B000C1849D /* FeedImageDataStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B75A21D2D9CD2B000C1849D /* FeedImageDataStoreSpecs.swift */; }; + 5B75A2232D9CD61600C1849D /* InMemoryFeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B75A2222D9CD61600C1849D /* InMemoryFeedStore.swift */; }; + 5B75A2252D9CD6E300C1849D /* InMemoryFeedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B75A2242D9CD6E300C1849D /* InMemoryFeedStoreTests.swift */; }; + 5B75A2272D9CD76100C1849D /* InMemoryFeedImageDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B75A2262D9CD76100C1849D /* InMemoryFeedImageDataStoreTests.swift */; }; 5B7AB8D72BF8BCE60034C68B /* FeedItemsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */; }; 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */; }; 5B8829042D6A7527006E0BD7 /* Feed.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */; }; @@ -265,6 +270,11 @@ 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 = ""; }; + 5B75A21A2D9CD20800C1849D /* XCTestCase+FeedImageDataStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FeedImageDataStoreSpecs.swift"; sourceTree = ""; }; + 5B75A21D2D9CD2B000C1849D /* FeedImageDataStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataStoreSpecs.swift; sourceTree = ""; }; + 5B75A2222D9CD61600C1849D /* InMemoryFeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryFeedStore.swift; sourceTree = ""; }; + 5B75A2242D9CD6E300C1849D /* InMemoryFeedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryFeedStoreTests.swift; sourceTree = ""; }; + 5B75A2262D9CD76100C1849D /* InMemoryFeedImageDataStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryFeedImageDataStoreTests.swift; sourceTree = ""; }; 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapperTests.swift; sourceTree = ""; }; 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feed.xcstrings; sourceTree = ""; }; 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresenter.swift; sourceTree = ""; }; @@ -794,6 +804,23 @@ path = "Feed Presentation"; sourceTree = ""; }; + 5B75A21C2D9CD28800C1849D /* FeedImageDataStoreSpecs */ = { + isa = PBXGroup; + children = ( + 5B75A21D2D9CD2B000C1849D /* FeedImageDataStoreSpecs.swift */, + 5B75A21A2D9CD20800C1849D /* XCTestCase+FeedImageDataStoreSpecs.swift */, + ); + path = FeedImageDataStoreSpecs; + sourceTree = ""; + }; + 5B75A2212D9CD60000C1849D /* InMemory */ = { + isa = PBXGroup; + children = ( + 5B75A2222D9CD61600C1849D /* InMemoryFeedStore.swift */, + ); + path = InMemory; + sourceTree = ""; + }; 5B7BDE002BFA81830002E7F8 /* Feed API */ = { isa = PBXGroup; children = ( @@ -847,6 +874,7 @@ children = ( 5B034B3D2CA0BA7A00FB65F8 /* Helpers */, 5BF9F3062CD9A4DF00C8DB96 /* FeedStoreSpecs */, + 5B75A21C2D9CD28800C1849D /* FeedImageDataStoreSpecs */, 5B8BD18E2C3798D400CCA870 /* CacheFeedUseCaseTests.swift */, 5B034B3A2CA0B09F00FB65F8 /* LoadFeedFromCacheUseCaseTests.swift */, 5B034B402CA371CB00FB65F8 /* ValidateFeedCacheUseCaseTests.swift */, @@ -854,6 +882,8 @@ 5B88291F2D6BE22C006E0BD7 /* LoadFeedImageDataFromCacheUseCaseTests.swift */, 5B8829282D6BF226006E0BD7 /* CacheFeedImageDataUseCaseTests.swift */, 5B88292A2D6BF4F0006E0BD7 /* CoreDataFeedImageDataStoreTests.swift */, + 5B75A2242D9CD6E300C1849D /* InMemoryFeedStoreTests.swift */, + 5B75A2262D9CD76100C1849D /* InMemoryFeedImageDataStoreTests.swift */, ); path = "Feed Cache"; sourceTree = ""; @@ -881,6 +911,7 @@ isa = PBXGroup; children = ( 5BC4F6C92CDAEFD30002D4CF /* CoreData */, + 5B75A2212D9CD60000C1849D /* InMemory */, ); path = Infrastructure; sourceTree = ""; @@ -905,8 +936,8 @@ 5BF9F2F82CD9961400C8DB96 /* FeedStoreSpecs.swift */, 5BF9F2FA2CD997F300C8DB96 /* XCTestCase+FeedStoreSpecs.swift */, 5BF9F2FE2CD99FF300C8DB96 /* XCTestCase+FailableRetrieveFeedStoreSpecs.swift */, - 5BF9F3002CD9A10500C8DB96 /* XCTestCase+FailableInsertFeedStoreSpecs.swift */, 5BF9F3022CD9A1C600C8DB96 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift */, + 5BF9F3002CD9A10500C8DB96 /* XCTestCase+FailableInsertFeedStoreSpecs.swift */, ); path = FeedStoreSpecs; sourceTree = ""; @@ -1202,6 +1233,7 @@ 5B1926572D8BB459006C9C65 /* FeedEndpoint.swift in Sources */, 5B107E132BF5BB4200927709 /* FeedImage.swift in Sources */, 5B1926552D8BAFB1006C9C65 /* ImageCommentsEndpoint.swift in Sources */, + 5B75A2232D9CD61600C1849D /* InMemoryFeedStore.swift in Sources */, 5BBDA01A2D6FF5F100D68DF0 /* FeedImageDataCache.swift in Sources */, 5B8829242D6BEB71006E0BD7 /* FeedImageDataStore.swift in Sources */, 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */, @@ -1229,6 +1261,7 @@ 5B88291E2D6BD7BE006E0BD7 /* URLProtocolStub.swift in Sources */, 5B034B412CA371CB00FB65F8 /* ValidateFeedCacheUseCaseTests.swift in Sources */, 5B74FD222D6A5F39007478DC /* FeedPresenterTests.swift in Sources */, + 5B75A2272D9CD76100C1849D /* InMemoryFeedImageDataStoreTests.swift in Sources */, 5B8829132D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift in Sources */, 5B7AB8D72BF8BCE60034C68B /* FeedItemsMapperTests.swift in Sources */, 5B88290D2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift in Sources */, @@ -1236,6 +1269,8 @@ 5B7349472D8532C9007F7D5D /* ImageCommentsPresenterTests.swift in Sources */, 5B8829292D6BF226006E0BD7 /* CacheFeedImageDataUseCaseTests.swift in Sources */, 5B88290B2D6A8133006E0BD7 /* FeedLocalizationTests.swift in Sources */, + 5B75A21B2D9CD20800C1849D /* XCTestCase+FeedImageDataStoreSpecs.swift in Sources */, + 5B75A2252D9CD6E300C1849D /* InMemoryFeedStoreTests.swift in Sources */, 5B304EEA2BFF582400AF431F /* URLSessionHTTPClientTests.swift in Sources */, 5B8829202D6BE22C006E0BD7 /* LoadFeedImageDataFromCacheUseCaseTests.swift in Sources */, 5B7349122D819FC7007F7D5D /* ImageCommentsMapperTests.swift in Sources */, @@ -1243,6 +1278,7 @@ 5B19265B2D8BB575006C9C65 /* ImageCommentsEndpointTests.swift in Sources */, 5B034B452CA3A1A100FB65F8 /* SharedTestHelpers.swift in Sources */, 5B8BD18F2C3798D400CCA870 /* CacheFeedUseCaseTests.swift in Sources */, + 5B75A21E2D9CD2B000C1849D /* FeedImageDataStoreSpecs.swift in Sources */, 5B7349352D844D6D007F7D5D /* SharedLocalizationTests.swift in Sources */, 5B8829262D6BF168006E0BD7 /* FeedImageDataStoreSpy.swift in Sources */, 5B73494E2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/InMemory/InMemoryFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/InMemory/InMemoryFeedStore.swift new file mode 100644 index 0000000..6ede4b9 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/InMemory/InMemoryFeedStore.swift @@ -0,0 +1,37 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public class InMemoryFeedStore { + private var feedCache: CachedFeed? + private var feedImageDataCache = NSCache() + + public init() {} +} + +extension InMemoryFeedStore: FeedStore { + public func deleteCachedFeed() throws { + feedCache = nil + } + + public func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { + feedCache = CachedFeed(feed: feed, timestamp: timestamp) + } + + public func retrieve() throws -> CachedFeed? { + feedCache + } +} + +extension InMemoryFeedStore: FeedImageDataStore { + public func insert(_ data: Data, for url: URL) throws { + feedImageDataCache.setObject(data as NSData, forKey: url as NSURL) + } + + public func retrieve(dataForURL url: URL) throws -> Data? { + feedImageDataCache.object(forKey: url as NSURL) as Data? + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift index c92c3ef..afa0332 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift @@ -6,59 +6,44 @@ import XCTest import EssentialFeed -class CoreDataFeedImageDataStoreTests: XCTestCase { +class CoreDataFeedImageDataStoreTests: XCTestCase, FeedImageDataStoreSpecs { func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { - try makeSUT { sut in - expect(sut, toCompleteRetrievalWith: notFound(), for: anyURL()) + try makeSUT { sut, imageDataURL in + self.assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache(on: sut, imageDataURL: imageDataURL) } } func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { - try makeSUT { sut in - let url = URL(string: "http://a-url.com")! - let nonMatchingURL = URL(string: "http://another-url.com")! - - insert(anyData(), for: url, into: sut) - - expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL) + try makeSUT { sut, imageDataURL in + self.assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch(on: sut, imageDataURL: imageDataURL) } } func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { - try makeSUT { sut in - let storedData = anyData() - let matchingURL = URL(string: "http://a-url.com")! - - insert(storedData, for: matchingURL, into: sut) - - expect(sut, toCompleteRetrievalWith: found(storedData), for: matchingURL) + try makeSUT { sut, imageDataURL in + self.assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL(on: sut, imageDataURL: imageDataURL) } } func test_retrieveImageData_deliversLastInsertedValue() throws { - try makeSUT { sut in - let firstStoredData = Data("first".utf8) - let lastStoredData = Data("last".utf8) - let url = URL(string: "http://a-url.com")! - - insert(firstStoredData, for: url, into: sut) - insert(lastStoredData, for: url, into: sut) - - expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: url) + try makeSUT { sut, imageDataURL in + self.assertThatRetrieveImageDataDeliversLastInsertedValueForURL(on: sut, imageDataURL: imageDataURL) } } - // - MARK: Helpers + // MARK: - Helpers - private func makeSUT(_ test: @escaping (CoreDataFeedStore) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { + private func makeSUT(_ test: @escaping (CoreDataFeedStore, URL) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { let storeURL = URL(fileURLWithPath: "/dev/null") let sut = try CoreDataFeedStore(storeURL: storeURL) trackForMemoryLeaks(sut, file: file, line: line) let exp = expectation(description: "wait for operation") sut.perform { - test(sut) + let imageDataURL = URL(string: "http://a-url.com")! + insertFeedImage(with: imageDataURL, into: sut, file: file, line: line) + test(sut, imageDataURL) exp.fulfill() } wait(for: [exp], timeout: 0.1) @@ -66,36 +51,11 @@ class CoreDataFeedImageDataStoreTests: XCTestCase { } -private func notFound() -> Result { - return .success(.none) -} - -private func found(_ data: Data) -> Result { - return .success(data) -} - -private func localImage(url: URL) -> LocalFeedImage { - return LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) -} - -private func expect(_ sut: CoreDataFeedStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #filePath, line: UInt = #line) { - let receivedResult = Result { try sut.retrieve(dataForURL: url) } - - switch (receivedResult, expectedResult) { - case let (.success( receivedData), .success(expectedData)): - XCTAssertEqual(receivedData, expectedData, file: file, line: line) - - default: - XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) - } -} - -private func insert(_ data: Data, for url: URL, into sut: CoreDataFeedStore, file: StaticString = #filePath, line: UInt = #line) { +private func insertFeedImage(with url: URL, into sut: CoreDataFeedStore, file: StaticString = #filePath, line: UInt = #line) { do { - let image = localImage(url: url) + let image = LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) try sut.insert([image], timestamp: Date()) - try sut.insert(data, for: url) } catch { - XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) + XCTFail("Failed to insert feed image with URL \(url) - error: \(error)", file: file, line: line) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/FeedImageDataStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/FeedImageDataStoreSpecs.swift new file mode 100644 index 0000000..155120a --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/FeedImageDataStoreSpecs.swift @@ -0,0 +1,13 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +protocol FeedImageDataStoreSpecs { + func test_retrieveImageData_deliversNotFoundWhenEmpty() throws + func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws + func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws + func test_retrieveImageData_deliversLastInsertedValue() throws +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift new file mode 100644 index 0000000..8a519eb --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift @@ -0,0 +1,94 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import Foundation +import EssentialFeed + +extension FeedImageDataStoreSpecs where Self: XCTestCase { + + func assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line + ) { + expect(sut, toCompleteRetrievalWith: notFound(), for: imageDataURL, file: file, line: line) + } + + func assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line + ) { + let nonMatchingURL = URL(string: "http://a-non-matching-url.com")! + + insert(anyData(), for: imageDataURL, into: sut, file: file, line: line) + + expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL, file: file, line: line) + } + + func assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line + ) { + let storedData = anyData() + + insert(storedData, for: imageDataURL, into: sut, file: file, line: line) + + expect(sut, toCompleteRetrievalWith: found(storedData), for: imageDataURL, file: file, line: line) + } + + func assertThatRetrieveImageDataDeliversLastInsertedValueForURL( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line + ) { + let firstStoredData = Data("first".utf8) + let lastStoredData = Data("last".utf8) + + insert(firstStoredData, for: imageDataURL, into: sut, file: file, line: line) + insert(lastStoredData, for: imageDataURL, into: sut, file: file, line: line) + + expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: imageDataURL, file: file, line: line) + } + +} + +extension FeedImageDataStoreSpecs where Self: XCTestCase { + + func notFound() -> Result { + .success(.none) + } + + func found(_ data: Data) -> Result { + .success(data) + } + + func expect(_ sut: FeedImageDataStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #filePath, line: UInt = #line) { + let receivedResult = Result { try sut.retrieve(dataForURL: url) } + + switch (receivedResult, expectedResult) { + case let (.success( receivedData), .success(expectedData)): + XCTAssertEqual(receivedData, expectedData, file: file, line: line) + + default: + XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) + } + } + + func insert(_ data: Data, for url: URL, into sut: FeedImageDataStore, file: StaticString = #filePath, line: UInt = #line) { + do { + try sut.insert(data, for: url) + } catch { + XCTFail("Failed to insert image data: \(data) - error: \(error)", file: file, line: line) + } + } + +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift new file mode 100644 index 0000000..aadd572 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift @@ -0,0 +1,43 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class InMemoryFeedImageDataStoreTests: XCTestCase, FeedImageDataStoreSpecs { + + func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { + let sut = makeSUT() + + assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache(on: sut) + } + + func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { + let sut = makeSUT() + + assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch(on: sut) + } + + func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { + let sut = makeSUT() + + assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL(on: sut) + } + + func test_retrieveImageData_deliversLastInsertedValue() throws { + let sut = makeSUT() + + assertThatRetrieveImageDataDeliversLastInsertedValueForURL(on: sut) + } + + // - MARK: Helpers + + private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> InMemoryFeedStore { + let sut = InMemoryFeedStore() + trackForMemoryLeaks(sut, file: file, line: line) + return sut + } + +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift new file mode 100644 index 0000000..d953827 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift @@ -0,0 +1,85 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class InMemoryFeedStoreTests: XCTestCase, FeedStoreSpecs { + + func test_retrieve_deliversEmptyOnEmptyCache() throws { + let sut = makeSUT() + + assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) + } + + func test_retrieve_hasNoSideEffectsOnEmptyCache() throws { + let sut = makeSUT() + + assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) + } + + func test_retrieve_deliversFoundValuesOnNonEmptyCache() throws { + let sut = makeSUT() + + assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) + } + + func test_retrieve_hasNoSideEffectsOnNonEmptyCache() throws { + let sut = makeSUT() + + assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) + } + + func test_insert_deliversNoErrorOnEmptyCache() throws { + let sut = makeSUT() + + assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + } + + func test_insert_deliversNoErrorOnNonEmptyCache() throws { + let sut = makeSUT() + + assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + } + + func test_insert_overridesPreviouslyInsertedCacheValues() throws { + let sut = makeSUT() + + assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) + } + + func test_delete_deliversNoErrorOnEmptyCache() throws { + let sut = makeSUT() + + assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + } + + func test_delete_hasNoSideEffectsOnEmptyCache() throws { + let sut = makeSUT() + + assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) + } + + func test_delete_deliversNoErrorOnNonEmptyCache() throws { + let sut = makeSUT() + + assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + } + + func test_delete_emptiesPreviouslyInsertedCache() throws { + let sut = makeSUT() + + assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) + } + + // - MARK: Helpers + + private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> InMemoryFeedStore { + let sut = InMemoryFeedStore() + trackForMemoryLeaks(sut, file: file, line: line) + return sut + } + +}